This commit is contained in:
super
2026-04-30 11:38:13 +08:00
362 changed files with 18232 additions and 485 deletions

111
.gitignore vendored
View File

@@ -1,47 +1,110 @@
# frontend-vue dependencies
# ===== frontend-vue =====
frontend-vue/node_modules/
# frontend-vue build output
frontend-vue/dist/
frontend-vue/new_web_source
# generated type declarations
frontend-vue/auto-imports.d.ts
frontend-vue/components.d.ts
# logs
frontend-vue/npm-debug.log*
frontend-vue/yarn-debug.log*
frontend-vue/yarn-error.log*
frontend-vue/pnpm-debug.log*
# backend-java build output
# ===== backend-java =====
backend-java/target/
# backend-java runtime data
backend-java/data/
backend-java/*.log
backend-java/logs/
logs/
aiimage-backend.log*
backend/tmp/
# backend-java local config
backend-java/src/main/resources/application-local.yml
# backend-java IDE files
backend-java/.idea/
backend-java/*.iml
# desktop app
desktop/
ERP-Demo/
xlsx/
app/__pycache__
# ===== backend / app =====
backend/tmp/
backend/__pycache__
app/__pycache__
app/assets/
app/new_web_source
app/user_data/
OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md
架构.md
# ===== logs =====
logs/
aiimage-backend.log*
# ===== Python =====
__pycache__/
**/__pycache__/
*.py[cod]
*$py.class
*.pyo
.Python
*.egg-info/
*.egg
MANIFEST
.installed.cfg
# Build / packaging
build/
dist/
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.python-version
# Testing
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
htmlcov/
# Type checking
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
# Jupyter
.ipynb_checkpoints
*.ipynb_checkpoints/
profile_default/
ipython_config.py
# ===== Database =====
*.db
*.sqlite3
# ===== Misc =====
desktop/
ERP-Demo/
xlsx/
.omx/
.codex
.rtk
OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md
架构.md
*ts.%

View File

@@ -1,4 +1,3 @@
base_url=http://8.136.19.173:15124
workflow_id=7608812635877900322
mysql_host=8.136.19.173
mysql_user=aiimage
@@ -12,10 +11,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
client_name=ShuFuAI
# java_api_base=http://47.111.163.154:18080
java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080
# java_api_base=http://121.196.149.225:18080
debug=true
java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080

18
app/.env.example Normal file
View File

@@ -0,0 +1,18 @@
workflow_id=7608812635877900322
mysql_host=8.136.19.173
mysql_user=aiimage
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
proxy_mode=2
zn_company=rongchuang123
zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
client_name=ShuFuAI
java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080

Binary file not shown.

Binary file not shown.

View File

@@ -1081,7 +1081,69 @@ class ApproveTask:
if shop_name in runing_shop:
del runing_shop[shop_name]
self.log(f"店铺 {shop_name} 已从执行列表中移除")
def action_init(self,driver,country_name,shop_name,risk_listing_filter):
"""切换到指定国际 -> 页面 -》 筛选好"""
max_retries = 5
switch_success = False
switch_success_pg = False
search_success = False
sku_ls = []
for retry in range(max_retries):
try:
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
if retry > 2:
# 刷新不行就重新打开店铺
self.log("重试前重新打开店铺...")
try:
driver.close_store()
time.sleep(3)
driver.open_shop(shop_name)
except Exception as e:
self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
# 如果不是第一次尝试,先刷新页面
if retry > 0:
self.log("重试前刷新页面...")
try:
driver.tab.refresh()
time.sleep(3)
except Exception as e:
self.log(f"刷新页面失败: {str(e)}", "WARNING")
switch_success = driver.SwitchingCountries(country_name)
if switch_success:
self.log(f"成功切换到国家 {country_name}")
# 切换到库存管理页面
driver.SwitchPage()
self.log(f"已切换到库存管理页面")
time.sleep(3)
switch_success_pg = True
sku_ls = driver.search(filter_type=risk_listing_filter)
search_success = True
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
break
else:
self.log(f"切换到国家 {country_name} 失败", "WARNING")
except Exception as e:
import traceback
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
# 如果还有重试机会,等待后继续
if retry < max_retries - 1:
time.sleep(2)
# 如果切换失败,直接返回
# if not switch_success or not switch_success_pg or not search_success:
# error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
# self.log(error_message, "ERROR")
# if task_id in runing_task:
# runing_task[task_id]["processed_countries"] += 1
return switch_success,switch_success_pg,search_success,sku_ls
def process_country(self, driver: AmzoneApprove, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str):
"""处理单个国家的审批任务
@@ -1108,87 +1170,98 @@ class ApproveTask:
max_retries = 3
switch_success = False
for retry in range(max_retries):
try:
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
if retry > 1:
# 刷新不行就重新打开店铺
self.log("重试前重新打开店铺...")
try:
driver.close_store()
time.sleep(3)
driver.open_shop(shop_name)
except Exception as e:
self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
# 如果不是第一次尝试,先刷新页面
if retry > 0:
self.log("重试前刷新页面...")
try:
driver.tab.refresh()
time.sleep(3)
except Exception as e:
self.log(f"刷新页面失败: {str(e)}", "WARNING")
switch_success = driver.SwitchingCountries(country_name)
if switch_success:
self.log(f"成功切换到国家 {country_name}")
break
else:
self.log(f"切换到国家 {country_name} 失败", "WARNING")
except Exception as e:
import traceback
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
# 如果还有重试机会,等待后继续
if retry < max_retries - 1:
time.sleep(2)
# for retry in range(max_retries):
# try:
# self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
# if retry > 1:
# # 刷新不行就重新打开店铺
# self.log("重试前重新打开店铺...")
# try:
# driver.close_store()
# time.sleep(3)
# driver.open_shop(shop_name)
# except Exception as e:
# self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
#
# # 如果不是第一次尝试,先刷新页面
# if retry > 0:
# self.log("重试前刷新页面...")
# try:
# driver.tab.refresh()
# time.sleep(3)
# except Exception as e:
# self.log(f"刷新页面失败: {str(e)}", "WARNING")
#
# switch_success = driver.SwitchingCountries(country_name)
# if switch_success:
# self.log(f"成功切换到国家 {country_name}")
# break
# else:
# self.log(f"切换到国家 {country_name} 失败", "WARNING")
#
# except Exception as e:
# import traceback
# self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
# self.log(traceback.format_exc(), "ERROR")
#
# # 如果还有重试机会,等待后继续
# if retry < max_retries - 1:
# time.sleep(2)
#
# # 如果切换失败,直接返回
# if not switch_success:
# error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
# self.log(error_message, "ERROR")
# if task_id in runing_task:
# runing_task[task_id]["processed_countries"] += 1
# return
#
# # 切换到库存管理页面
# try:
# driver.SwitchPage()
# self.log(f"已切换到库存管理页面")
# except Exception as e:
# import traceback
# self.log(f"切换页面失败: {str(e)}", "ERROR")
# self.log(traceback.format_exc(), "ERROR")
# if task_id in runing_task:
# runing_task[task_id]["processed_countries"] += 1
# return
#
# # 搜索需要审批的商品最多重试3次
# sku_ls = []
# for retry in range(max_retries):
# try:
# self.log(f"尝试搜索需要审批的商品 (第 {retry + 1}/{max_retries} 次)")
# sku_ls = driver.search(filter_type=risk_listing_filter)
# break
# except Exception as e:
# self.log(f"搜索商品异常: {str(e)}", "ERROR")
# if retry < max_retries - 1:
# try:
# driver.tab.refresh()
# time.sleep(3)
# except Exception as refresh_error:
# self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
#
max_retries = 5
switch_success, switch_success_pg, search_success,sku_ls = self.action_init(driver, country_name, shop_name,
risk_listing_filter)
# 如果切换失败,直接返回
if not switch_success:
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
if not switch_success or not switch_success_pg or not search_success:
error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
self.log(error_message, "ERROR")
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
return
# 切换到库存管理页面
try:
driver.SwitchPage()
self.log(f"已切换到库存管理页面")
except Exception as e:
import traceback
self.log(f"切换页面失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
return
# 搜索需要审批的商品最多重试3次
sku_ls = []
for retry in range(max_retries):
try:
self.log(f"尝试搜索需要审批的商品 (第 {retry + 1}/{max_retries} 次)")
sku_ls = driver.search(filter_type=risk_listing_filter)
break
except Exception as e:
self.log(f"搜索商品异常: {str(e)}", "ERROR")
if retry < max_retries - 1:
try:
driver.tab.refresh()
time.sleep(3)
except Exception as refresh_error:
self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
# 如果没有需要审批的商品,直接返回
# # 如果没有需要审批的商品,直接返回
if len(sku_ls) == 0:
self.log(f"国家 {country_name} 没有需要审批的商品")
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
return
self.log(f"国家 {country_name}{len(sku_ls)} 个需要审批的商品,开始处理...")
# 处理所有需要审批的商品通过yield获取结果

View File

@@ -537,8 +537,6 @@ class StatusTask:
self.log(f"切换页面失败重试退出", "ERROR")
return
# 处理所有需要审批的商品通过yield获取结果
# 指定 asin

760
app/amazon/base.py Normal file
View File

@@ -0,0 +1,760 @@
import json
import os
import subprocess
import time
import uuid
from typing import Any, Dict, List, Literal, Optional, TypedDict
from loguru import logger
import requests
from DrissionPage import Chromium
from DrissionPage._pages.chromium_tab import ChromiumTab
from DrissionPage.common import By
try:
import winreg
except ImportError:
winreg = None
STATUS_OK = "0"
STATUS_LOGIN_FAILED = "-10003"
DEFAULT_SOCKET_PORT = 19890
CLIENT_API_TIMEOUT = 120
PORT_CHECK_TIMEOUT = 2
UPDATE_CORE_RETRY_DELAY = 2
CLIENT_RESTART_DELAY = 5
CLIENT_START_RETRIES = 3
CLIENT_READY_TIMEOUT = 10
CLIENT_READY_INTERVAL = 0.5
CLIENT_POST_START_DELAY = 5
PROCESS_KILL_DELAY = 3
DOC_LOAD_TIMEOUT = 30
COUNTRY_INITIAL_LOAD_TIMEOUT = 120
COUNTRY_LOOKUP_TIMEOUT = 20
COUNTRY_CLICK_TIMEOUT = 10
COUNTRY_DROPDOWN_DELAY = 1
COUNTRY_DROPDOWN_READY_TIMEOUT = 20
COUNTRY_DROPDOWN_RETRY_INTERVAL = 0.5
IP_CHECK_TIMEOUT = 60
LOGIN_ATTEMPTS = 4
PASSWORD_INPUT_TIMEOUT = 5
PASSWORD_SUBMIT_TIMEOUT = 5
OTP_SEND_TIMEOUT = 5
OTP_INPUT_LOOKUP_ATTEMPTS = 5
OTP_INPUT_TIMEOUT = 30
OTP_INPUT_DISPLAY_TIMEOUT = 10
OTP_CODE_WAIT_SECONDS = 30
OTP_SUBMIT_TIMEOUT = 10
OTP_RESULT_LOAD_TIMEOUT = 20
OTP_ERROR_TIMEOUT = 10
OTP_SEND_DELAY = 1
LOGIN_FINAL_SUBMIT_TIMEOUT = 10
NEED_LOGIN_DELAY = 3
NEED_LOGIN_TIMEOUT = 5
COUNTRY_LABEL_XPATH = 'xpath://div[@class="dropdown-account-switcher-header-label"]/span[last()]'
COUNTRY_DROPDOWN_XPATH = 'xpath://div[@class="dropdown-account-switcher-header-label"]'
COUNTRY_LIST_ITEM_XPATH = 'xpath://div[@class="dropdown-account-switcher-list-item"]'
NEED_LOGIN_XPATH = 'xpath://h1[@class="a-spacing-small"]|//span[contains(text(),"登录")]'
PASSWORD_INPUT_XPATH = 'xpath://input[@type="password"]'
PASSWORD_SUBMIT_XPATH = 'xpath://input[@id="signInSubmit"]'
OTP_SEND_XPATH = 'xpath://span[@id="auth-send-code" and contains(string(.),"发送一次性密码")]'
OTP_INPUT_XPATH = 'xpath://input[@name="otpCode"]'
OTP_SUBMIT_XPATH = 'xpath://input[@id="auth-signin-button"]'
OTP_ERROR_XPATH = 'xpath://div[@id="auth-error-message-box"]'
class UserInfo(TypedDict):
username: str
password: str
company: str
def kill_process(version: Literal["v5", "v6"]):
"""结束指定版本的紫鸟客户端进程."""
logger.info("准备杀紫鸟客户端进程,version={}", version)
driver = ZiniaoDriver({})
driver.kill_process(version)
class ZiniaoDriver:
"""封装紫鸟客户端启动,店铺浏览器生命周期和客户端 API 调用."""
def __init__(self, user_info: UserInfo, socket_port: int = DEFAULT_SOCKET_PORT):
"""初始化紫鸟浏览器驱动.
Args:
user_info: 紫鸟账号信息,包含 company, username, password.
socket_port: 客户端 HTTP 通信端口.
"""
self.user_info = user_info
self.socket_port = socket_port
self.client_path = None
self.browser = None
self.tab: Optional[ChromiumTab] = None
self.store_id = None
@property
def client_url(self) -> str:
return f"http://127.0.0.1:{self.socket_port}"
def _build_payload(self, action: str, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
payload = {
"action": action,
"requestId": str(uuid.uuid4()),
}
if extra:
payload.update(extra)
payload.update(self.user_info)
return payload
def _post_client(self, payload: Dict[str, Any]) -> Dict[str, Any]:
response = requests.post(
self.client_url,
json.dumps(payload).encode("utf-8"),
timeout=CLIENT_API_TIMEOUT,
)
return response.json()
@staticmethod
def _status_code(result: Optional[Dict[str, Any]]) -> Optional[str]:
if result is None:
return None
return str(result.get("statusCode"))
@staticmethod
def _result_text(result: Dict[str, Any]) -> str:
return json.dumps(result, ensure_ascii=False)
@staticmethod
def _find_store_oauth(shop_list: Optional[List[Dict[str, Any]]], shop_name: str):
for shop in shop_list or []:
if shop.get("browserName") == shop_name:
return shop.get("browserOauth")
return None
def _is_client_ready(self) -> bool:
try:
requests.get(self.client_url, timeout=PORT_CHECK_TIMEOUT)
return True
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
return False
def _wait_until_client_ready(self, timeout: float) -> bool:
start_check_time = time.time()
while time.time() - start_check_time < timeout:
if self._is_client_ready():
return True
time.sleep(CLIENT_READY_INTERVAL)
return False
def get_zinaio_exe(self, protocol_name: str = "superbrowser"):
"""从 Windows 注册表读取紫鸟客户端可执行文件路径.
Args:
protocol_name: 注册表协议名称.
Returns:
紫鸟浏览器可执行文件路径. 未找到时返回 None.
"""
if winreg is None:
logger.error("当前系统不支持 winreg,无法从注册表获取紫鸟客户端路径")
return None
key_path = rf"SOFTWARE\Classes\{protocol_name}\shell\open\command"
for root_key, root_name in (
(winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER"),
(winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE"),
):
try:
logger.info("正在从注册表读取紫鸟客户端路径:{}\\{}", root_name, key_path)
key = winreg.OpenKey(root_key, key_path)
command, _ = winreg.QueryValueEx(key, "")
winreg.CloseKey(key)
if isinstance(command, str):
sub = "ziniao.exe"
exe_path = command[0 : command.find(sub) + len(sub) + 1]
else:
exe_path = command[0]
logger.info("已获取紫鸟客户端路径:{}", exe_path)
return exe_path
except FileNotFoundError:
logger.warning("注册表中未找到紫鸟客户端路径:{}\\{}", root_name, key_path)
logger.error("未能获取紫鸟客户端路径,protocol_name={}", protocol_name)
return None
def update_core(self):
"""更新紫鸟浏览器内核.
打开店铺前调用,要求客户端版本 5.285.7 以上. 接口可能因 HTTP 超时
返回未完成状态,因此会循环调用直到成功或判定客户端不支持.
"""
payload = self._build_payload("updateCore")
logger.info("开始更新紫鸟浏览器内核")
while True:
result = self._post_client(payload)
logger.info("更新内核返回:{}", result)
if self._handle_update_core_result(result):
return
if result is None:
continue
logger.info("等待更新内核完成:{}", self._result_text(result))
time.sleep(UPDATE_CORE_RETRY_DELAY)
def _handle_update_core_result(self, result: Optional[Dict[str, Any]]) -> bool:
if result is None:
logger.info("等待客户端启动后继续更新内核")
time.sleep(UPDATE_CORE_RETRY_DELAY)
return False
status_code = self._status_code(result)
if status_code is None or status_code == STATUS_LOGIN_FAILED:
logger.error("当前紫鸟客户端版本不支持更新内核接口,请升级客户端")
return True
if status_code == STATUS_OK:
logger.info("紫鸟浏览器内核更新完成")
return True
return False
def kill_process(self, version: Literal["v5", "v6"]):
"""结束指定版本的紫鸟客户端进程.
Args:
version: 客户端主版本.
"""
if version == "v5":
process_name = "SuperBrowser.exe"
logger.info("准备结束紫鸟 v5 starter.exe")
self._kill_process_by_name("starter.exe")
else:
process_name = "ziniao.exe"
logger.info("准备结束紫鸟客户端进程:{}", process_name)
self._kill_process_by_name(process_name)
time.sleep(PROCESS_KILL_DELAY)
def _kill_process_by_name(self, process_name: str) -> None:
result = subprocess.run(
["taskkill", "/f", "/t", "/im", process_name],
capture_output=True,
text=True,
encoding="utf-8",
errors="ignore",
check=False,
)
output = (result.stdout or result.stderr or "").strip()
if result.returncode == 0:
if output:
logger.info("结束进程返回:{}", output)
return
if "没有找到进程" in output or "not found" in output.lower():
logger.info("进程未运行,跳过结束:{}", process_name)
return
logger.warning("结束进程失败:{} | {}", process_name, output or f"returncode={result.returncode}")
def get_browser_list(self) -> Optional[list[Dict[str, Any]]]:
"""获取紫鸟浏览器店铺列表.
Returns:
浏览器店铺列表.登录失败或请求失败时返回 None.
"""
payload = self._build_payload("getBrowserList")
request_id = payload["requestId"]
logger.info("开始获取紫鸟浏览器列表,requestId={}", request_id)
result = self._post_client(payload)
status_code = self._status_code(result)
if status_code == STATUS_OK:
browser_list = result.get("browserList")
logger.info("获取紫鸟浏览器列表成功,数量={}", len(browser_list or []))
return browser_list
if status_code == STATUS_LOGIN_FAILED:
logger.error("获取紫鸟浏览器列表登录失败:{}", self._result_text(result))
return None
logger.error("获取紫鸟浏览器列表失败:{}", self._result_text(result))
return None
def open_store(
self,
store_info,
isWebDriverReadOnlyMode=0,
isprivacy=0,
isHeadless=0,
cookieTypeSave=0,
jsInfo="",
):
"""启动紫鸟店铺浏览器.
Args:
store_info: 店铺 browserId 或 browserOauth.
isWebDriverReadOnlyMode: WebDriver 只读模式开关.
isprivacy: 隐私模式开关.
isHeadless: 无头模式开关.
cookieTypeSave: Cookie 保存类型.
jsInfo: 注入的 JS 信息.
Returns:
紫鸟客户端返回的启动结果.
"""
payload = self._build_start_browser_payload(
store_info=store_info,
is_web_driver_read_only_mode=isWebDriverReadOnlyMode,
is_privacy=isprivacy,
is_headless=isHeadless,
cookie_type_save=cookieTypeSave,
js_info=jsInfo,
)
request_id = payload["requestId"]
logger.info("开始打开紫鸟店铺,store_info={},requestId={}", store_info, request_id)
result = self._post_client(payload)
status_code = self._status_code(result)
if status_code == STATUS_OK:
logger.info("紫鸟店铺打开成功,debuggingPort={}", result.get("debuggingPort"))
return result
if status_code == STATUS_LOGIN_FAILED:
logger.error("打开紫鸟店铺登录失败:{}", self._result_text(result))
raise RuntimeError(f"[open_store]登录失败 {self._result_text(result)}")
logger.error("打开紫鸟店铺失败:{}", self._result_text(result))
raise RuntimeError(f"[open_store]失败 {self._result_text(result)} ")
def _build_start_browser_payload(
self,
*,
store_info,
is_web_driver_read_only_mode=0,
is_privacy=0,
is_headless=0,
cookie_type_save=0,
js_info="",
) -> Dict[str, Any]:
payload = self._build_payload(
"startBrowser",
{
"isWaitPluginUpdate": 0,
"isHeadless": is_headless,
"isWebDriverReadOnlyMode": is_web_driver_read_only_mode,
"cookieTypeLoad": 0,
"cookieTypeSave": cookie_type_save,
"runMode": "1",
"isLoadUserPlugin": True,
"pluginIdType": 1,
"privacyMode": is_privacy,
},
)
if store_info.isdigit():
payload["browserId"] = store_info
else:
payload["browserOauth"] = store_info
if len(str(js_info)) > 2:
payload["injectJsInfo"] = json.dumps(js_info)
return payload
def get_browser(self, port) -> Chromium:
"""连接 DrissionPage 浏览器实例.
Args:
port: Chromium 调试端口.
Returns:
DrissionPage Chromium 实例.
"""
logger.info("开始连接 DrissionPage 浏览器,port={}", port)
browser = Chromium(port)
logger.info("DrissionPage 浏览器连接完成,port={}", port)
return browser
def start_client(self):
"""启动紫鸟客户端并更新浏览器内核."""
if self._is_client_ready():
logger.info("端口 {} 已启动,跳过启动紫鸟客户端", self.socket_port)
return
logger.info("端口 {} 未启动,开始启动紫鸟客户端", self.socket_port)
self.kill_process("v6")
time.sleep(CLIENT_RESTART_DELAY)
client_path = self.get_zinaio_exe("superbrowserv6")
if not client_path:
raise RuntimeError("未找到紫鸟客户端路径,无法启动客户端")
self.client_path = client_path.strip('"')
cmd = [
self.client_path,
"--run_type=web_driver",
"--ipc_type=http",
"--port=" + str(self.socket_port),
]
logger.info("紫鸟客户端启动命令:{}", " ".join(cmd))
for retry_count in range(CLIENT_START_RETRIES):
logger.info("{}/{} 次尝试启动紫鸟客户端", retry_count + 1, CLIENT_START_RETRIES)
subprocess.Popen(cmd)
if self._wait_until_client_ready(CLIENT_READY_TIMEOUT):
logger.info("紫鸟客户端启动成功,第 {} 次尝试", retry_count + 1)
time.sleep(CLIENT_POST_START_DELAY)
self.update_core()
return
logger.warning("{} 次尝试启动失败,10秒内未检测到客户端启动", retry_count + 1)
logger.error("紫鸟客户端启动失败,已重试 {}", CLIENT_START_RETRIES)
raise RuntimeError(f"客户端启动失败:重试 {CLIENT_START_RETRIES} 次后仍未成功启动")
def open_shop(self, shop_name: str):
"""打开指定店铺并完成 IP 检测.
Args:
shop_name: 店铺名称.
Returns:
成功时返回浏览器实例.店铺不存在时返回错误信息.
"""
logger.info("开始打开店铺:{}", shop_name)
self.start_client()
self.store_id = self._find_store_oauth(self.get_browser_list(), shop_name)
if not self.store_id:
logger.warning("店铺不存在:{}", shop_name)
return "店铺不存在"
ret_json = self.open_store(self.store_id)
self.store_id = ret_json.get("browserOauth")
if self.store_id is None:
self.store_id = ret_json.get("browserId")
self.browser = self.get_browser(ret_json.get("debuggingPort"))
ip_check_url = ret_json.get("ipDetectionPage")
if not ip_check_url:
logger.error("ip检测页地址为空,准备关闭店铺:{}", shop_name)
self.close_store(self.store_id)
raise RuntimeError("没有IP检测地址,为了店铺安全不打开店铺")
if self.open_ip_check(self.browser, ip_check_url):
logger.info("IP检测通过,打开店铺平台主页:{}", shop_name)
self.open_launcher_page(ret_json.get("launcherPage"), self.browser)
return self.browser
logger.error("IP检测不通过,停止打开店铺:{}", shop_name)
raise RuntimeError("IP检测不通过,可能是因为网络环境变化导致的,为了店铺安全不打开店铺")
def close_store(self, browser_oauth=None):
"""关闭紫鸟店铺浏览器.
Args:
browser_oauth: 店铺 OAuth 标识. 未提供时使用当前打开的店铺.
Returns:
紫鸟客户端返回的关闭结果.
"""
if browser_oauth is None:
browser_oauth = self.store_id
payload = self._build_payload(
"stopBrowser",
{
"duplicate": 0,
"browserOauth": browser_oauth,
},
)
request_id = payload["requestId"]
logger.info("开始关闭紫鸟店铺,browserOauth={},requestId={}", browser_oauth, request_id)
result = self._post_client(payload)
status_code = self._status_code(result)
if status_code == STATUS_OK:
logger.info("紫鸟店铺关闭成功,browserOauth={}", browser_oauth)
return result
if status_code == STATUS_LOGIN_FAILED:
logger.error("关闭紫鸟店铺登录失败:{}", self._result_text(result))
raise RuntimeError(f"【close_store】登录失败 {self._result_text(result)}")
logger.error("关闭紫鸟店铺失败:{}", self._result_text(result))
raise RuntimeError(f"[close_store]失败: {self._result_text(result)} ")
def open_launcher_page(self, launcher_page: str, browser: Chromium = None):
"""打开店铺平台启动页.
Args:
launcher_page: 要打开的启动页 URL.
browser: 浏览器实例. 未提供时使用当前浏览器实例.
"""
if browser is None:
browser = self.browser
logger.info("打开店铺平台启动页:{}", launcher_page)
tab = browser.new_tab(url=launcher_page)
self.tab = tab
return tab
def open_ip_check(self, browser: Chromium, ip_check_url: str):
"""打开 IP 检测页并判断网络环境是否通过.
Args:
browser: DrissionPage 浏览器会话.
ip_check_url: IP 检测页地址.
Returns:
IP 检测通过时返回 True,否则返回 False.
"""
try:
logger.info("开始打开IP检测页:{}", ip_check_url)
tab = browser.latest_tab
tab.get(ip_check_url)
success_button = tab.ele(
(By.XPATH, '//button[contains(@class, "styles_btn--success")]'),
timeout=IP_CHECK_TIMEOUT,
)
if success_button:
logger.info("IP检测成功")
return True
logger.warning("IP检测超时或未找到成功按钮")
return False
except Exception:
logger.exception("IP检测异常")
return False
class AmamzonBase(ZiniaoDriver):
"""亚马逊页面操作基类."""
def switch_to_country(self, country_name: str):
"""切换亚马逊账号国家并验证结果.
流程包括读取当前国家,打开国家下拉框,选择目标国家并等待页面加载.
Args:
country_name: 目标国家名称.
Returns:
切换成功返回 True,否则返回 False.
"""
try:
if self.browser is None:
logger.warning("浏览器实例不存在,请先打开店铺")
return False
self.tab.wait.doc_loaded(timeout=COUNTRY_INITIAL_LOAD_TIMEOUT, raise_err=False)
current_country = self._get_current_country(timeout=COUNTRY_LOOKUP_TIMEOUT)
if current_country is None:
logger.warning("无法获取当前国家信息")
return False
if current_country == country_name:
logger.info("当前已经是目标国家 {},无需切换", country_name)
return True
if not self._open_country_dropdown():
return False
if not self._select_country(country_name):
return False
logger.info("等待国家切换后页面加载")
self.tab.wait.doc_loaded()
return self._verify_country(country_name)
except Exception:
logger.exception("切换国家时发生异常")
return False
def _get_current_country(self, *, timeout: int) -> Optional[str]:
logger.info("正在检查当前国家")
current_country_ele = self.tab.ele(COUNTRY_LABEL_XPATH, timeout=timeout)
if not current_country_ele:
return None
current_country = current_country_ele.text.strip()
logger.info("当前国家:{}", current_country)
return current_country
def _open_country_dropdown(self) -> bool:
logger.info("正在打开国家切换下拉框")
dropdown_header = self.tab.ele(COUNTRY_DROPDOWN_XPATH, timeout=COUNTRY_CLICK_TIMEOUT)
if not dropdown_header:
logger.warning("找不到国家切换下拉框")
return False
logger.info("正在展开国家列表")
first_item = self._wait_country_list_item(dropdown_header)
if first_item is None:
logger.warning("找不到国家列表项")
return False
first_item.click()
time.sleep(COUNTRY_DROPDOWN_DELAY)
return True
def _wait_country_list_item(self, dropdown_header):
deadline = time.time() + COUNTRY_DROPDOWN_READY_TIMEOUT
attempt = 0
while time.time() < deadline:
attempt += 1
try:
dropdown_header.click()
except Exception as exc:
logger.info("点击国家切换下拉框失败, attempt={}, error={}", attempt, exc)
time.sleep(COUNTRY_DROPDOWN_DELAY)
first_item = self.tab.ele(COUNTRY_LIST_ITEM_XPATH, timeout=COUNTRY_DROPDOWN_RETRY_INTERVAL)
if first_item:
return first_item
self.tab.wait.doc_loaded(timeout=COUNTRY_CLICK_TIMEOUT, raise_err=False)
time.sleep(COUNTRY_DROPDOWN_RETRY_INTERVAL)
return None
def _select_country(self, country_name: str) -> bool:
logger.info("正在切换到国家:{}", country_name)
target_country = self.tab.ele(self._country_option_xpath(country_name), timeout=COUNTRY_CLICK_TIMEOUT)
if not target_country:
logger.warning("找不到目标国家:{}", country_name)
return False
target_country.click()
return True
@staticmethod
def _country_option_xpath(country_name: str) -> str:
return (
"xpath://div[@class="
'"dropdown-account-switcher-list-item dropdown-account-switcher-list-item-indented" '
f'and @title="{country_name}"]'
)
def _verify_country(self, country_name: str) -> bool:
new_country = self._get_current_country(timeout=COUNTRY_CLICK_TIMEOUT)
if new_country is None:
logger.warning("无法验证国家切换结果")
return False
if new_country == country_name:
logger.info("国家切换成功:{}", new_country)
return True
logger.warning("国家切换失败,当前国家:{},目标国家:{}", new_country, country_name)
return False
def need_login(self):
"""判断当前页面是否需要登录."""
time.sleep(NEED_LOGIN_DELAY)
self.tab.wait.doc_loaded(timeout=DOC_LOAD_TIMEOUT, raise_err=False)
need_login_ele = self.tab.eles(NEED_LOGIN_XPATH, timeout=NEED_LOGIN_TIMEOUT)
if len(need_login_ele) > 0:
logger.info("检测到需要登录元素")
return True
logger.info("未检测到登录元素")
return False
def login(self, password, username=""):
"""登录当前页面,必要时等待并提交一次性验证码.
Args:
password: 登录密码.
username: 兼容保留参数,当前未使用.
Returns:
登录成功返回 True,失败返回 False.
"""
try:
self.tab.wait.doc_loaded(timeout=DOC_LOAD_TIMEOUT, raise_err=False)
for _ in range(LOGIN_ATTEMPTS):
self._submit_password_if_present(password)
self.tab.wait.doc_loaded(timeout=DOC_LOAD_TIMEOUT, raise_err=False)
if self._send_otp_if_present():
continue
if self._submit_ready_otp():
return True
self._click_final_login_submit_if_present()
except Exception:
logger.exception("登录过程中发生异常")
return False
def _submit_password_if_present(self, password) -> None:
pwd_input = self.tab.eles(PASSWORD_INPUT_XPATH, timeout=PASSWORD_INPUT_TIMEOUT)
if len(pwd_input) > 0:
logger.info("检测到密码输入框,准备输入密码")
pwd_input[0].input(password, clear=True)
submit_btn = self.tab.eles(PASSWORD_SUBMIT_XPATH, timeout=PASSWORD_SUBMIT_TIMEOUT)
if len(submit_btn) > 0:
logger.info("点击登录提交按钮")
submit_btn[0].click()
def _send_otp_if_present(self) -> bool:
send_code = self.tab.eles(OTP_SEND_XPATH, timeout=OTP_SEND_TIMEOUT)
logger.info("发送一次性密码元素数量:{}", len(send_code))
if len(send_code) == 0:
return False
logger.info("检测到发送一次性密码按钮,准备点击")
send_code[0].click()
time.sleep(OTP_SEND_DELAY)
self.tab.wait.doc_loaded(timeout=DOC_LOAD_TIMEOUT, raise_err=False)
return True
def _submit_ready_otp(self) -> bool:
for _ in range(OTP_INPUT_LOOKUP_ATTEMPTS):
otp_input = self._find_otp_input()
if otp_input is None:
return False
if self._wait_for_otp_value(otp_input) and self._submit_otp_if_ready():
return True
return False
def _find_otp_input(self):
otp_code_input = self.tab.eles(OTP_INPUT_XPATH, timeout=OTP_INPUT_TIMEOUT)
logger.info("验证码输入框数量:{}", len(otp_code_input))
if len(otp_code_input) == 0:
return None
otp_code_input[0].wait.displayed(timeout=OTP_INPUT_DISPLAY_TIMEOUT, raise_err=False)
return otp_code_input[0]
@staticmethod
def _has_otp_value(otp_input) -> bool:
return otp_input.value is not None and otp_input.value.strip() != ""
def _wait_for_otp_value(self, otp_input) -> bool:
for _ in range(OTP_CODE_WAIT_SECONDS):
if self._has_otp_value(otp_input):
logger.info("检测到验证码输入完成")
return True
time.sleep(1)
return False
def _submit_otp_if_ready(self) -> bool:
submit_btn = self.tab.eles(OTP_SUBMIT_XPATH, timeout=OTP_SUBMIT_TIMEOUT)
if len(submit_btn) == 0:
return False
submit_btn[0].click()
self.tab.wait.doc_loaded(timeout=OTP_RESULT_LOAD_TIMEOUT, raise_err=False)
error_mes = self.tab.eles(OTP_ERROR_XPATH, timeout=OTP_ERROR_TIMEOUT)
if len(error_mes) > 0:
logger.warning("验证码输入错误:{}", error_mes[0].text)
self.tab.refresh()
return False
logger.info("登录成功")
return True
def _click_final_login_submit_if_present(self) -> None:
submit_btn = self.tab.eles(OTP_SUBMIT_XPATH, timeout=LOGIN_FINAL_SUBMIT_TIMEOUT)
if len(submit_btn) > 0:
logger.info("点击二次登录提交按钮")
submit_btn[0].click()

View File

@@ -8,11 +8,12 @@ import json
import os
from typing import Literal
from DrissionPage import Chromium
from DrissionPage import Chromium,ChromiumOptions
from DrissionPage.common import By
from DrissionPage._pages.chromium_tab import ChromiumTab
def kill_process(version: Literal["v5", "v6"]):
"""杀紫鸟客户端进程(独立函数版本)"""
driver = ZiniaoDriver({})
@@ -205,7 +206,11 @@ class ZiniaoDriver:
Returns:
Chromium: DrissionPage浏览器实例
"""
browser = Chromium(port)
co = ChromiumOptions()
# 设置不加载图片、静音
co.set_local_port(port)
co.no_imgs(True).mute(True)
browser = Chromium(co)
return browser
def start_client(self):

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ from amazon.approve import ApproveTask
from amazon.match_action import MatchTak
from amazon.price_match import PriceTask
from amazon.asin_status import StatusTask
from amazon.patrol_delete import PatrolDeleteTask
from amazon.detail_spider import SpiderTask
@@ -59,6 +60,7 @@ class TaskMonitor:
"shop-match-run" : "匹配价格",
"price-track-run" : "跟价",
"query-asin-run" : "状态查询",
"patrol-delete-run" : "巡店删除",
"appearance-patent-run" : "亚马逊采集",
}
try:
@@ -128,6 +130,7 @@ class TaskMonitor:
"匹配价格" : MatchTak,
"跟价" : PriceTask,
"状态查询" : StatusTask,
"巡店删除" : PatrolDeleteTask,
"亚马逊采集" : SpiderTask,
}
self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...")

View File

@@ -468,6 +468,70 @@ class MatchTak:
del runing_shop[shop_name]
self.log(f"店铺 {shop_name} 已从执行列表中移除")
def action_init(self,driver,country_name,shop_name,risk_listing_filter):
"""切换到指定国际 -> 页面 -》 筛选好"""
max_retries = 5
switch_success = False
switch_success_pg = False
search_success = False
sku_ls = []
for retry in range(max_retries):
try:
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
if retry > 2:
# 刷新不行就重新打开店铺
self.log("重试前重新打开店铺...")
try:
driver.close_store()
time.sleep(3)
driver.open_shop(shop_name)
except Exception as e:
self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
# 如果不是第一次尝试,先刷新页面
if retry > 0:
self.log("重试前刷新页面...")
try:
driver.tab.refresh()
time.sleep(3)
except Exception as e:
self.log(f"刷新页面失败: {str(e)}", "WARNING")
switch_success = driver.SwitchingCountries(country_name)
if switch_success:
self.log(f"成功切换到国家 {country_name}")
# 切换到库存管理页面
driver.SwitchPage()
self.log(f"已切换到库存管理页面")
time.sleep(3)
switch_success_pg = True
sku_ls = driver.search(filter_type=risk_listing_filter)
search_success = True
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
break
else:
self.log(f"切换到国家 {country_name} 失败", "WARNING")
except Exception as e:
import traceback
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
# 如果还有重试机会,等待后继续
if retry < max_retries - 1:
time.sleep(2)
# 如果切换失败,直接返回
# if not switch_success or not switch_success_pg or not search_success:
# error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
# self.log(error_message, "ERROR")
# if task_id in runing_task:
# runing_task[task_id]["processed_countries"] += 1
return switch_success,switch_success_pg,search_success,sku_ls
def process_country(self, driver: AmzoneMatchAction, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str,limit:str=None):
"""处理单个国家的审批任务
@@ -491,90 +555,108 @@ class MatchTak:
runing_task[task_id]["current_country"] = country_name
# 切换国家最多重试3次
max_retries = 3
switch_success = False
for retry in range(max_retries):
try:
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
if retry > 1:
# 刷新不行就重新打开店铺
self.log("重试前重新打开店铺...")
try:
driver.close_store()
time.sleep(3)
driver.open_shop(shop_name)
except Exception as e:
self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
# 如果不是第一次尝试,先刷新页面
if retry > 0:
self.log("重试前刷新页面...")
try:
driver.tab.refresh()
time.sleep(3)
except Exception as e:
self.log(f"刷新页面失败: {str(e)}", "WARNING")
switch_success = driver.SwitchingCountries(country_name)
if switch_success:
self.log(f"成功切换到国家 {country_name}")
break
else:
self.log(f"切换到国家 {country_name} 失败", "WARNING")
except Exception as e:
import traceback
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
# 如果还有重试机会,等待后继续
if retry < max_retries - 1:
time.sleep(2)
# max_retries = 3
# switch_success = False
#
# for retry in range(max_retries):
# try:
# self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
# if retry > 1:
# # 刷新不行就重新打开店铺
# self.log("重试前重新打开店铺...")
# try:
# driver.close_store()
# time.sleep(3)
# driver.open_shop(shop_name)
# except Exception as e:
# self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
#
# # 如果不是第一次尝试,先刷新页面
# if retry > 0:
# self.log("重试前刷新页面...")
# try:
# driver.tab.refresh()
# time.sleep(3)
# except Exception as e:
# self.log(f"刷新页面失败: {str(e)}", "WARNING")
#
# switch_success = driver.SwitchingCountries(country_name)
# if switch_success:
# self.log(f"成功切换到国家 {country_name}")
# break
# else:
# self.log(f"切换到国家 {country_name} 失败", "WARNING")
#
# except Exception as e:
# import traceback
# self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
# self.log(traceback.format_exc(), "ERROR")
#
# # 如果还有重试机会,等待后继续
# if retry < max_retries - 1:
# time.sleep(2)
#
# # 如果切换失败,直接返回
# if not switch_success:
# error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
# self.log(error_message, "ERROR")
# if task_id in runing_task:
# runing_task[task_id]["processed_countries"] += 1
# return
#
# # 切换到库存管理页面
# try:
# driver.SwitchPage()
# self.log(f"已切换到库存管理页面")
# except Exception as e:
# import traceback
# self.log(f"切换页面失败: {str(e)}", "ERROR")
# self.log(traceback.format_exc(), "ERROR")
# if task_id in runing_task:
# runing_task[task_id]["processed_countries"] += 1
# return
#
# # 搜索需要审批的商品最多重试3次
# sku_ls = []
# for retry in range(max_retries):
# try:
# self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
# sku_ls = driver.search(filter_type=risk_listing_filter)
# break
# except Exception as e:
# self.log(f"搜索商品异常: {str(e)}", "ERROR")
# if retry < max_retries - 1:
# try:
# driver.tab.refresh()
# time.sleep(3)
# except Exception as refresh_error:
# self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
#
# # 如果没有需要审批的商品,直接返回
# if len(sku_ls) == 0:
# self.log(f"国家 {country_name} 没有搜索出的商品")
# if task_id in runing_task:
# runing_task[task_id]["processed_countries"] += 1
# return
#
max_retries = 5
switch_success, switch_success_pg, search_success, sku_ls = self.action_init(driver, country_name, shop_name,
risk_listing_filter)
# 如果切换失败,直接返回
if not switch_success:
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
if not switch_success or not switch_success_pg or not search_success:
error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
self.log(error_message, "ERROR")
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
return
# 切换到库存管理页面
try:
driver.SwitchPage()
self.log(f"已切换到库存管理页面")
except Exception as e:
import traceback
self.log(f"切换页面失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
return
# 搜索需要审批的商品最多重试3次
sku_ls = []
for retry in range(max_retries):
try:
self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
sku_ls = driver.search(filter_type=risk_listing_filter)
break
except Exception as e:
self.log(f"搜索商品异常: {str(e)}", "ERROR")
if retry < max_retries - 1:
try:
driver.tab.refresh()
time.sleep(3)
except Exception as refresh_error:
self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
# 如果没有需要审批的商品,直接返回
# # 如果没有需要审批的商品,直接返回
if len(sku_ls) == 0:
self.log(f"国家 {country_name} 没有搜索出的商品")
self.log(f"国家 {country_name} 没有需要审批的商品")
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
return
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
# 处理所有需要审批的商品通过yield获取结果

1882
app/amazon/patrol_delete.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,28 +16,32 @@ from amazon.tool import show_notification,get_shop_info,remove_special_character
from config import runing_task, runing_shop,base_dir,DELETE_BRAND_API_BASE
class RepricingLogic:
@staticmethod
def situation_1_general_decrease(my_price, rank1_price, is_first_time=False):
"""
情况1常规递减跟价 (最左侧独立长图块)
当不是自己购物车时,按总价当作第一名递减。
"""
if is_first_time:
return rank1_price - 0.3
diff = my_price - rank1_price
# 这里的 diff 算出的是一个正数,代表“我比第一名低多少”
diff = rank1_price - my_price
# 精简了原图中长串的阶梯逻辑
# 如果当前价格高于或等于第一名默认按照第一名减0.3处理
if diff <= 0:
return rank1_price - 0.3
# 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值)
# 例如差价0.7,则相当于 第一名价格 - (0.7 + 0.5) = 第一名价格 - 1.2
if diff <= 0.3:
return rank1_price - 0.5
return rank1_price - (diff + 0.5)
elif diff <= 0.8:
return rank1_price - 0.7
return rank1_price - (diff + 0.7)
elif diff <= 10.5:
# 0.8 到 10.5 之间,原图逻辑全部是减1
return rank1_price - 1.0
# 0.8 到 10.5 之间全部是减1
return rank1_price - (diff + 1.0)
else:
return None # 相差10.5以上,跳过
@@ -45,55 +49,56 @@ class RepricingLogic:
def situation_2_own_buybox(my_price, rank2_price):
"""
情况2已经是自己购物车
逻辑:判断与第二名的差价,如果拉开足够差距,则稍微降一点点(减0.5)保持优势,防止卖太便宜。
"""
diff = rank2_price - my_price
# 1. 绝对差价2欧以上
if diff >= 2.0:
return rank2_price - 0.3
# 2. 产品售价区间判定 (是否要提高价格)
if (20 <= my_price < 30 and diff >= 4) or \
(30 <= my_price < 60 and diff >= 8) or \
(60 <= my_price <= 150 and diff >= 15):
return rank2_price - 0.3 # 满足区间大差价,提价至第二名之下
return rank2_price - 0.3
return my_price # 不满足条件则保持原价
return my_price
@staticmethod
def situation_3_not_own_buybox(my_price, rank1_price):
"""
情况3不是自己购物车 (常规情况)
情况3不是自己购物车 (除Amazon US外)
"""
diff = my_price - rank1_price
diff = rank1_price - my_price
if diff <= 0.3:
return rank1_price - 0.5
elif 0.5 <= diff <= 0.8:
return rank1_price - 0.7
elif 0.8 < diff <= 2.5:
return rank1_price - 1.0
elif diff > 2.5: # 2.5-3.5以上跳过
return None
if diff <= 0:
return rank1_price - 0.3
# 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值)
if diff <= 0.3:
return rank1_price - (diff + 0.5)
elif diff <= 0.8:
return rank1_price - (diff + 0.7)
elif diff <= 2.5:
return rank1_price - (diff + 1.0)
else:
return None # 2.5-3.5以上跳过
return rank1_price - 0.3 # 默认按第一名减0.3
@staticmethod
def situation_4_encounter_amz_us_1st(my_price, amz_us_price):
"""
情况4第一名是 Amazon US 卖家
情况4遇到第一名是 Amazon US 卖家
"""
diff = my_price - amz_us_price
diff = amz_us_price - my_price
if diff <= 3:
return amz_us_price - 2
elif 4 <= diff <= 6:
if diff <= 0:
return amz_us_price - 3
elif 8 <= diff <= 12:
return None # 跳过不跟价
return amz_us_price - 3 # 默认直接按照 Amazon US 减去3
# 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值)
if diff <= 3:
return amz_us_price - (diff + 2)
elif diff <= 6:
return amz_us_price - (diff + 3)
else:
return None # 超过6直接跳过
@staticmethod
def situation_5_im_1st_amz_us_2nd(my_price, amz_us_price):
@@ -103,7 +108,7 @@ class RepricingLogic:
diff = amz_us_price - my_price
if diff <= 7:
return None # 相差5以内跳过 (保持原价)
return None # 相差7以内跳过 (保持原价)
else:
return amz_us_price - 5
@@ -133,16 +138,6 @@ def calculate_target_price(
price_col = recommended_price + recommended_shipping
print(f"【紫鸟后台价格】{is_my_buybox} 推荐价格: {recommended_price},推荐运费: {recommended_shipping},总和: {price_col}")
return RepricingLogic.situation_1_general_decrease(my_price,price_col,is_my_buybox)
# backend_base_price = recommended_price
# backend_shipping = recommended_shipping
# total_price = backend_base_price + backend_shipping
#
# if is_my_buybox:
# # 🌟【逻辑更新】:如果是自己的购物车,直接跳过,不做任何价格调整
# return None
# else:
# # 不是自己的购物车:直接将紫鸟的总和作为"第一名"价格,强制抛入阶梯跟价逻辑
# return calculate_standard_competitor_pricing(my_price, total_price)
# # 第一名是自己, 第二名 Amazon US 卖家 的情况
shop_name_1 = front_end_data.get("top_sellers")[0].get("shop_name")
@@ -152,13 +147,11 @@ def calculate_target_price(
return RepricingLogic.situation_5_im_1st_amz_us_2nd(my_price,price_2)
# --- Step 3: 常规核心分流逻辑 (如果没有紫鸟后台数据) ---
# 分支 A第一名是 Amazon US 卖家 (特殊强敌优先)
price_1 = float(front_end_data.get("top_sellers")[0].get("price")) # 实际第一名价格
if "Amazon" in shop_name_1:
return RepricingLogic.situation_4_encounter_amz_us_1st(my_price,price_1)
# 分支 B不是 Amazon US且目前是自己的购物车
elif is_my_buybox:
price_2 = float(front_end_data.get("top_sellers")[1].get("price")) # 实际第二名价格
@@ -625,7 +618,7 @@ class AmzonePriceMatch(AmamzonBase):
if len(page_pamel) > 0:
current_page = page_pamel[0].sr('xpath:.//ul[@class="pages"]//li[@aria-current="true"]').text
#测试=====
# if int(current_page) > 3:
# if int(current_page) > 3:c
# break
# 测试===========
@@ -650,6 +643,13 @@ class AmzonePriceMatch(AmamzonBase):
print(f"{self.mark_name}】获取到 {len(sku_ls)}")
if len(sku_ls) == 0:
if mode == "asin":
print(f"asin 模式查询不到,{asin}")
yield (asin, {
"statu": "查询不到"
})
break
retry_num += 1
print(f"没有获取到 ASIN 商品,重试{retry_num}/{max_retry_num}")
self.tab.refresh()
@@ -680,19 +680,16 @@ class AmzonePriceMatch(AmamzonBase):
bottom_price = f"{sum(split_currency_values(bottom_price_text))}"
except Exception as e:
print("最低价提取失败",e)
current_price_ele = sku_ele.eles('xpath:.//b[text()="价格"]/../..//kat-input',timeout=5)
if len(current_price_ele) > 0:
current_price = current_price_ele[0].attr("value")
print("获取到当前价格为 ->",current_price)
# 如果紫鸟里当前价格低于最低价则删除,否则跳过
if mode == "status" and miniprice_info.get(asin):
# if mode == "status" and miniprice_info.get(asin):
if miniprice_info.get(asin):
backend_price = float(miniprice_info.get(asin))
if float(current_price) <= backend_price:
# yield (asin, {
# "statu": "删除(当前价格低于最低价)",
# "deleteSkipAsin": True,
# "removeAsin": asin,
# })
yield (asin, {
"statu": "跳过(当前价格低于或等于最低价)",
"currentPrice": current_price,
@@ -764,6 +761,9 @@ class AmzonePriceMatch(AmamzonBase):
print(asin,"-->>",f"【逻辑计算后的价格】",adjust_prices)
if adjust_prices is not None and adjust_prices != float(current_price):
#修改价格
print("准备填入的原始值:",adjust_prices)
adjust_prices = round(adjust_prices, 2)
print("保留两位小数后的::",adjust_prices)
self.tab.actions.click(current_price_ele[0])
time.sleep(1)
current_price_ele[0].input(f"{adjust_prices}", clear=True)
@@ -784,13 +784,13 @@ class AmzonePriceMatch(AmamzonBase):
"statu" : "改价成功",
"currentPrice" : current_price,
"recommendedPrice" : f"{recommendedPrice}",
"minimumPrice" : bottom_price, #最低价格
"minimumPrice" : f"{miniprice_info.get(asin)}" if miniprice_info.get(asin) else "",#bottom_price, #最低价格
"firstPlace": price_1,
"secondPlace": price_2,
"cartShopName" : cartShopName,
"shippingFee" : f"{shipping_fee}",
"priceChangeStatus" : f"{adjust_prices}",
"modifyCount" : "1",
"priceChangePrice" : f"{adjust_prices}",
"priceChangeStatus" : "改价成功",
})
else:
recommendedPrice = recommend_price + shipping_fee
@@ -803,13 +803,14 @@ class AmzonePriceMatch(AmamzonBase):
"statu": "跳过,无需改价",
"currentPrice": current_price,
"recommendedPrice": f"{recommendedPrice}",
"minimumPrice": bottom_price, # 最低价格
# "minimumPrice": bottom_price, # 最低价格
"minimumPrice": f"{miniprice_info.get(asin)}" if miniprice_info.get(asin) else "",
"firstPlace": price_1,
"secondPlace": price_2,
"cartShopName": cartShopName,
"shippingFee": f"{shipping_fee}",
"priceChangeStatus": "",
"modifyCount": "0",
"priceChangeStatus": "跳过,无需改价",
"priceChangePrice": f"{adjust_prices}".replace("None",""),
})
already_asin.add(asin)
@@ -1030,6 +1031,68 @@ class PriceTask:
return driver
return driver
def action_init(self,driver,country_name,shop_name,risk_listing_filter):
"""切换到指定国际 -> 页面 -》 筛选好"""
max_retries = 5
switch_success = False
switch_success_pg = False
search_success = False
sku_ls = []
for retry in range(max_retries):
try:
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
if retry > 2:
# 刷新不行就重新打开店铺
self.log("重试前重新打开店铺...")
try:
driver.close_store()
time.sleep(3)
driver.open_shop(shop_name)
except Exception as e:
self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
# 如果不是第一次尝试,先刷新页面
if retry > 0:
self.log("重试前刷新页面...")
try:
driver.tab.refresh()
time.sleep(3)
except Exception as e:
self.log(f"刷新页面失败: {str(e)}", "WARNING")
switch_success = driver.SwitchingCountries(country_name)
if switch_success:
self.log(f"成功切换到国家 {country_name}")
# 切换到库存管理页面
driver.SwitchPage()
self.log(f"已切换到库存管理页面")
time.sleep(3)
switch_success_pg = True
sku_ls = driver.search(filter_type=risk_listing_filter)
search_success = True
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
break
else:
self.log(f"切换到国家 {country_name} 失败", "WARNING")
except Exception as e:
import traceback
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
# 如果还有重试机会,等待后继续
if retry < max_retries - 1:
time.sleep(2)
# 如果切换失败,直接返回
# if not switch_success or not switch_success_pg or not search_success:
# error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
# self.log(error_message, "ERROR")
# if task_id in runing_task:
# runing_task[task_id]["processed_countries"] += 1
return switch_success,switch_success_pg,search_success
def process_shop(self, shop_item: dict, country_codes: list, task_id: int, risk_listing_filter: str,
user_id=None, stage_index=None, final_stage: bool = True, limit: str = None):
@@ -1150,94 +1213,42 @@ class PriceTask:
runing_task[task_id]["current_country"] = country_name
# 切换国家最多重试3次
max_retries = 3
switch_success = False
for retry in range(max_retries):
try:
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
if retry > 1:
# 刷新不行就重新打开店铺
self.log("重试前重新打开店铺...")
try:
driver.close_store()
time.sleep(3)
driver.open_shop(shop_name)
except Exception as e:
self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
# 如果不是第一次尝试,先刷新页面
if retry > 0:
self.log("重试前刷新页面...")
try:
driver.tab.refresh()
time.sleep(3)
except Exception as e:
self.log(f"刷新页面失败: {str(e)}", "WARNING")
switch_success = driver.SwitchingCountries(country_name)
if switch_success:
self.log(f"成功切换到国家 {country_name}")
break
else:
self.log(f"切换到国家 {country_name} 失败", "WARNING")
except Exception as e:
import traceback
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
# 如果还有重试机会,等待后继续
if retry < max_retries - 1:
time.sleep(2)
max_retries = 5
switch_success,switch_success_pg,search_success = self.action_init( driver, country_name, shop_name, risk_listing_filter)
# 如果切换失败,直接返回
if not switch_success:
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
if not switch_success or not switch_success_pg or not search_success:
error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
self.log(error_message, "ERROR")
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
return
# 切换到库存管理页面
for retry in range(max_retries):
try:
driver.SwitchPage()
self.log(f"已切换到库存管理页面")
except Exception as e:
import traceback
self.log(f"切换页面失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
if retry >= max_retries-1:
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
self.log(f"切换页面失败重试退出", "ERROR")
return
# 搜索需要审批的商品最多重试3次
sku_ls = []
for retry in range(max_retries):
try:
self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
sku_ls = driver.search(filter_type=risk_listing_filter)
break
except Exception as e:
self.log(f"搜索商品异常: {str(e)}", "ERROR")
if retry < max_retries - 1:
try:
driver.tab.refresh()
time.sleep(3)
except Exception as refresh_error:
self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
# sku_ls = []
# for retry in range(max_retries):
# try:
# self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
# sku_ls = driver.search(filter_type=risk_listing_filter)
# break
# except Exception as e:
# self.log(f"搜索商品异常: {str(e)}", "ERROR")
# if retry < max_retries - 1:
# try:
# driver.tab.refresh()
# driver.tab.wait.doc_loaded()
# time.sleep(3)
# except Exception as refresh_error:
# self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
#
# # 如果没有需要审批的商品,直接返回
# if len(sku_ls) == 0:
# self.log(f"国家 {country_name} 没有搜索出的商品")
# if task_id in runing_task:
# runing_task[task_id]["processed_countries"] += 1
# return
# 如果没有需要审批的商品,直接返回
if len(sku_ls) == 0:
self.log(f"国家 {country_name} 没有搜索出的商品")
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
return
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
self.log(f"国家 {country_name} ,开始处理...")
# 处理所有需要审批的商品通过yield获取结果
@@ -1333,36 +1344,35 @@ class PriceTask:
"""
url = f"{DELETE_BRAND_API_BASE}/api/price-track/tasks/{task_id}/result"
country_aliases = {name: code for code, name in self.country_info.items()}
country_key = country_aliases.get(str(country_code).strip(), str(country_code).strip())
countries = {}
if asin or status:
countries[country_key] = [
{
"shopMallName": shopMallName,
"asin": asin,
"price": status.get("currentPrice") if status.get("currentPrice") else "",
"recommendedPrice": status.get("recommendedPrice") if status.get("recommendedPrice") else "",
"minimumPrice": status.get("minimumPrice") if status.get("minimumPrice") else "",
"firstPlace": status.get("firstPlace") if status.get("firstPlace") else "",
"secondPlace": status.get("secondPlace") if status.get("secondPlace") else "",
"cartShopName": status.get("cartShopName") if status.get("cartShopName") else "",
"priceChangeStatus": status.get("priceChangeStatus") if status.get("priceChangeStatus") else "",
"deleteSkipAsin": status.get("deleteSkipAsin", False),
"removeAsin": status.get("removeAsin") if status.get("removeAsin") else "",
"modifyCount": status.get("modifyCount") if status.get("modifyCount") else "",
"shippingFee": status.get("shippingFee") if status.get("shippingFee") else "",
"status": status.get("statu")
}
]
payload = {
"shops": [
{
"shopName": shop_name,
"countries": countries,
"countries": {
country_key : [
{
"shopMallName": shopMallName,
"asin": asin,
"price": status.get("currentPrice") if status.get("currentPrice") else "",
"recommendedPrice": status.get("recommendedPrice") if status.get("recommendedPrice") else "",
"minimumPrice": status.get("minimumPrice") if status.get("minimumPrice") else "",
"firstPlace": status.get("firstPlace") if status.get("firstPlace") else "",
"secondPlace": status.get("secondPlace") if status.get("secondPlace") else "",
"cartShopName": status.get("cartShopName") if status.get("cartShopName") else "",
# "priceChangeStatus": "UPDATED",
"deleteSkipAsin": status.get("deleteSkipAsin",False),
"removeAsin": status.get("removeAsin") if status.get("removeAsin") else "",
"modifyCount": "1",
"shippingFee" : status.get("shippingFee") if status.get("shippingFee") else "",
"status": status.get("statu"),
"priceChangeStatus" : status.get("priceChangeStatus") if status.get("priceChangeStatus") else "",
"priceChangePrice" : status.get("priceChangePrice") if status.get("priceChangePrice") else "",
}
]
},
"error": ""
}
]
@@ -1408,20 +1418,20 @@ class PriceTask:
if __name__ == '__main__':
# 使用示例
# user_info = {
# "company": "rongchuang123",
# "company": "尾号5578的公司115",
# "username": "自动化_Robot",
# "password": "#20zsg25"
# }
# shop_name = "郭亚芳"
# country = "国"
# shop_name = "刘建煌"
# country = "国"
# kill_process('v6')
# driver = AmzonePriceMatch(user_info)
# browser = driver.open_shop(shop_name)
# sw_suc = driver.SwitchingCountries(country)
# driver.SwitchPage()
# risk_listing_filter = "Active"
# _shopMallName = "yafang123"
# ap_asin ="B0BTHBJDKG"
# _shopMallName = "Jianhuang 888"
# ap_asin ="B0F25Y52PH"
# skip_asin = []
# for _ in range(3):
# try:
@@ -1439,14 +1449,19 @@ if __name__ == '__main__':
# print(f"ASIN {asin} 的处理结果: {status}")
# print("已完成操作")
front_end_data = {'top_sellers': [{'rank': 1, 'price': '50.24', 'stock': '26', 'shop_name': 'yafang123'}, {'rank': 2, 'price': '44.35', 'stock': '100', 'shop_name': 'QinPimy'}], 'cart_seller': 'Amazon US', 'timestamp': '2026-04-27 16:17:42'}
front_end_data = {'top_sellers': [{'rank': 1, 'price': '52.51', 'stock': '100', 'shop_name': 'Amazon US'}, {'rank': 2, 'price': '62.58', 'stock': '30', 'shop_name': 'Amazon US'}], 'cart_seller': 'Jianhuang 888', 'timestamp': '2026-04-30 09:52:49'}
current_Price = 44.35
current_shop_name = "yafang123"
recommended_price = 44.35
current_Price = 52.51
current_shop_name = "Jianhuang 888"
recommended_price = 52.51
recommended_shipping =0.0
res = calculate_target_price(
front_end_data, current_Price, current_shop_name,
recommended_price, recommended_shipping
)
print(res)
# task_data = {'type': 'price-track-run', 'ts': 1777388166302, 'data': {'task_id': 4892, 'result_id': 6685, 'shop_name': '魏振峰', 'shop_id': '27543917795757', 'platform': '亚马逊', 'company_name': 'rongchuang123', 'shop_mall_name': 'WEIZHENFENG168', 'matched': True, 'match_status': 'MATCHED', 'match_message': None, 'success': False, 'error': None, 'output_filename': None, 'download_url': None, 'task_status': 'RUNNING', 'loop_run_id': 168, 'round_index': 1, 'country_codes': ['DE', 'UK', 'FR', 'IT', 'ES'], 'mode': 'asin', 'skip_asins': {}, 'skip_asins_by_country': {}, 'skip_asin_details_by_country': {}, 'minimum_price_by_country_and_asin': {'DE': {'B0CFY5M3XJ': '38', 'B0DSJGJBWV': '9'}, 'UK': {'B0G2LPSGP5': '30', 'B0G1LY2K8L': '10'}}, 'skip_asin_delete_policy': {'enabled': False, 'mode': 'NONE', 'deleteWhenPriceBelowMinimum': False, 'compareField': 'price', 'thresholdField': 'minimumPrice', 'target': 'skip_asin', 'source': 'frontend-price-track'}, 'delete_skip_asin_when_price_below_minimum': False, 'deleteSkipAsinWhenPriceBelowMinimum': False, 'asin_rows_by_country': {'DE': [{'shopMallName': '', 'asin': 'B0CFY5M3XJ', 'price': '', 'recommendedPrice': '', 'minimumPrice': '38', 'firstPlace': '', 'secondPlace': '', 'cartShopName': '', 'priceChangeStatus': '', 'modifyCount': '', 'status': ''}, {'shopMallName': '', 'asin': 'B0DSJGJBWV', 'price': '', 'recommendedPrice': '', 'minimumPrice': '9', 'firstPlace': '', 'secondPlace': '', 'cartShopName': '', 'priceChangeStatus': '', 'modifyCount': '', 'status': ''}], 'UK': [{'shopMallName': '', 'asin': 'B0G2LPSGP5', 'price': '', 'recommendedPrice': '', 'minimumPrice': '30', 'firstPlace': '', 'secondPlace': '', 'cartShopName': '', 'priceChangeStatus': '', 'modifyCount': '', 'status': ''}, {'shopMallName': '', 'asin': 'B0G1LY2K8L', 'price': '', 'recommendedPrice': '', 'minimumPrice': '10', 'firstPlace': '', 'secondPlace': '', 'cartShopName': '', 'priceChangeStatus': '', 'modifyCount': '', 'status': ''}]}, 'taskId': 4892, 'resultId': 6685, 'shopName': '魏振峰', 'shopId': '27543917795757', 'platformName': '亚马逊', 'companyName': 'rongchuang123', 'shopMallName': 'WEIZHENFENG168', 'matchStatus': 'MATCHED', 'matchMessage': None, 'outputFilename': None, 'downloadUrl': None, 'taskStatus': 'RUNNING', 'loopRunId': 168, 'roundIndex': 1}}
# price_cls = PriceTask(user_info)
# price_cls.process_task(task_data)

View File

@@ -0,0 +1,55 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function attrsOf(el) {
const attrs = {};
if (!el || !el.attributes) return attrs;
for (const attr of el.attributes) attrs[attr.name] = attr.value;
return attrs;
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function rowOf(el, index) {
const attrs = attrsOf(el);
return {
index,
tag: el.tagName.toLowerCase(),
visible: isVisible(el),
text: textOf(el),
attrs,
outerHTML: (el.outerHTML || '').slice(0, 2500),
};
}
const candidates = allElements()
.map((el, index) => rowOf(el, index))
.filter((row) => row.visible && /补全草稿|快速查看|quick view|draft/i.test(`${row.tag} ${row.text} ${Object.values(row.attrs).join(' ')}`))
.slice(0, 120);
return candidates;

View File

@@ -0,0 +1,73 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function attrsOf(el) {
const attrs = {};
if (!el || !el.attributes) return attrs;
for (const attr of el.attributes) attrs[attr.name] = attr.value;
return attrs;
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function attrsText(attrs) {
return Object.entries(attrs).map(([key, value]) => `${key}=${value}`).join(' ');
}
const pattern = /快速查看|quick|draft|草稿|提交|submitted|missing|inventory|myinventory/i;
const elements = allElements()
.map((el, index) => {
const attrs = attrsOf(el);
const text = textOf(el);
return {
index,
tag: el.tagName.toLowerCase(),
visible: isVisible(el),
text: text.slice(0, 500),
attrs,
outerHTML: (el.outerHTML || '').slice(0, 1000),
haystack: `${el.tagName} ${text} ${attrsText(attrs)}`,
};
})
.filter((row) => pattern.test(row.haystack))
.map(({ haystack, ...row }) => row)
.slice(0, 80);
const resources = performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.filter((url) => pattern.test(url))
.slice(-120);
return {
location: window.location.href,
readyState: document.readyState,
capturedResponses: (window.__crawlerCompleteDraftResponses || []).slice(-10),
elements,
resources,
};

View File

@@ -0,0 +1,121 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function attrsOf(el) {
const attrs = {};
if (!el || !el.attributes) return attrs;
for (const attr of el.attributes) attrs[attr.name] = attr.value;
return attrs;
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function hasCountText(text) {
return /[(]\s*[\d,]+\s*[)]/.test(text);
}
function insideListingRow(el) {
return Boolean(el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]'));
}
function rowOf(el, index) {
const attrs = attrsOf(el);
const text = textOf(el);
const labelText = attrs.label || attrs.title || attrs['aria-label'] || '';
const valueText = attrs.value || attrs.count || attrs['data-count'] || attrs['data-value'] || '';
return {
index,
raw_text: text,
label_text: labelText,
quantity_text: valueText,
attrs,
visible: isVisible(el),
outerHTML: (el.outerHTML || '').slice(0, 1200),
};
}
function splitCountRow(el, index) {
const countText = textOf(el);
if (!/^\s*[\d,]+\s*$/.test(countText)) return null;
let parent = el.parentElement;
for (let depth = 0; parent && depth < 4; depth += 1, parent = parent.parentElement) {
const parentText = textOf(parent);
const labelText = parentText
.replace(new RegExp(`(^|\\s)${countText.replace(/,/g, '\\,')}(\\s|$)`), ' ')
.replace(/[(]\s*[\d,]+\s*[)]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (labelText && labelText !== parentText && !/^\s*[\d,]+\s*$/.test(labelText)) {
return {
index,
raw_text: '',
label_text: labelText,
quantity_text: countText,
attrs: attrsOf(parent),
visible: isVisible(parent),
outerHTML: (parent.outerHTML || '').slice(0, 1200),
};
}
}
return null;
}
const regionRows = allElements().filter((el) => isVisible(el) && !insideListingRow(el));
const byText = new Map();
regionRows
.forEach((el, index) => {
const row = rowOf(el, index);
const combined = `${row.raw_text} ${row.label_text} ${row.quantity_text}`;
if (row.raw_text.length > 200) return;
if (/[(]\s*[\d,]+\s*[)]/.test(combined)) {
const key = combined;
if (!byText.has(key)) byText.set(key, row);
return;
}
const splitRow = splitCountRow(el, index);
if (splitRow) {
const key = `${splitRow.label_text} ${splitRow.quantity_text}`;
if (!byText.has(key)) byText.set(key, splitRow);
}
});
return {
region: {
url: window.location.href,
title: document.title,
},
rows: Array.from(byText.values()).filter((row) => {
const combined = `${row.raw_text} ${row.label_text}`.toLowerCase();
return !/所有商品|启售商品|改善商品信息/.test(combined);
}),
candidates: Array.from(byText.values()).slice(0, 80),
};

View File

@@ -0,0 +1,89 @@
function textOf(node) {
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
}
function attrsText(el) {
if (!el || !el.attributes) return '';
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
for (const assigned of node.assignedElements({ flatten: true })) {
walkRoots(assigned, rows, seen);
}
}
node = walker.nextNode();
}
}
function allElements(root) {
const rows = [];
walkRoots(root || document, rows, new Set());
return rows;
}
function clickElement(el) {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
if (el.focus) el.focus();
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
}
el.click();
}
function clickTargetFor(el) {
if (el && el.shadowRoot) {
const button = el.shadowRoot.querySelector('button:not([disabled]), [role="button"]:not([disabled])');
if (button) return button;
}
return el;
}
const modal = document.querySelector('kat-modal[data-testid="action-modal"]') ||
allElements(document).find((el) => /删除|Delete/i.test(textOf(el)) && (el.tagName || '').toLowerCase() === 'kat-modal');
if (!modal) return { ok: false, reason: 'delete modal not found' };
const buttons = allElements(modal).filter((el) => {
const haystack = `${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`;
return isVisible(el) && /kat-button|button/i.test(el.tagName || '') && /variant=primary|删除|确认|Delete|Confirm/i.test(haystack);
});
const button = buttons[0];
if (!button) {
return {
ok: false,
reason: 'confirm button not found',
modalText: textOf(modal),
modalHTML: (modal.outerHTML || '').slice(0, 2000),
};
}
const target = clickTargetFor(button);
clickElement(target);
return {
ok: true,
clicked: {
tag: button.tagName,
text: textOf(button),
attrs: attrsText(button),
targetTag: target.tagName,
},
};

View File

@@ -0,0 +1,46 @@
function textOf(node) {
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
}
function attrsText(el) {
if (!el || !el.attributes) return '';
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
for (const assigned of node.assignedElements({ flatten: true })) {
walkRoots(assigned, rows, seen);
}
}
node = walker.nextNode();
}
}
const rows = [];
walkRoots(document, rows, new Set());
const seen = new Set();
return rows
.filter((el) => {
const haystack = `${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`;
return isVisible(el) && /kat-alert|alert|toast|notification|已删除|已经删除|15\s*分钟|success/i.test(haystack);
})
.map((el) => textOf(el) || attrsText(el))
.filter((text) => text && !seen.has(text) && seen.add(text))
.slice(0, 30);

View File

@@ -0,0 +1,187 @@
function textOf(node) {
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
}
function attrsText(el) {
if (!el || !el.attributes) return '';
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
for (const assigned of node.assignedElements({ flatten: true })) {
walkRoots(assigned, rows, seen);
}
}
node = walker.nextNode();
}
}
function allElements(root) {
const rows = [];
walkRoots(root || document, rows, new Set());
return rows;
}
function closestDeep(el, selector) {
let node = el;
while (node) {
if (node.closest) {
const found = node.closest(selector);
if (found) return found;
}
const root = node.getRootNode && node.getRootNode();
node = root && root.host ? root.host : null;
}
return null;
}
function insideInventoryRow(el) {
return Boolean(closestDeep(el, 'div[data-sku]'));
}
function isDeleteAction(el) {
const haystack = `${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`;
return /data-action=DeleteListing|action=DeleteListing|删除商品信息|删除商品和报价|DeleteListing|delete\s+listing/i.test(haystack);
}
function clickElement(el) {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
if (el.focus) el.focus();
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
}
el.click();
}
function hostDropdownOf(el) {
const host = closestDeep(el, 'kat-dropdown-button');
return host || ((el.tagName || '').toLowerCase() === 'kat-dropdown-button' ? el : null);
}
function dropdownToggleOf(dropdown, fallback) {
if (!dropdown) return fallback;
if (fallback && textOf(fallback)) return fallback;
return fallback || dropdown;
}
function openBulkActionMenu(candidate) {
const dropdown = hostDropdownOf(candidate);
const toggle = dropdownToggleOf(dropdown, candidate);
clickElement(toggle);
return { dropdown, toggle };
}
function summarize(el) {
return {
tag: el.tagName,
text: textOf(el),
attrs: attrsText(el),
visible: isVisible(el),
outerHTML: (el.outerHTML || '').slice(0, 1000),
};
}
function findVisibleBulkDeleteAction() {
return allElements(document).find((el) => isDeleteAction(el) && isVisible(el) && !insideInventoryRow(el));
}
function findDeleteActionAfterMenuOpen(candidate) {
const dropdown = hostDropdownOf(candidate) || candidate;
const exactMenuButton = allElements(dropdown).find((child) => {
const tag = (child.tagName || '').toLowerCase();
const role = child.getAttribute && child.getAttribute('role');
return tag === 'button' && role === 'menuitem' && isDeleteAction(child);
});
if (exactMenuButton) return { mode: 'bulk-action-menuitem-delete', el: exactMenuButton };
const nestedDeleteAction = allElements(dropdown).find((child) => isDeleteAction(child) && isVisible(child));
if (nestedDeleteAction) return { mode: 'bulk-action-nested-delete', el: nestedDeleteAction };
const deleteAction = findVisibleBulkDeleteAction();
if (deleteAction) return { mode: 'bulk-action-visible-delete', el: deleteAction };
return null;
}
function isBulkActionCandidate(el) {
const tag = (el.tagName || '').toLowerCase();
if (!/^(button|kat-button|kat-dropdown-button|kat-select|kat-popover|kat-menu-button)$/.test(tag)) return false;
if (!isVisible(el) || insideInventoryRow(el)) return false;
const haystack = `${tag} ${attrsText(el)} ${textOf(el)}`;
return /DeleteListing|删除商品信息|删除|操作|更多|Action|Actions|selected|选定|已选择|批量/i.test(haystack);
}
const directDelete = findVisibleBulkDeleteAction();
if (directDelete) {
clickElement(directDelete);
return { ok: true, mode: 'direct-delete-button', clicked: summarize(directDelete) };
}
const rows = allElements(document);
const candidates = rows.filter(isBulkActionCandidate);
for (const candidate of candidates) {
try {
const openResult = openBulkActionMenu(candidate);
const deleteAction = findDeleteActionAfterMenuOpen(candidate);
if (deleteAction) {
clickElement(deleteAction.el);
return {
ok: true,
mode: deleteAction.mode,
candidate: summarize(candidate),
opened: {
dropdown: openResult.dropdown ? summarize(openResult.dropdown) : null,
toggle: openResult.toggle ? summarize(openResult.toggle) : null,
},
clicked: summarize(deleteAction.el),
};
}
return {
ok: false,
reason: 'bulk action menu opened; delete action not visible yet',
menuOpened: true,
candidate: summarize(candidate),
opened: {
dropdown: openResult.dropdown ? summarize(openResult.dropdown) : null,
toggle: openResult.toggle ? summarize(openResult.toggle) : null,
},
deleteLikeElements: allElements(document)
.filter((el) => /DeleteListing|删除商品信息|删除商品和报价|delete\s+listing/i.test(`${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`) && !insideInventoryRow(el))
.slice(0, 20)
.map(summarize),
};
} catch (error) {
// Try the next non-row bulk action control.
}
}
return {
ok: false,
reason: 'bulk delete action not found',
candidates: candidates.slice(0, 20).map(summarize),
deleteLikeElements: rows
.filter((el) => /DeleteListing|删除商品信息|删除商品和报价|delete\s+listing/i.test(`${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`) && !insideInventoryRow(el))
.slice(0, 20)
.map(summarize),
visibleButtons: rows
.filter((el) => /^(button|kat-button|kat-dropdown-button)$/i.test(el.tagName || '') && isVisible(el))
.slice(0, 20)
.map(summarize),
};

View File

@@ -0,0 +1,71 @@
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function insideInventoryRow(el) {
return Boolean(el && el.closest && el.closest('div[data-sku]'));
}
function clickTargetFor(checkbox) {
if (!checkbox) return null;
if (checkbox.shadowRoot) {
const shadowTarget = checkbox.shadowRoot.querySelector('[role="checkbox"], .checkbox');
if (shadowTarget) return shadowTarget;
}
return checkbox;
}
const rowCount = document.querySelectorAll('div[data-sku]').length;
if (rowCount <= 0) {
return { ok: false, reason: 'no inventory rows after status filter', rowCount };
}
const checkboxes = allElements()
.filter((el) => (el.tagName || '').toLowerCase() === 'kat-checkbox' && isVisible(el))
.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
return rectA.top - rectB.top || rectA.left - rectB.left;
});
const headerCheckbox = checkboxes.find((el) => !insideInventoryRow(el)) || checkboxes[0];
if (!headerCheckbox) {
return { ok: false, reason: 'select-all checkbox not found', rowCount, checkboxCount: checkboxes.length };
}
const target = clickTargetFor(headerCheckbox);
target.scrollIntoView({ block: 'center', inline: 'nearest' });
target.click();
return {
ok: true,
rowCount,
checkboxCount: checkboxes.length,
clicked: {
tag: headerCheckbox.tagName,
insideInventoryRow: insideInventoryRow(headerCheckbox),
outerHTML: (headerCheckbox.outerHTML || '').slice(0, 1000),
},
};

View File

@@ -0,0 +1,124 @@
function textOf(node) {
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
}
function normalize(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function parseStatus(rawText) {
const text = normalize(rawText);
if (!text) return '';
const match = text.match(/^(.*?)\s*[(]\s*[\d,]+\s*[)]\s*$/);
return normalize(match ? match[1] : text);
}
function collectStatusTargets() {
const pattern = /(全部|在搜索结果中禁止显示|配送问题|缺少报价|已停售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|不可售|在售)\s*[(]?\s*[\d,]*\s*[)]?/g;
const hits = [];
for (const el of allElements()) {
if (!isVisible(el)) continue;
const text = normalize(textOf(el));
if (!text) continue;
const match = text.match(pattern);
if (!match) continue;
const parsed = parseStatus(match[0]);
hits.push({
el,
text,
parsed,
tag: el.tagName,
clickable: Boolean(el.onclick) || el.matches('button,[role="button"],kat-option,kat-label,a,[tabindex]'),
});
}
return hits;
}
const targetStatus = normalize((__crawlerPayload || {}).status);
const candidates = collectStatusTargets()
.filter((item) => item.parsed === targetStatus || item.text.includes(targetStatus))
.map((item) => ({
...item,
score:
(item.parsed === targetStatus ? 300 : 0) +
(item.clickable ? 120 : 0) +
(/^[^ ]+\s*[(]\s*[\d,]+\s*[)]$/.test(item.text) ? 80 : 0) +
(/button|option|label/i.test(item.tag) ? 30 : 0),
}))
.sort((a, b) => b.score - a.score);
if (!candidates.length) {
return { ok: false, reason: 'visible status target not found', targetStatus };
}
for (const candidate of candidates) {
const chain = [candidate.el];
let node = candidate.el.parentElement;
for (let depth = 0; node && depth < 5; depth += 1, node = node.parentElement) {
chain.push(node);
}
for (const el of chain) {
try {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
el.click();
return {
ok: true,
targetStatus,
clicked: {
tag: el.tagName,
text: normalize(textOf(el)).slice(0, 300),
},
matched: {
tag: candidate.tag,
text: candidate.text.slice(0, 300),
parsed: candidate.parsed,
score: candidate.score,
},
};
} catch (error) {
// try parent
}
}
}
return {
ok: false,
reason: 'visible status target click failed',
targetStatus,
candidates: candidates.slice(0, 10).map((item) => ({
tag: item.tag,
text: item.text.slice(0, 300),
parsed: item.parsed,
score: item.score,
clickable: item.clickable,
})),
};

View File

@@ -0,0 +1,3 @@
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }));
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
return true;

View File

@@ -0,0 +1,95 @@
const keywords = ['ListingStatus', 'listing status', '商品状态', '商品狀態', 'status', 'filter', '库存'];
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function attrsOf(el) {
const attrs = {};
if (!el || !el.attributes) return attrs;
for (const attr of el.attributes) attrs[attr.name] = attr.value;
return attrs;
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function nearestLabel(el) {
const parts = [];
let node = el;
for (let depth = 0; node && depth < 6; depth += 1, node = node.parentElement) {
const text = textOf(node);
if (text) parts.push(text.slice(0, 400));
}
const id = el.getAttribute('id');
if (id) {
document.querySelectorAll(`label[for="${CSS.escape(id)}"]`).forEach((label) => {
const text = textOf(label);
if (text) parts.unshift(text);
});
}
return [...new Set(parts)].join(' | ');
}
function hasKeyword(text) {
return keywords.some((keyword) => text.toLowerCase().includes(keyword.toLowerCase()));
}
function candidateRow(el, index) {
const attrs = attrsOf(el);
const attrText = Object.entries(attrs).map(([k, v]) => `${k}=${v}`).join(' ');
const label = nearestLabel(el);
const text = textOf(el);
const haystack = `${el.tagName} ${attrText} ${text} ${label}`;
let score = 0;
if (/商品状态|商品狀態|ListingStatus|listing status/i.test(haystack)) score += 100;
if (/status/i.test(haystack)) score += 25;
if (/filter|库存/i.test(haystack)) score += 10;
if (isVisible(el)) score += 5;
if (el.closest('[data-sku], tr, [role="row"]')) score -= 50;
return {
index,
score,
tag: el.tagName.toLowerCase(),
visible: isVisible(el),
text,
label,
attrs,
outerHTML: (el.outerHTML || '').slice(0, 2000),
};
}
const rows = allElements();
const candidates = [];
rows.forEach((el, index) => {
const tag = (el.tagName || '').toLowerCase();
const haystack = `${tag} ${textOf(el)} ${Object.values(attrsOf(el)).join(' ')} ${nearestLabel(el)}`;
if (tag === 'kat-dropdown' || tag === 'kat-option' || hasKeyword(haystack)) {
candidates.push(candidateRow(el, index));
}
});
return candidates.sort((a, b) => b.score - a.score).slice(0, 80);

View File

@@ -0,0 +1,295 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function normalize(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function attrsText(el) {
if (!el || !el.attributes) return '';
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
}
function attrsOf(el) {
const attrs = {};
if (!el || !el.attributes) return attrs;
for (const attr of el.attributes) attrs[attr.name] = attr.value;
return attrs;
}
function isDisabled(el) {
if (!el) return false;
return (
el.hasAttribute('disabled') ||
el.disabled === true ||
el.getAttribute('aria-disabled') === 'true' ||
/\bdisabled\b/i.test(el.getAttribute('class') || '')
);
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function nearestLabel(el) {
const parts = [];
let node = el;
for (let depth = 0; node && depth < 7; depth += 1, node = node.parentElement) {
const text = textOf(node);
if (text) parts.push(text.slice(0, 500));
}
const id = el.getAttribute('id');
if (id) {
document.querySelectorAll(`label[for="${CSS.escape(id)}"]`).forEach((label) => {
const text = textOf(label);
if (text) parts.unshift(text);
});
}
return [...new Set(parts)].join(' | ');
}
function optionTextOf(dropdown) {
const roots = [dropdown];
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
const texts = [];
for (const root of roots) {
for (const option of Array.from(root.querySelectorAll('kat-option, kat-label, [role="option"], [class*="parentKatOptionStyle"]'))) {
texts.push(`${textOf(option)} ${attrsText(option)}`);
}
}
return normalize(texts.join(' '));
}
function isSearchFieldDropdown(text) {
return /(^|\s)(SKUS?|ASIN|FNSKU|UPC\/EAN|TITLE_KEYWORD|GTIN)(\s|$)|商品名称\/关键字|data-value=(SKU|ASIN|FNSKU|TITLE_KEYWORD|GTIN)|value=(SKU|ASIN|FNSKU|TITLE_KEYWORD|GTIN)/i.test(text);
}
function statusHitCount(text) {
const matches = normalize(text).match(
/(全部|在售|不可售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|配送问题|缺少报价|已停售|在搜索结果中禁止显示)\s*(?:[(]\s*[\d,]+\s*[)])?/g
);
return matches ? matches.length : 0;
}
function scoreDropdown(el) {
const text = textOf(el);
const label = nearestLabel(el);
const attrs = attrsText(el);
const optionText = optionTextOf(el);
const haystack = `${el.tagName} ${attrs} ${text} ${label} ${optionText}`;
const statusHits = statusHitCount(`${text} ${label} ${optionText}`);
const isStatusDropdown = /statusDropdown|商品状态|商品狀態|ListingStatus|listing status/i.test(haystack);
let score = 0;
if (isSearchFieldDropdown(haystack)) score -= 1000;
if (isStatusDropdown) score += 500;
if (/商品状态|商品狀態|ListingStatus|listing status/i.test(haystack)) score += 120;
if (/\bstatus\b|状态/i.test(haystack)) score += 25;
if (/filter|筛选|筛選|库存/i.test(haystack)) score += 10;
if (/全部\s*[(]\s*[\d,]+\s*[)]/.test(`${text} ${label} ${optionText}`)) score += 300;
if (statusHits >= 2) score += statusHits * 180;
if (!isStatusDropdown && statusHits === 0) score -= 200;
if (isVisible(el)) score += 10;
if (el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]')) score -= 80;
if (isDisabled(el)) score -= 20;
return { score, text, label, haystack, statusHits, disabled: isDisabled(el) };
}
function candidateTriggers(dropdown) {
const selectors = [
'kat-dropdown-button',
'kat-button',
'button',
'[role="button"]',
'[aria-haspopup]',
'[part="trigger"]',
'[part="button"]',
'.header',
'.trigger',
'.button',
'span',
'div',
];
const seen = new Set();
const triggers = [];
const roots = [dropdown];
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
for (const root of roots) {
for (const selector of selectors) {
for (const el of Array.from(root.querySelectorAll(selector))) {
if (!el || seen.has(el)) continue;
seen.add(el);
const text = textOf(el);
const attrs = attrsText(el);
const score =
(isVisible(el) ? 20 : 0) +
(/全部\s*[(]\s*[\d,]+\s*[)]/.test(text) ? 200 : 0) +
(/商品状态|商品狀態|ListingStatus|listing status/i.test(`${text} ${attrs}`) ? 120 : 0) +
(/button|trigger|toggle|dropdown|expand/i.test(`${el.tagName} ${attrs}`) ? 40 : 0);
triggers.push({ el, text, attrs, score });
}
}
}
return triggers
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
}
function clickTrigger(dropdown) {
const triggers = candidateTriggers(dropdown);
for (const trigger of triggers) {
try {
trigger.el.scrollIntoView({ block: 'center', inline: 'nearest' });
trigger.el.click();
return {
ok: true,
trigger: {
tag: trigger.el.tagName,
text: trigger.text,
attrs: trigger.attrs,
score: trigger.score,
},
};
} catch (error) {
// continue
}
}
try {
dropdown.scrollIntoView({ block: 'center', inline: 'nearest' });
dropdown.click();
return {
ok: true,
trigger: {
tag: dropdown.tagName,
text: textOf(dropdown),
attrs: attrsText(dropdown),
score: -1,
},
};
} catch (error) {
return {
ok: false,
reason: String(error),
tried: triggers.slice(0, 10).map((item) => ({
tag: item.el.tagName,
text: item.text,
attrs: item.attrs,
score: item.score,
})),
};
}
}
const dropdowns = allElements().filter((el) => el.tagName.toLowerCase() === 'kat-dropdown');
const ranked = dropdowns
.map((el, index) => ({ el, index, ...scoreDropdown(el) }))
.filter((row) => row.score > 0)
.sort((a, b) => b.score - a.score);
if (!ranked.length) {
return { ok: false, reason: 'listing status kat-dropdown not found', candidates: [] };
}
const selected = ranked[0];
if (selected.disabled) {
return {
ok: false,
reason: 'listing status dropdown disabled/loading',
selected: {
index: selected.index,
score: selected.score,
statusHits: selected.statusHits,
text: selected.text,
label: selected.label,
attrs: attrsOf(selected.el),
outerHTML: (selected.el.outerHTML || '').slice(0, 2000),
},
candidates: ranked.slice(0, 10).map((row) => ({
index: row.index,
score: row.score,
statusHits: row.statusHits,
disabled: row.disabled,
text: row.text,
label: row.label,
attrs: attrsText(row.el),
outerHTML: (row.el.outerHTML || '').slice(0, 1000),
})),
};
}
selected.el.setAttribute('data-crawler-listing-status-dropdown', 'true');
const clickResult = clickTrigger(selected.el);
if (!clickResult.ok) {
return {
ok: false,
reason: 'listing status dropdown trigger click failed',
selected: {
index: selected.index,
score: selected.score,
text: selected.text,
label: selected.label,
attrs: attrsText(selected.el),
outerHTML: (selected.el.outerHTML || '').slice(0, 2000),
},
clickResult,
candidates: ranked.slice(0, 10).map((row) => ({
index: row.index,
score: row.score,
text: row.text,
label: row.label,
attrs: attrsText(row.el),
outerHTML: (row.el.outerHTML || '').slice(0, 1000),
})),
};
}
return {
ok: true,
selected: {
index: selected.index,
score: selected.score,
statusHits: selected.statusHits,
text: selected.text,
label: selected.label,
attrs: attrsText(selected.el),
outerHTML: (selected.el.outerHTML || '').slice(0, 2000),
},
trigger: clickResult.trigger,
candidates: ranked.slice(0, 10).map((row) => ({
index: row.index,
score: row.score,
statusHits: row.statusHits,
disabled: row.disabled,
text: row.text,
label: row.label,
attrs: attrsText(row.el),
outerHTML: (row.el.outerHTML || '').slice(0, 1000),
})),
};

View File

@@ -0,0 +1,129 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function attrsOf(el) {
const attrs = {};
if (!el || !el.attributes) return attrs;
for (const attr of el.attributes) attrs[attr.name] = attr.value;
return attrs;
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function findMarkedDropdown() {
return allElements().find((el) => el.getAttribute('data-crawler-listing-status-dropdown') === 'true') || null;
}
function insideListingRow(el) {
return Boolean(el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]'));
}
function normalize(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function isOptionLike(el) {
if (!el || !el.tagName) return false;
const tag = el.tagName.toLowerCase();
const role = el.getAttribute('role');
const className = el.getAttribute('class') || '';
return (
tag === 'kat-option' ||
tag === 'kat-label' ||
role === 'option' ||
/parentKatOptionStyle/i.test(className)
);
}
function parseStatus(rawText) {
const text = normalize(rawText);
if (!text) return '';
const match = text.match(/^(.*?)\s*[(]\s*[\d,]+\s*[)]\s*$/);
return normalize(match ? match[1] : text);
}
function isListingStatusOption(row) {
const text = normalize(`${row.raw_text} ${row.value || ''}`);
const parsed = parseStatus(row.raw_text);
const haystack = normalize(`${text} ${parsed}`);
if (/^(所有|SKUS?|ASIN|FNSKU|UPC\/EAN|商品名称\/关键字)$|^(ALL|SKU|ASIN|FNSKU|TITLE_KEYWORD|GTIN)$/i.test(haystack)) {
return false;
}
return /^(全部|在售|不可售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|配送问题|缺少报价|已停售|在搜索结果中禁止显示)$/.test(parsed);
}
function optionRow(el, index) {
const attrs = attrsOf(el);
const rawText = textOf(el) || attrs.label || attrs.text || attrs.value || '';
return {
index,
raw_text: rawText,
value: attrs.value || attrs['data-value'] || attrs.name || null,
tag: (el.tagName || '').toLowerCase(),
attrs,
visible: isVisible(el),
outerHTML: (el.outerHTML || '').slice(0, 1200),
};
}
const markedDropdown = findMarkedDropdown();
let scoped = [];
if (markedDropdown) {
const roots = [markedDropdown];
if (markedDropdown.shadowRoot) roots.push(markedDropdown.shadowRoot);
for (const root of roots) {
scoped = scoped.concat(Array.from(root.querySelectorAll('kat-option, kat-label, [role="option"], [class*="parentKatOptionStyle"]')));
}
}
const allOptions = allElements().filter((el) => {
if (!isOptionLike(el)) return false;
if (insideListingRow(el)) return false;
return isVisible(el);
});
const byNode = new Map();
const byStatus = new Map();
scoped.concat(allOptions).forEach((el, index) => {
if (!byNode.has(el)) {
const row = optionRow(el, index);
if (isListingStatusOption(row)) {
byNode.set(el, row);
const parsed = parseStatus(row.raw_text);
const hasValue = Boolean(row.value);
const hasQuantity = /[(]\s*[\d,]+\s*[)]/.test(row.raw_text);
const quality = (hasValue ? 2 : 0) + (hasQuantity ? 1 : 0);
const existing = byStatus.get(parsed);
if (!existing || quality > existing.quality) {
byStatus.set(parsed, { row, quality });
}
}
}
});
return Array.from(byStatus.values()).map((item) => item.row).filter((row) => row.raw_text);

View File

@@ -0,0 +1,91 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function extractStatuses(text) {
const pattern = /(全部|在搜索结果中禁止显示|配送问题|缺少报价|已停售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|不可售|在售)\s*(?:[(]\s*([\d,]+)\s*[)])?/g;
const rows = [];
let match;
while ((match = pattern.exec(text)) !== null) {
rows.push({
status: match[1],
quantity: match[2] || '',
raw_text: match[0].trim(),
});
}
return rows;
}
function parseDropdown(dropdown, index) {
const nodes = Array.from(dropdown.querySelectorAll('kat-option, kat-label, [class*="parentKatOptionStyle"]'));
const seen = new Set();
const rows = nodes
.flatMap((node) => {
const rawText = textOf(node);
return extractStatuses(rawText).map((parsed) => ({
...parsed,
value: node.getAttribute('value') || '',
}));
})
.filter((row) => {
const key = `${row.status}::${row.quantity}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const quantityCount = rows.filter((row) => row.quantity).length;
const rawText = textOf(dropdown);
const className = String(dropdown.className || '');
const isStatusDropdown = /statusDropdown/i.test(className);
const score =
rows.length * 100 +
quantityCount * 50 +
(isStatusDropdown ? 500 : 0) +
(/在搜索结果中禁止显示|需要批准|详情页面已删除/.test(rawText) ? 200 : 0);
return {
index,
tag: (dropdown.tagName || '').toLowerCase(),
raw_text: rawText,
rows,
rowCount: rows.length,
quantityCount,
score,
className,
outerHTML: (dropdown.outerHTML || '').slice(0, 1200),
};
}
const ranked = allElements()
.map((el, index) => ({ el, index }))
.filter(({ el }) => (el.tagName || '').toLowerCase() === 'kat-dropdown')
.map(({ el, index }) => parseDropdown(el, index))
.filter((row) => row.rowCount >= 3)
.sort((a, b) => b.score - a.score);
const best = ranked[0];
return {
rows: best ? best.rows : [],
selected: best || null,
candidates: ranked.slice(0, 10),
};

View File

@@ -0,0 +1,148 @@
const __crawlerPayload = arguments[0] || {};
function textOf(node) {
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function findMarkedDropdown() {
return allElements().find((el) => el.getAttribute('data-crawler-listing-status-dropdown') === 'true') || null;
}
function insideListingRow(el) {
return Boolean(el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]'));
}
function isOptionLike(el) {
if (!el || !el.tagName) return false;
const tag = el.tagName.toLowerCase();
const role = el.getAttribute('role');
const className = el.getAttribute('class') || '';
return (
tag === 'kat-option' ||
tag === 'kat-label' ||
role === 'option' ||
/parentKatOptionStyle/i.test(className)
);
}
function isSearchFieldOption(meta) {
return /^(所有|SKUS?|ASIN|FNSKU|UPC\/EAN|商品名称\/关键字)$|^(ALL|SKU|ASIN|FNSKU|TITLE_KEYWORD|GTIN)$/i.test(
`${meta.rawText} ${meta.parsedStatus} ${meta.value}`
);
}
function isListingStatusOption(meta) {
if (isSearchFieldOption(meta)) return false;
return /^(全部|在售|不可售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|配送问题|缺少报价|已停售|在搜索结果中禁止显示)$/.test(
meta.parsedStatus
);
}
function normalize(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function parseStatus(rawText) {
const text = normalize(rawText);
if (!text) return '';
const match = text.match(/^(.*?)\s*[(]\s*[\d,]+\s*[)]\s*$/);
return normalize(match ? match[1] : text);
}
function optionMeta(el, index) {
const rawText = textOf(el) || el.getAttribute('label') || el.getAttribute('text') || el.getAttribute('value') || '';
return {
index,
rawText: normalize(rawText),
parsedStatus: parseStatus(rawText),
value: el.getAttribute('value') || el.getAttribute('data-value') || '',
visible: isVisible(el),
};
}
function clickTargetFor(el) {
if (!el) return el;
return el.closest('[class*="parentKatOptionStyle"], kat-option, [role="option"]') || el;
}
const targetStatus = normalize((__crawlerPayload || {}).status);
const dropdown = findMarkedDropdown();
const options = [];
if (dropdown) {
const roots = [dropdown];
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
for (const root of roots) {
for (const el of Array.from(root.querySelectorAll('kat-option, kat-label, [role="option"], [class*="parentKatOptionStyle"]'))) {
options.push(el);
}
}
}
if (!options.length) {
for (const el of allElements()) {
if (!isOptionLike(el)) continue;
if (!isVisible(el) || insideListingRow(el)) continue;
options.push(el);
}
}
const metas = options
.map((el, index) => ({ el, ...optionMeta(el, index) }))
.filter((item) => isListingStatusOption(item));
const matched = metas.find((item) => item.parsedStatus === targetStatus || item.rawText === targetStatus);
if (!matched) {
return {
ok: false,
reason: 'status option not found',
targetStatus,
options: metas.map(({ index, rawText, parsedStatus, value, visible }) => ({
index,
rawText,
parsedStatus,
value,
visible,
})),
};
}
const clickTarget = clickTargetFor(matched.el);
clickTarget.scrollIntoView({ block: 'center', inline: 'nearest' });
clickTarget.click();
return {
ok: true,
targetStatus,
selected: {
index: matched.index,
rawText: matched.rawText,
parsedStatus: matched.parsedStatus,
value: matched.value,
clickedTag: clickTarget.tagName,
},
};

View File

@@ -0,0 +1,94 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function attrsOf(el) {
const attrs = {};
if (!el || !el.attributes) return attrs;
for (const attr of el.attributes) attrs[attr.name] = attr.value;
return attrs;
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function snapshot(el, index) {
return {
index,
tag: el.tagName.toLowerCase(),
text: textOf(el),
attrs: attrsOf(el),
outerHTML: (el.outerHTML || '').slice(0, 2000),
};
}
const KEYWORD_RE = /推荐报价|featured offer|buy box|商城|marketplace|欧洲|英国|德国|法国|西班牙|意大利|2\s*天前|30\s*天前|2\s*days?\s*ago|30\s*days?\s*ago/i;
const visible = allElements().filter((el) => isVisible(el));
const keywordMatches = visible
.map((el, index) => ({ el, index, text: textOf(el) }))
.filter((row) => KEYWORD_RE.test(`${row.el.tagName} ${row.text} ${Object.values(attrsOf(row.el)).join(' ')}`))
.slice(0, 120)
.map(({ el, index }) => snapshot(el, index));
const buyBoxCard = visible.find((el) => (el.getAttribute('data-key') || '') === 'KPI_CARD_BUYBOX')
|| visible.find((el) => (el.id || '') === 'KPI_CARD_BUYBOX');
const buyBoxNeighborhood = [];
if (buyBoxCard) {
let node = buyBoxCard;
for (let depth = 0; node && depth < 6; depth += 1, node = node.parentElement) {
buyBoxNeighborhood.push({
depth,
tag: node.tagName.toLowerCase(),
text: textOf(node),
attrs: attrsOf(node),
outerHTML: (node.outerHTML || '').slice(0, 2500),
});
}
}
const buyBoxLeafMatches = [];
if (buyBoxCard) {
const scoped = [];
walkRoots(buyBoxCard, scoped, new Set());
scoped
.filter((el) => isVisible(el))
.map((el, index) => ({ el, index, text: textOf(el) }))
.filter((row) => row.text && row.text.length <= 200)
.filter((row) => /推荐报价|商城|marketplace|欧洲|英国|德国|法国|西班牙|意大利|\d+(?:\.\d+)?\s*%|2\s*天前|30\s*天前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(row.text))
.slice(0, 120)
.forEach(({ el, index }) => buyBoxLeafMatches.push(snapshot(el, index)));
}
return {
url: window.location.href,
title: document.title,
buyBoxFound: !!buyBoxCard,
buyBoxNeighborhood,
buyBoxLeafMatches,
keywordMatches,
};

View File

@@ -0,0 +1,132 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function attrsOf(el) {
const attrs = {};
if (!el || !el.attributes) return attrs;
for (const attr of el.attributes) attrs[attr.name] = attr.value;
return attrs;
}
function attrsText(el) {
return Object.entries(attrsOf(el)).map(([key, value]) => `${key}=${value}`).join(' ');
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function clickableFor(el) {
if (el && el.querySelector) {
const preferred = el.querySelector(
'casino-button[data-actionname="expand"], [data-actionname="expand"], casino-button[aria-label="chevron-down"], [aria-label="chevron-down"]'
);
if (preferred) return preferred;
}
let node = el;
for (let depth = 0; node && depth < 7; depth += 1, node = node.parentElement) {
if (node.querySelector) {
const preferred = node.querySelector(
'casino-button[data-actionname="expand"], [data-actionname="expand"], casino-button[aria-label="chevron-down"], [aria-label="chevron-down"]'
);
if (preferred) return preferred;
}
const tag = node.tagName.toLowerCase();
const role = node.getAttribute('role');
if (tag === 'button' || tag === 'a' || tag === 'kat-button' || role === 'button' || node.onclick) {
return node;
}
}
return el;
}
function scoreEntry(el) {
const text = textOf(el);
const haystack = `${el.tagName} ${attrsText(el)} ${text}`;
let score = 0;
if (/推荐报价百分比|推荐报价.*百分比|featured offer percentage|featured offer.*%|featured offer/i.test(haystack)) {
score += 120;
}
if (/buy box|购物车|报价/i.test(haystack)) score += 20;
if (/%/.test(haystack)) score += 15;
if (/button|link|href|click|card|metric/i.test(haystack)) score += 10;
if (isVisible(el)) score += 10;
if (text.length > 800) score -= 30;
return { score, text, haystack };
}
const ranked = allElements()
.filter((el) => isVisible(el))
.map((el, index) => ({ el, index, ...scoreEntry(el) }))
.filter((row) => row.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.text.length - b.text.length;
});
if (!ranked.length) {
return {
ok: false,
reason: 'recommended offer percentage entry not found',
url: window.location.href,
title: document.title,
candidates: allElements()
.filter((el) => isVisible(el))
.map((el, index) => ({ index, tag: el.tagName.toLowerCase(), text: textOf(el), attrs: attrsOf(el) }))
.filter((row) => /推荐报价|featured offer|buy box|百分比|percentage/i.test(`${row.tag} ${row.text} ${Object.values(row.attrs).join(' ')}`))
.slice(0, 80),
};
}
const selected = ranked[0];
const clickable = clickableFor(selected.el);
clickable.setAttribute('data-crawler-recommended-offer-entry', 'true');
clickable.scrollIntoView({ block: 'center', inline: 'nearest' });
['pointerdown', 'mousedown', 'mouseup', 'click'].forEach((eventName) => {
clickable.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, composed: true }));
});
if (typeof clickable.click === 'function') clickable.click();
return {
ok: true,
selected: {
index: selected.index,
score: selected.score,
text: selected.text,
attrs: attrsText(selected.el),
clickableTag: clickable.tagName.toLowerCase(),
clickableText: textOf(clickable),
outerHTML: (selected.el.outerHTML || '').slice(0, 2000),
},
candidates: ranked.slice(0, 12).map((row) => ({
index: row.index,
score: row.score,
text: row.text,
attrs: attrsText(row.el),
outerHTML: (row.el.outerHTML || '').slice(0, 1000),
})),
};

View File

@@ -0,0 +1,332 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function attrsOf(el) {
const attrs = {};
if (!el || !el.attributes) return attrs;
for (const attr of el.attributes) attrs[attr.name] = attr.value;
return attrs;
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function walkRoots(root, rows, seen) {
if (!root || seen.has(root)) return;
seen.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node = walker.currentNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function rowOf(el, index) {
const attrs = attrsOf(el);
const rect = el.getBoundingClientRect();
return {
index,
tag: el.tagName.toLowerCase(),
raw_text: textOf(el),
label_text: attrs.label || attrs.title || attrs['aria-label'] || '',
percentage_text: attrs.value || attrs['data-value'] || '',
attrs,
visible: isVisible(el),
rect_left: rect.left,
rect_top: rect.top,
rect_width: rect.width,
rect_height: rect.height,
outerHTML: (el.outerHTML || '').slice(0, 1200),
};
}
const COUNTRIES = [
{ country: '欧洲', aliases: ['欧洲', 'Europe'] },
{ country: '英国', aliases: ['英国', 'UK', 'United Kingdom'] },
{ country: '德国', aliases: ['德国', 'Germany'] },
{ country: '法国', aliases: ['法国', 'France'] },
{ country: '西班牙', aliases: ['西班牙', 'Spain'] },
{ country: '意大利', aliases: ['意大利', 'Italy'] },
];
function percentagesOf(text) {
return (text.match(/\d+(?:\.\d+)?\s*%/g) || []).map((item) => item.replace(/\s+/g, ''));
}
function parentOrHost(el) {
if (!el) return null;
if (el.parentElement) return el.parentElement;
const root = el.getRootNode && el.getRootNode();
return root && root.host ? root.host : null;
}
function countryOfText(text) {
const normalized = (text || '').trim().toLowerCase();
return COUNTRIES.find(({ aliases }) => aliases.some((alias) => normalized === alias.toLowerCase())) || null;
}
function countCountries(text) {
return COUNTRIES.filter(({ aliases }) => aliases.some((alias) => new RegExp(`(^|\\s)${alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, 'i').test(text))).length;
}
function bestCountryRowText(el) {
const visited = new Set();
let node = el;
for (let depth = 0; node && depth < 7; depth += 1, node = parentOrHost(node)) {
if (visited.has(node)) break;
visited.add(node);
const text = textOf(node);
if (!text || text.length > 500) continue;
const percentages = percentagesOf(text);
if (percentages.length >= 2 && countCountries(text) <= 1) return text;
}
const parent = parentOrHost(el);
if (!parent || !parent.children) return '';
const children = Array.from(parent.children).filter((child) => isVisible(child) && textOf(child));
const index = children.indexOf(el);
if (index < 0) return '';
const nearby = children.slice(Math.max(0, index - 1), Math.min(children.length, index + 5)).map((child) => textOf(child));
const joined = nearby.join(' ');
return percentagesOf(joined).length >= 2 ? joined : '';
}
function directCountryRows(elements) {
const results = new Map();
elements.forEach((el) => {
const match = countryOfText(textOf(el));
if (!match || results.has(match.country)) return;
const text = bestCountryRowText(el);
const percentages = percentagesOf(text);
if (percentages.length < 2) return;
results.set(match.country, {
country: match.country,
ratio2DaysAgo: percentages[0],
ratio30DaysAgo: percentages[1],
raw_text: text,
source: 'rendered-country-row',
});
});
return Array.from(results.values());
}
function expandedCardOf(elements) {
return elements.find((el) => el.tagName && el.tagName.toLowerCase() === 'casino-card' && /\bcasino-expanded-card\b/.test(el.className || ''));
}
function directRowsFromExpandedCard(expandedCard) {
if (!expandedCard) return [];
const scoped = [];
walkRoots(expandedCard, scoped, new Set());
const countries = [];
const percentages = [];
const seen = new Set();
scoped
.filter((el) => isVisible(el))
.forEach((el) => {
const text = textOf(el);
const title = (el.getAttribute && el.getAttribute('title')) || '';
const aria = (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('arialabel'))) || '';
const candidate = (title || aria || text || '').replace(/\s+/g, ' ').trim();
if (!candidate || candidate.length > 20) return;
const rect = el.getBoundingClientRect();
const area = rect.width * rect.height;
if (area <= 0 || area > 20000) return;
const key = `${candidate}|${Math.round(rect.left)}|${Math.round(rect.top)}|${Math.round(rect.width)}|${Math.round(rect.height)}`;
if (seen.has(key)) return;
seen.add(key);
const countryMatch = countryOfText(candidate);
if (countryMatch) {
countries.push({
country: countryMatch.country,
x: rect.left,
y: rect.top + rect.height / 2,
h: rect.height,
area,
});
return;
}
if (/^\d+(?:\.\d+)?\s*%$/.test(candidate)) {
percentages.push({
value: candidate.replace(/\s+/g, ''),
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
area,
});
}
});
const dedupedCountries = [];
countries
.sort((a, b) => a.y - b.y || a.x - b.x || a.area - b.area)
.forEach((item) => {
const exists = dedupedCountries.some((row) => row.country === item.country && Math.abs(row.y - item.y) < 8);
if (!exists) dedupedCountries.push(item);
});
const results = dedupedCountries.map((row) => {
const sameRow = percentages
.filter((item) => item.x > row.x + 20 && Math.abs(item.y - row.y) <= Math.max(14, row.h))
.sort((a, b) => a.x - b.x || a.y - b.y || a.area - b.area);
const unique = [];
sameRow.forEach((item) => {
if (!unique.some((existing) => existing.value === item.value && Math.abs(existing.x - item.x) < 8)) {
unique.push(item);
}
});
return {
country: row.country,
ratio2DaysAgo: unique[0] ? unique[0].value : '',
ratio30DaysAgo: unique[1] ? unique[1].value : '',
source: 'expanded-card-geometry',
};
});
return results.filter((item) => item.ratio2DaysAgo || item.ratio30DaysAgo);
}
function scoreRegion(el) {
const text = textOf(el);
if (!/%/.test(text)) return 0;
let score = 0;
if (/推荐报价百分比|推荐报价|featured offer percentage|featured offer|buy box/i.test(text)) score += 80;
if (/2\s*天前|30\s*天前|2\s*日前|30\s*日前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(text)) score += 80;
if (/欧洲|英国|德国|法国|西班牙|意大利|Europe|UK|United Kingdom|Germany|France|Spain|Italy/i.test(text)) score += 40;
if (/%/.test(text)) score += 20;
if (isVisible(el)) score += 10;
if (text.length > 2500) score -= 40;
return score;
}
function isRelevantRatioRow(row) {
const combined = `${row.raw_text} ${row.label_text} ${row.percentage_text}`;
const hasDayLabels = /2\s*天前|30\s*天前|2\s*日前|30\s*日前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(combined);
const hasCountries = /欧洲|英国|德国|法国|西班牙|意大利|Europe|UK|United Kingdom|Germany|France|Spain|Italy/i.test(combined);
if (!/%|2\s*天前|30\s*天前|2\s*日前|30\s*日前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(combined) && !hasCountries) return false;
if (row.raw_text.length > 800 && !hasDayLabels && !hasCountries) return false;
return true;
}
function collectRelevantRows(sourceElements) {
const byText = new Map();
const scoped = [];
sourceElements.forEach((root) => walkRoots(root, scoped, new Set()));
scoped
.filter((el) => isVisible(el))
.forEach((el, index) => {
const row = rowOf(el, index);
if (!isRelevantRatioRow(row)) return;
const combined = `${row.raw_text} ${row.label_text} ${row.percentage_text}`;
const key = `${combined}|${Math.round(row.rect_left)}|${Math.round(row.rect_top)}|${Math.round(row.rect_width)}|${Math.round(row.rect_height)}`;
if (!byText.has(key)) byText.set(key, row);
});
return Array.from(byText.values());
}
function scrollableElements(elements) {
return elements.filter((el) => {
if (!el || !el.getBoundingClientRect || !isVisible(el)) return false;
const style = window.getComputedStyle(el);
if (!/(auto|scroll)/i.test(`${style.overflowY} ${style.overflow}`)) return false;
return el.scrollHeight > el.clientHeight + 20;
});
}
function collectRowsAcrossScroll(sourceElements, visibleElements) {
const targets = scrollableElements(visibleElements);
if (document.scrollingElement) targets.push(document.scrollingElement);
const byKey = new Map();
const originals = targets.map((target) => ({ target, top: target.scrollTop }));
for (const target of targets) {
const maxTop = Math.max(0, target.scrollHeight - target.clientHeight);
const positions = [...new Set([0, Math.floor(maxTop / 2), maxTop])];
for (const position of positions) {
target.scrollTop = position;
collectRelevantRows(sourceElements).forEach((row) => {
const combined = `${row.raw_text} ${row.label_text} ${row.percentage_text}`;
const key = `${combined}|${Math.round(row.rect_left)}|${Math.round(row.rect_top)}|${Math.round(row.rect_width)}|${Math.round(row.rect_height)}`;
if (!byKey.has(key)) byKey.set(key, row);
});
}
}
originals.forEach(({ target, top }) => {
target.scrollTop = top;
});
return Array.from(byKey.values());
}
const visible = allElements().filter((el) => isVisible(el));
const expandedCard = expandedCardOf(visible);
const expandedDirectRows = directRowsFromExpandedCard(expandedCard);
const directRows = expandedDirectRows.length ? expandedDirectRows : directCountryRows(visible);
const regions = visible
.map((el, index) => {
const rect = el.getBoundingClientRect();
return { el, index, score: scoreRegion(el), text: textOf(el), area: rect.width * rect.height };
})
.filter((row) => row.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.text.length !== b.text.length) return a.text.length - b.text.length;
return a.area - b.area;
});
const sourceElements = expandedCard ? [expandedCard] : (regions.length ? [regions[0].el] : visible);
const scoped = [];
sourceElements.forEach((root) => walkRoots(root, scoped, new Set()));
const byText = new Map();
collectRelevantRows(sourceElements)
.concat(collectRowsAcrossScroll(sourceElements, visible))
.forEach((row) => {
const combined = `${row.raw_text} ${row.label_text} ${row.percentage_text}`;
const key = `${combined}|${Math.round(row.rect_left)}|${Math.round(row.rect_top)}|${Math.round(row.rect_width)}|${Math.round(row.rect_height)}`;
if (!byText.has(key)) byText.set(key, row);
});
return {
directRows,
region: regions.length
? {
score: regions[0].score,
text: regions[0].text.slice(0, 2000),
outerHTML: (regions[0].el.outerHTML || '').slice(0, 3000),
}
: {
url: window.location.href,
title: document.title,
},
rows: Array.from(byText.values()),
candidates: regions.slice(0, 20).map((row) => ({
index: row.index,
score: row.score,
text: row.text.slice(0, 1000),
})),
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.module-page[data-v-e32c69fa]{min-height:100vh;background:#1a1a1a}.main-content[data-v-e32c69fa]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-e32c69fa]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-e32c69fa]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-e32c69fa],.subsection-title[data-v-e32c69fa]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-e32c69fa]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-e32c69fa],.loading-msg[data-v-e32c69fa],.files[data-v-e32c69fa],.muted[data-v-e32c69fa]{color:#888;font-size:12px;line-height:1.5}.link[data-v-e32c69fa]{color:#6ea8fe;text-decoration:none}.link[data-v-e32c69fa]:hover{color:#9fc5ff}.btns[data-v-e32c69fa],.run-row[data-v-e32c69fa]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-e32c69fa],.btn-run[data-v-e32c69fa],.btn-delete[data-v-e32c69fa]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-e32c69fa]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-e32c69fa]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-e32c69fa]{background:#27ae60}.btn-run[data-v-e32c69fa]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-e32c69fa]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-e32c69fa]{display:block;margin:4px 0}.prompt-card[data-v-e32c69fa]{margin-bottom:18px}.prompt-input[data-v-e32c69fa]{width:100%;box-sizing:border-box;resize:vertical;min-height:180px;padding:10px 12px;border:1px solid #333;border-radius:8px;background:#202020;color:#d8d8d8;font-size:12px;line-height:1.6;outline:none}.prompt-input[data-v-e32c69fa]:focus{border-color:#3498db}.prompt-default-label[data-v-e32c69fa]{margin-top:10px;color:#8d8d8d;font-size:12px}.prompt-preview[data-v-e32c69fa]{margin-top:10px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#9ea7b3;font-size:12px;line-height:1.6;white-space:pre-wrap}.parse-card[data-v-e32c69fa],.queue-payload[data-v-e32c69fa]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-e32c69fa]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-e32c69fa]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-e32c69fa]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-e32c69fa]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-e32c69fa]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-e32c69fa]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-e32c69fa]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-e32c69fa]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-e32c69fa]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-e32c69fa]{color:#666;font-size:13px;padding:18px;text-align:center}.result-table[data-v-e32c69fa]{--el-table-bg-color: #222;--el-table-tr-bg-color: #222;--el-table-header-bg-color: #2a2a2a;--el-table-text-color: #ccc;--el-table-border-color: #333}.task-list[data-v-e32c69fa]{list-style:none;margin:0;padding:12px}.task-item[data-v-e32c69fa]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-e32c69fa]{flex:1;min-width:0}.id[data-v-e32c69fa]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-e32c69fa]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-e32c69fa]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-e32c69fa]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-e32c69fa]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-e32c69fa]{background:#3498db2e;color:#3498db}.btn-delete[data-v-e32c69fa]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-e32c69fa]{flex-direction:column;height:auto}.left-panel[data-v-e32c69fa]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-e32c69fa]{grid-template-columns:repeat(2,minmax(0,1fr))}}

View File

@@ -0,0 +1 @@
.module-page[data-v-d71413c4]{min-height:100vh;background:#1a1a1a}.main-content[data-v-d71413c4]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-d71413c4]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-d71413c4]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-d71413c4],.subsection-title[data-v-d71413c4]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-d71413c4]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-d71413c4],.loading-msg[data-v-d71413c4],.files[data-v-d71413c4],.muted[data-v-d71413c4]{color:#888;font-size:12px;line-height:1.5}.link[data-v-d71413c4]{color:#6ea8fe;text-decoration:none}.link[data-v-d71413c4]:hover{color:#9fc5ff}.btns[data-v-d71413c4],.run-row[data-v-d71413c4]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-d71413c4],.btn-run[data-v-d71413c4],.btn-delete[data-v-d71413c4],.download[data-v-d71413c4]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-d71413c4]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-d71413c4]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-d71413c4]{background:#27ae60}.btn-run[data-v-d71413c4]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-d71413c4]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-d71413c4]{display:block;margin:4px 0}.prompt-card[data-v-d71413c4]{margin-bottom:18px}.prompt-input[data-v-d71413c4]{width:100%;box-sizing:border-box;resize:vertical;min-height:180px;padding:10px 12px;border:1px solid #333;border-radius:8px;background:#202020;color:#d8d8d8;font-size:12px;line-height:1.6;outline:none}.prompt-input[data-v-d71413c4]:focus{border-color:#3498db}.prompt-default-label[data-v-d71413c4]{margin-top:10px;color:#8d8d8d;font-size:12px}.prompt-preview[data-v-d71413c4]{margin-top:10px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#9ea7b3;font-size:12px;line-height:1.6;white-space:pre-wrap}.parse-card[data-v-d71413c4],.queue-payload[data-v-d71413c4]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-d71413c4]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-d71413c4]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-d71413c4]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-d71413c4]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-d71413c4]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-d71413c4]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-d71413c4]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-d71413c4]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-d71413c4]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-d71413c4]{color:#666;font-size:13px;padding:18px;text-align:center}.result-table[data-v-d71413c4]{--el-table-bg-color: #222;--el-table-tr-bg-color: #222;--el-table-header-bg-color: #2a2a2a;--el-table-text-color: #ccc;--el-table-border-color: #333}.task-list[data-v-d71413c4]{list-style:none;margin:0;padding:12px}.task-item[data-v-d71413c4]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-d71413c4]{flex:1;min-width:0}.id[data-v-d71413c4]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-d71413c4]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-d71413c4]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-d71413c4]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-d71413c4]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-d71413c4]{background:#3498db2e;color:#3498db}.result-hint[data-v-d71413c4]{margin-top:6px;color:#e0b96d}.download[data-v-d71413c4]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-d71413c4]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-d71413c4]{flex-direction:column;height:auto}.left-panel[data-v-d71413c4]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-d71413c4]{grid-template-columns:repeat(2,minmax(0,1fr))}}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{Q as r}from"./pywebview-V_UIajJm.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
import{aV as r}from"./pywebview-Bt854mYs.js";const n="";function a(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{a as e};

View File

@@ -0,0 +1 @@
import{Q as r}from"./pywebview-DiP0HdY6.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
import{aZ as r}from"./pywebview-C66x_2Dh.js";const n="";function a(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{a as e};

View File

@@ -0,0 +1 @@
import{Q as r}from"./pywebview-Cs1Kot1q.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
import{Q as r}from"./pywebview-BCPdlDdb.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
import{aO as r}from"./pywebview-DuyK2jB1.js";const n="";function a(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{a as e};

View File

@@ -0,0 +1 @@
import{aO as r}from"./pywebview-9YBa--7x.js";const n="";function a(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{a as e};

View File

@@ -0,0 +1 @@
import{aI as r}from"./pywebview-D4gpiFjY.js";const n="";function a(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{a as e};

View File

@@ -0,0 +1 @@
import{aO as r}from"./pywebview-ClWy2SbE.js";const n="";function a(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{a as e};

View File

@@ -0,0 +1 @@
import{Q as r}from"./pywebview-D808cNhy.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
import{Q as r}from"./pywebview-Cq_E2BnJ.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
import{Q as r}from"./pywebview-D7_PdvyM.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
import{aO as r}from"./pywebview-CeWJDVeG.js";const n="";function a(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{a as e};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More