feat: patrol delete
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
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
|
||||
@@ -17,8 +17,6 @@ except ImportError:
|
||||
winreg = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATUS_OK = "0"
|
||||
STATUS_LOGIN_FAILED = "-10003"
|
||||
|
||||
@@ -76,7 +74,7 @@ class UserInfo(TypedDict):
|
||||
|
||||
def kill_process(version: Literal["v5", "v6"]):
|
||||
"""结束指定版本的紫鸟客户端进程."""
|
||||
logger.info("准备杀紫鸟客户端进程,version=%s", version)
|
||||
logger.info("准备杀紫鸟客户端进程,version={}", version)
|
||||
driver = ZiniaoDriver({})
|
||||
driver.kill_process(version)
|
||||
|
||||
@@ -171,7 +169,7 @@ class ZiniaoDriver:
|
||||
(winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE"),
|
||||
):
|
||||
try:
|
||||
logger.info("正在从注册表读取紫鸟客户端路径:%s\\%s", root_name, key_path)
|
||||
logger.info("正在从注册表读取紫鸟客户端路径:{}\\{}", root_name, key_path)
|
||||
key = winreg.OpenKey(root_key, key_path)
|
||||
command, _ = winreg.QueryValueEx(key, "")
|
||||
winreg.CloseKey(key)
|
||||
@@ -180,12 +178,12 @@ class ZiniaoDriver:
|
||||
exe_path = command[0 : command.find(sub) + len(sub) + 1]
|
||||
else:
|
||||
exe_path = command[0]
|
||||
logger.info("已获取紫鸟客户端路径:%s", exe_path)
|
||||
logger.info("已获取紫鸟客户端路径:{}", exe_path)
|
||||
return exe_path
|
||||
except FileNotFoundError:
|
||||
logger.warning("注册表中未找到紫鸟客户端路径:%s\\%s", root_name, key_path)
|
||||
logger.warning("注册表中未找到紫鸟客户端路径:{}\\{}", root_name, key_path)
|
||||
|
||||
logger.error("未能获取紫鸟客户端路径,protocol_name=%s", protocol_name)
|
||||
logger.error("未能获取紫鸟客户端路径,protocol_name={}", protocol_name)
|
||||
return None
|
||||
|
||||
def update_core(self):
|
||||
@@ -199,13 +197,13 @@ class ZiniaoDriver:
|
||||
logger.info("开始更新紫鸟浏览器内核")
|
||||
while True:
|
||||
result = self._post_client(payload)
|
||||
logger.info("更新内核返回:%s", result)
|
||||
logger.info("更新内核返回:{}", result)
|
||||
if self._handle_update_core_result(result):
|
||||
return
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
logger.info("等待更新内核完成:%s", self._result_text(result))
|
||||
logger.info("等待更新内核完成:{}", self._result_text(result))
|
||||
time.sleep(UPDATE_CORE_RETRY_DELAY)
|
||||
|
||||
def _handle_update_core_result(self, result: Optional[Dict[str, Any]]) -> bool:
|
||||
@@ -232,14 +230,35 @@ class ZiniaoDriver:
|
||||
if version == "v5":
|
||||
process_name = "SuperBrowser.exe"
|
||||
logger.info("准备结束紫鸟 v5 starter.exe")
|
||||
os.system("taskkill /f /t /im starter.exe")
|
||||
self._kill_process_by_name("starter.exe")
|
||||
else:
|
||||
process_name = "ziniao.exe"
|
||||
|
||||
logger.info("准备结束紫鸟客户端进程:%s", process_name)
|
||||
os.system("taskkill /f /t /im " + process_name)
|
||||
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]]]:
|
||||
"""获取紫鸟浏览器店铺列表.
|
||||
|
||||
@@ -249,18 +268,18 @@ class ZiniaoDriver:
|
||||
payload = self._build_payload("getBrowserList")
|
||||
request_id = payload["requestId"]
|
||||
|
||||
logger.info("开始获取紫鸟浏览器列表,requestId=%s", request_id)
|
||||
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("获取紫鸟浏览器列表成功,数量=%s", len(browser_list or []))
|
||||
logger.info("获取紫鸟浏览器列表成功,数量={}", len(browser_list or []))
|
||||
return browser_list
|
||||
if status_code == STATUS_LOGIN_FAILED:
|
||||
logger.error("获取紫鸟浏览器列表登录失败:%s", self._result_text(result))
|
||||
logger.error("获取紫鸟浏览器列表登录失败:{}", self._result_text(result))
|
||||
return None
|
||||
|
||||
logger.error("获取紫鸟浏览器列表失败:%s", self._result_text(result))
|
||||
logger.error("获取紫鸟浏览器列表失败:{}", self._result_text(result))
|
||||
return None
|
||||
|
||||
def open_store(
|
||||
@@ -295,17 +314,17 @@ class ZiniaoDriver:
|
||||
)
|
||||
request_id = payload["requestId"]
|
||||
|
||||
logger.info("开始打开紫鸟店铺,store_info=%s,requestId=%s", store_info, request_id)
|
||||
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=%s", result.get("debuggingPort"))
|
||||
logger.info("紫鸟店铺打开成功,debuggingPort={}", result.get("debuggingPort"))
|
||||
return result
|
||||
if status_code == STATUS_LOGIN_FAILED:
|
||||
logger.error("打开紫鸟店铺登录失败:%s", self._result_text(result))
|
||||
logger.error("打开紫鸟店铺登录失败:{}", self._result_text(result))
|
||||
raise RuntimeError(f"[open_store]登录失败 {self._result_text(result)}")
|
||||
|
||||
logger.error("打开紫鸟店铺失败:%s", self._result_text(result))
|
||||
logger.error("打开紫鸟店铺失败:{}", self._result_text(result))
|
||||
raise RuntimeError(f"[open_store]失败 {self._result_text(result)} ")
|
||||
|
||||
def _build_start_browser_payload(
|
||||
@@ -352,18 +371,18 @@ class ZiniaoDriver:
|
||||
Returns:
|
||||
DrissionPage Chromium 实例.
|
||||
"""
|
||||
logger.info("开始连接 DrissionPage 浏览器,port=%s", port)
|
||||
logger.info("开始连接 DrissionPage 浏览器,port={}", port)
|
||||
browser = Chromium(port)
|
||||
logger.info("DrissionPage 浏览器连接完成,port=%s", port)
|
||||
logger.info("DrissionPage 浏览器连接完成,port={}", port)
|
||||
return browser
|
||||
|
||||
def start_client(self):
|
||||
"""启动紫鸟客户端并更新浏览器内核."""
|
||||
if self._is_client_ready():
|
||||
logger.info("端口 %s 已启动,跳过启动紫鸟客户端", self.socket_port)
|
||||
logger.info("端口 {} 已启动,跳过启动紫鸟客户端", self.socket_port)
|
||||
return
|
||||
|
||||
logger.info("端口 %s 未启动,开始启动紫鸟客户端", self.socket_port)
|
||||
logger.info("端口 {} 未启动,开始启动紫鸟客户端", self.socket_port)
|
||||
self.kill_process("v6")
|
||||
time.sleep(CLIENT_RESTART_DELAY)
|
||||
client_path = self.get_zinaio_exe("superbrowserv6")
|
||||
@@ -377,21 +396,21 @@ class ZiniaoDriver:
|
||||
"--ipc_type=http",
|
||||
"--port=" + str(self.socket_port),
|
||||
]
|
||||
logger.info("紫鸟客户端启动命令:%s", " ".join(cmd))
|
||||
logger.info("紫鸟客户端启动命令:{}", " ".join(cmd))
|
||||
|
||||
for retry_count in range(CLIENT_START_RETRIES):
|
||||
logger.info("第 %s/%s 次尝试启动紫鸟客户端", retry_count + 1, 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("紫鸟客户端启动成功,第 %s 次尝试", retry_count + 1)
|
||||
logger.info("紫鸟客户端启动成功,第 {} 次尝试", retry_count + 1)
|
||||
time.sleep(CLIENT_POST_START_DELAY)
|
||||
self.update_core()
|
||||
return
|
||||
|
||||
logger.warning("第 %s 次尝试启动失败,10秒内未检测到客户端启动", retry_count + 1)
|
||||
logger.warning("第 {} 次尝试启动失败,10秒内未检测到客户端启动", retry_count + 1)
|
||||
|
||||
logger.error("紫鸟客户端启动失败,已重试 %s 次", CLIENT_START_RETRIES)
|
||||
logger.error("紫鸟客户端启动失败,已重试 {} 次", CLIENT_START_RETRIES)
|
||||
raise RuntimeError(f"客户端启动失败:重试 {CLIENT_START_RETRIES} 次后仍未成功启动")
|
||||
|
||||
def open_shop(self, shop_name: str):
|
||||
@@ -403,12 +422,12 @@ class ZiniaoDriver:
|
||||
Returns:
|
||||
成功时返回浏览器实例.店铺不存在时返回错误信息.
|
||||
"""
|
||||
logger.info("开始打开店铺:%s", shop_name)
|
||||
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("店铺不存在:%s", shop_name)
|
||||
logger.warning("店铺不存在:{}", shop_name)
|
||||
return "店铺不存在"
|
||||
|
||||
ret_json = self.open_store(self.store_id)
|
||||
@@ -419,16 +438,16 @@ class ZiniaoDriver:
|
||||
self.browser = self.get_browser(ret_json.get("debuggingPort"))
|
||||
ip_check_url = ret_json.get("ipDetectionPage")
|
||||
if not ip_check_url:
|
||||
logger.error("ip检测页地址为空,准备关闭店铺:%s", shop_name)
|
||||
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检测通过,打开店铺平台主页:%s", shop_name)
|
||||
logger.info("IP检测通过,打开店铺平台主页:{}", shop_name)
|
||||
self.open_launcher_page(ret_json.get("launcherPage"), self.browser)
|
||||
return self.browser
|
||||
|
||||
logger.error("IP检测不通过,停止打开店铺:%s", shop_name)
|
||||
logger.error("IP检测不通过,停止打开店铺:{}", shop_name)
|
||||
raise RuntimeError("IP检测不通过,可能是因为网络环境变化导致的,为了店铺安全不打开店铺")
|
||||
|
||||
def close_store(self, browser_oauth=None):
|
||||
@@ -452,17 +471,17 @@ class ZiniaoDriver:
|
||||
)
|
||||
request_id = payload["requestId"]
|
||||
|
||||
logger.info("开始关闭紫鸟店铺,browserOauth=%s,requestId=%s", browser_oauth, request_id)
|
||||
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=%s", browser_oauth)
|
||||
logger.info("紫鸟店铺关闭成功,browserOauth={}", browser_oauth)
|
||||
return result
|
||||
if status_code == STATUS_LOGIN_FAILED:
|
||||
logger.error("关闭紫鸟店铺登录失败:%s", 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)}")
|
||||
|
||||
logger.error("关闭紫鸟店铺失败:%s", 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):
|
||||
@@ -475,7 +494,7 @@ class ZiniaoDriver:
|
||||
if browser is None:
|
||||
browser = self.browser
|
||||
|
||||
logger.info("打开店铺平台启动页:%s", launcher_page)
|
||||
logger.info("打开店铺平台启动页:{}", launcher_page)
|
||||
tab = browser.new_tab(url=launcher_page)
|
||||
self.tab = tab
|
||||
return tab
|
||||
@@ -491,7 +510,7 @@ class ZiniaoDriver:
|
||||
IP 检测通过时返回 True,否则返回 False.
|
||||
"""
|
||||
try:
|
||||
logger.info("开始打开IP检测页:%s", ip_check_url)
|
||||
logger.info("开始打开IP检测页:{}", ip_check_url)
|
||||
tab = browser.latest_tab
|
||||
tab.get(ip_check_url)
|
||||
success_button = tab.ele(
|
||||
@@ -512,7 +531,7 @@ class ZiniaoDriver:
|
||||
class AmamzonBase(ZiniaoDriver):
|
||||
"""亚马逊页面操作基类."""
|
||||
|
||||
def SwitchingCountries(self, country_name: str):
|
||||
def switch_to_country(self, country_name: str):
|
||||
"""切换亚马逊账号国家并验证结果.
|
||||
|
||||
流程包括读取当前国家,打开国家下拉框,选择目标国家并等待页面加载.
|
||||
@@ -534,7 +553,7 @@ class AmamzonBase(ZiniaoDriver):
|
||||
logger.warning("无法获取当前国家信息")
|
||||
return False
|
||||
if current_country == country_name:
|
||||
logger.info("当前已经是目标国家 %s,无需切换", country_name)
|
||||
logger.info("当前已经是目标国家 {},无需切换", country_name)
|
||||
return True
|
||||
|
||||
if not self._open_country_dropdown():
|
||||
@@ -557,7 +576,7 @@ class AmamzonBase(ZiniaoDriver):
|
||||
return None
|
||||
|
||||
current_country = current_country_ele.text.strip()
|
||||
logger.info("当前国家:%s", current_country)
|
||||
logger.info("当前国家:{}", current_country)
|
||||
return current_country
|
||||
|
||||
def _open_country_dropdown(self) -> bool:
|
||||
@@ -579,10 +598,10 @@ class AmamzonBase(ZiniaoDriver):
|
||||
return True
|
||||
|
||||
def _select_country(self, country_name: str) -> bool:
|
||||
logger.info("正在切换到国家:%s", country_name)
|
||||
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("找不到目标国家:%s", country_name)
|
||||
logger.warning("找不到目标国家:{}", country_name)
|
||||
return False
|
||||
target_country.click()
|
||||
return True
|
||||
@@ -601,10 +620,10 @@ class AmamzonBase(ZiniaoDriver):
|
||||
logger.warning("无法验证国家切换结果")
|
||||
return False
|
||||
if new_country == country_name:
|
||||
logger.info("国家切换成功:%s", new_country)
|
||||
logger.info("国家切换成功:{}", new_country)
|
||||
return True
|
||||
|
||||
logger.warning("国家切换失败,当前国家:%s,目标国家:%s", new_country, country_name)
|
||||
logger.warning("国家切换失败,当前国家:{},目标国家:{}", new_country, country_name)
|
||||
return False
|
||||
|
||||
def need_login(self):
|
||||
@@ -657,7 +676,7 @@ class AmamzonBase(ZiniaoDriver):
|
||||
|
||||
def _send_otp_if_present(self) -> bool:
|
||||
send_code = self.tab.eles(OTP_SEND_XPATH, timeout=OTP_SEND_TIMEOUT)
|
||||
logger.info("发送一次性密码元素数量:%s", len(send_code))
|
||||
logger.info("发送一次性密码元素数量:{}", len(send_code))
|
||||
if len(send_code) == 0:
|
||||
return False
|
||||
|
||||
@@ -678,7 +697,7 @@ class AmamzonBase(ZiniaoDriver):
|
||||
|
||||
def _find_otp_input(self):
|
||||
otp_code_input = self.tab.eles(OTP_INPUT_XPATH, timeout=OTP_INPUT_TIMEOUT)
|
||||
logger.info("验证码输入框数量:%s", len(otp_code_input))
|
||||
logger.info("验证码输入框数量:{}", len(otp_code_input))
|
||||
if len(otp_code_input) == 0:
|
||||
return None
|
||||
|
||||
@@ -706,7 +725,7 @@ class AmamzonBase(ZiniaoDriver):
|
||||
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("验证码输入错误:%s", error_mes[0].text)
|
||||
logger.warning("验证码输入错误:{}", error_mes[0].text)
|
||||
self.tab.refresh()
|
||||
return False
|
||||
|
||||
@@ -718,3 +737,4 @@ class AmamzonBase(ZiniaoDriver):
|
||||
if len(submit_btn) > 0:
|
||||
logger.info("点击二次登录提交按钮")
|
||||
submit_btn[0].click()
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ from typing import Dict, Any, List
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from config import JSON_TASK_QUEUE, runing_task, runing_shop, DELETE_BRAND_API_BASE, ZN_COMPANY, ZN_USERNAME, ZN_PASSWORD
|
||||
from amazon.del_brand import AmazoneDriver, kill_process
|
||||
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.approve import ApproveTask
|
||||
from amazon.match_action import MatchTak
|
||||
from amazon.price_match import PriceTask
|
||||
from amazon.asin_status import StatusTask
|
||||
from amazon.product import ProductTask
|
||||
|
||||
|
||||
from amazon.tool import get_shop_info,show_notification
|
||||
@@ -53,12 +54,13 @@ class TaskMonitor:
|
||||
self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
|
||||
futures = [] # 保存所有提交的任务Future对象
|
||||
|
||||
task_type_info = {
|
||||
"product-risk-resolve-run" : "产品风险审批",
|
||||
"shop-match-run" : "匹配价格",
|
||||
"price-track-run" : "跟价",
|
||||
"query-asin-run" : "状态查询"
|
||||
}
|
||||
task_type_info = {
|
||||
"product-risk-resolve-run" : "产品风险审批",
|
||||
"shop-match-run" : "匹配价格",
|
||||
"price-track-run" : "跟价",
|
||||
"query-asin-run" : "状态查询",
|
||||
"patrol-delete-run" : "巡店删除"
|
||||
}
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
@@ -123,10 +125,11 @@ class TaskMonitor:
|
||||
try:
|
||||
TASK_INFO = {
|
||||
"产品风险审批" : ApproveTask,
|
||||
"匹配价格" : MatchTak,
|
||||
"跟价" : PriceTask,
|
||||
"状态查询" : StatusTask
|
||||
}
|
||||
"匹配价格" : MatchTak,
|
||||
"跟价" : PriceTask,
|
||||
"状态查询" : StatusTask,
|
||||
"巡店删除" : ProductTask
|
||||
}
|
||||
self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...")
|
||||
# 创建ApproveTask实例并处理任务
|
||||
TASK_CLS = TASK_INFO[TASK_TYPE] # 根据任务类型选择处理类,默认为ApproveTask
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
121
app/amazon/scripts/product/complete_draft_quick_view_tags.js
Normal file
121
app/amazon/scripts/product/complete_draft_quick_view_tags.js
Normal 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),
|
||||
};
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
95
app/amazon/scripts/product/listing_status_dropdown_debug.js
Normal file
95
app/amazon/scripts/product/listing_status_dropdown_debug.js
Normal 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);
|
||||
225
app/amazon/scripts/product/listing_status_dropdown_open.js
Normal file
225
app/amazon/scripts/product/listing_status_dropdown_open.js
Normal file
@@ -0,0 +1,225 @@
|
||||
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);
|
||||
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 statusHitCount(text) {
|
||||
const matches = normalize(text).match(
|
||||
/(全部\s*[((]\s*[\d,]+\s*[))]|在售\s*[((]\s*[\d,]+\s*[))]|不可售\s*[((]\s*[\d,]+\s*[))]|详情页面已删除\s*[((]\s*[\d,]+\s*[))]|订单页面已删除\s*[((]\s*[\d,]+\s*[))]|定价问题\s*[((]\s*[\d,]+\s*[))]|需要批准\s*[((]\s*[\d,]+\s*[))]|在搜索结果中禁止显示\s*[((]\s*[\d,]+\s*[))])/g
|
||||
);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
function scoreDropdown(el) {
|
||||
const text = textOf(el);
|
||||
const label = nearestLabel(el);
|
||||
const haystack = `${el.tagName} ${attrsText(el)} ${text} ${label}`;
|
||||
const statusHits = statusHitCount(`${text} ${label}`);
|
||||
let score = 0;
|
||||
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}`)) score += 300;
|
||||
if (statusHits >= 2) score += statusHits * 180;
|
||||
if (/SKUS?|ASIN|FNSKU|UPC\/EAN|TITLE_KEYWORD|商品名称\/关键字/i.test(`${text} ${label}`)) score -= 300;
|
||||
if (isVisible(el)) score += 10;
|
||||
if (el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]')) score -= 80;
|
||||
return { score, text, label, haystack, statusHits };
|
||||
}
|
||||
|
||||
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];
|
||||
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,
|
||||
text: row.text,
|
||||
label: row.label,
|
||||
attrs: attrsText(row.el),
|
||||
outerHTML: (row.el.outerHTML || '').slice(0, 1000),
|
||||
})),
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
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 insideListingRow(el) {
|
||||
return Boolean(el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]'));
|
||||
}
|
||||
|
||||
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,
|
||||
attrs,
|
||||
visible: isVisible(el),
|
||||
outerHTML: (el.outerHTML || '').slice(0, 1200),
|
||||
};
|
||||
}
|
||||
|
||||
const markedDropdown = document.querySelector('[data-crawler-listing-status-dropdown="true"]');
|
||||
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, [role="option"]')));
|
||||
}
|
||||
}
|
||||
|
||||
const allOptions = allElements().filter((el) => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const role = el.getAttribute('role');
|
||||
if (tag !== 'kat-option' && role !== 'option') return false;
|
||||
if (insideListingRow(el)) return false;
|
||||
return isVisible(el);
|
||||
});
|
||||
|
||||
const byNode = new Map();
|
||||
scoped.concat(allOptions).forEach((el, index) => {
|
||||
if (!byNode.has(el)) byNode.set(el, optionRow(el, index));
|
||||
});
|
||||
|
||||
return Array.from(byNode.values()).filter((row) => row.raw_text);
|
||||
91
app/amazon/scripts/product/listing_status_section.js
Normal file
91
app/amazon/scripts/product/listing_status_section.js
Normal 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),
|
||||
};
|
||||
79
app/amazon/scripts/product/listing_status_select_option.js
Normal file
79
app/amazon/scripts/product/listing_status_select_option.js
Normal file
@@ -0,0 +1,79 @@
|
||||
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 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),
|
||||
};
|
||||
}
|
||||
|
||||
const targetStatus = normalize((__crawlerPayload || {}).status);
|
||||
const dropdown = document.querySelector('[data-crawler-listing-status-dropdown="true"]');
|
||||
if (!dropdown) {
|
||||
return { ok: false, reason: 'marked dropdown missing', targetStatus };
|
||||
}
|
||||
|
||||
const roots = [dropdown];
|
||||
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
|
||||
|
||||
const options = [];
|
||||
for (const root of roots) {
|
||||
for (const el of Array.from(root.querySelectorAll('kat-option, [role="option"]'))) {
|
||||
if (!isVisible(el)) continue;
|
||||
options.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
const metas = options.map((el, index) => ({ el, ...optionMeta(el, index) }));
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
matched.el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
matched.el.click();
|
||||
return {
|
||||
ok: true,
|
||||
targetStatus,
|
||||
selected: {
|
||||
index: matched.index,
|
||||
rawText: matched.rawText,
|
||||
parsedStatus: matched.parsedStatus,
|
||||
value: matched.value,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
132
app/amazon/scripts/product/recommended_offer_percentage_open.js
Normal file
132
app/amazon/scripts/product/recommended_offer_percentage_open.js
Normal 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),
|
||||
})),
|
||||
};
|
||||
279
app/amazon/scripts/product/recommended_offer_percentage_rows.js
Normal file
279
app/amazon/scripts/product/recommended_offer_percentage_rows.js
Normal file
@@ -0,0 +1,279 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
scoped
|
||||
.filter((el) => isVisible(el))
|
||||
.forEach((el, index) => {
|
||||
const row = rowOf(el, index);
|
||||
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;
|
||||
if (row.raw_text.length > 800 && !hasDayLabels && !hasCountries) return;
|
||||
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>格式转换 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-C66x_2Dh.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据去重 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-C66x_2Dh.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>删除品牌 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-CWLpe7lu.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-C66x_2Dh.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BP3XWKAC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据拆分 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-C66x_2Dh.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -37,6 +37,7 @@ dependencies = [
|
||||
"importlib_metadata==8.7.1",
|
||||
"itsdangerous==2.2.0",
|
||||
"Jinja2==3.1.6",
|
||||
"loguru>=0.7.3",
|
||||
"lxml==6.0.2",
|
||||
"MarkupSafe==3.0.3",
|
||||
"MouseInfo==0.1.3",
|
||||
|
||||
@@ -106,15 +106,19 @@ def test_kill_process_uses_ziniao_driver(monkeypatch):
|
||||
assert calls == [("init", {}), ("kill_process", "v6")]
|
||||
|
||||
|
||||
def test_get_zinaio_exe_returns_none_when_winreg_unavailable(monkeypatch, caplog):
|
||||
def test_get_zinaio_exe_returns_none_when_winreg_unavailable(monkeypatch):
|
||||
driver = base.ZiniaoDriver({})
|
||||
monkeypatch.setattr(base, "winreg", None)
|
||||
messages = []
|
||||
sink_id = base.logger.add(messages.append, level="ERROR", format="{message}")
|
||||
|
||||
with caplog.at_level("ERROR", logger=base.logger.name):
|
||||
try:
|
||||
result = driver.get_zinaio_exe()
|
||||
finally:
|
||||
base.logger.remove(sink_id)
|
||||
|
||||
assert result is None
|
||||
assert any("winreg" in message for message in caplog.messages)
|
||||
assert any("winreg" in message for message in messages)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -250,7 +254,7 @@ def test_switching_countries_returns_true_when_already_current():
|
||||
}
|
||||
)
|
||||
|
||||
assert driver.SwitchingCountries("西班牙") is True
|
||||
assert driver.switch_to_country("西班牙") is True
|
||||
|
||||
|
||||
def test_switching_countries_returns_false_when_target_missing(monkeypatch):
|
||||
@@ -268,7 +272,7 @@ def test_switching_countries_returns_false_when_target_missing(monkeypatch):
|
||||
}
|
||||
)
|
||||
|
||||
assert driver.SwitchingCountries("西班牙") is False
|
||||
assert driver.switch_to_country("西班牙") is False
|
||||
|
||||
|
||||
def test_switching_countries_clicks_and_verifies_target(monkeypatch):
|
||||
@@ -292,7 +296,7 @@ def test_switching_countries_clicks_and_verifies_target(monkeypatch):
|
||||
}
|
||||
)
|
||||
|
||||
assert driver.SwitchingCountries("西班牙") is True
|
||||
assert driver.switch_to_country("西班牙") is True
|
||||
assert dropdown.clicks == 1
|
||||
assert first_item.clicks == 1
|
||||
assert target.clicks == 1
|
||||
|
||||
834
app/tests/test_product.py
Normal file
834
app/tests/test_product.py
Normal file
@@ -0,0 +1,834 @@
|
||||
from amazon.product import InventoryManage, ProductTask
|
||||
|
||||
|
||||
def test_product_task_post_result_builds_patrol_delete_payload(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
text = '{"success":true}'
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("config.DELETE_BRAND_API_BASE", "http://java.example")
|
||||
monkeypatch.setattr("amazon.product.requests.post", fake_post)
|
||||
|
||||
country_sections = [
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [{"status": "正常", "quantity": "12", "deleteQuantity": "3", "processStatus": "处理中"}],
|
||||
}
|
||||
]
|
||||
cart_ratios = [{"country": "德国", "ratio": "25%"}]
|
||||
|
||||
ProductTask().post_result(
|
||||
task_id=3089,
|
||||
shop_name="郭亚芳",
|
||||
country_sections=country_sections,
|
||||
cart_ratios=cart_ratios,
|
||||
shop_done=True,
|
||||
)
|
||||
|
||||
assert captured["url"] == "http://java.example/api/patrol-delete/tasks/3089/result"
|
||||
assert captured["kwargs"]["headers"] == {"Content-Type": "application/json"}
|
||||
assert captured["kwargs"]["timeout"] == 30
|
||||
assert captured["kwargs"]["verify"] is False
|
||||
assert captured["kwargs"]["json"]["shops"] == [
|
||||
{
|
||||
"shopName": "郭亚芳",
|
||||
"shopDone": True,
|
||||
"submissionId": captured["kwargs"]["json"]["shops"][0]["submissionId"],
|
||||
"chunkIndex": 1,
|
||||
"chunkTotal": 1,
|
||||
"countrySections": country_sections,
|
||||
"cartRatios": cart_ratios,
|
||||
}
|
||||
]
|
||||
assert captured["kwargs"]["json"]["shops"][0]["submissionId"].startswith("patrol-delete:3089:郭亚芳:")
|
||||
|
||||
|
||||
def test_product_task_process_task_aggregates_country_results(monkeypatch):
|
||||
from config import runing_shop, runing_task
|
||||
|
||||
runing_task.clear()
|
||||
runing_shop.clear()
|
||||
calls = []
|
||||
|
||||
class Driver:
|
||||
def close_store(self):
|
||||
return None
|
||||
|
||||
def fake_post_result(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
def fake_open_shop(self, **kwargs):
|
||||
return Driver()
|
||||
|
||||
def fake_process_country(self, driver, task_id, shop_name, country_name):
|
||||
if country_name == "德国":
|
||||
return {
|
||||
"success": True,
|
||||
"countrySection": {
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "商品信息草稿",
|
||||
"quantity": "0",
|
||||
"deleteQuantity": "2",
|
||||
"processStatus": "已完成",
|
||||
}
|
||||
],
|
||||
},
|
||||
"cartRatio": {"country": "德国", "ratio": "25%"},
|
||||
"deletedCount": 2,
|
||||
"reopenRequired": False,
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"countrySection": {
|
||||
"country": "英国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "全部",
|
||||
"quantity": "",
|
||||
"deleteQuantity": "",
|
||||
"processStatus": "处理失败: 切换国家失败",
|
||||
}
|
||||
],
|
||||
},
|
||||
"cartRatio": {"country": "英国", "ratio": ""},
|
||||
"deletedCount": 0,
|
||||
"reopenRequired": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(ProductTask, "open_shop", fake_open_shop)
|
||||
monkeypatch.setattr(ProductTask, "process_country", fake_process_country)
|
||||
monkeypatch.setattr(ProductTask, "post_result", fake_post_result)
|
||||
|
||||
country_sections = [
|
||||
{"country": "德国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]},
|
||||
{"country": "英国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]},
|
||||
]
|
||||
cart_ratios = [{"country": "德国", "ratio": ""}, {"country": "英国", "ratio": ""}]
|
||||
ProductTask().process_task(
|
||||
{
|
||||
"type": "patrol-delete-run",
|
||||
"data": {
|
||||
"taskId": 1,
|
||||
"items": [{"shopName": "郭亚芳", "companyName": "rongchuang123"}],
|
||||
"country_sections": country_sections,
|
||||
"cart_ratios": cart_ratios,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert runing_task[1]["status"] == "completed"
|
||||
assert runing_task[1]["processed_shops"] == 1
|
||||
assert runing_task[1]["processed_countries"] == 2
|
||||
assert runing_task[1]["success_count"] == 1
|
||||
assert runing_task[1]["failed_count"] == 0
|
||||
assert runing_task[1]["failed_countries"] == 1
|
||||
assert runing_task[1]["deleted_listings_count"] == 2
|
||||
assert "郭亚芳" not in runing_shop
|
||||
assert calls == [
|
||||
{
|
||||
"task_id": 1,
|
||||
"shop_name": "郭亚芳",
|
||||
"country_sections": [
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "商品信息草稿",
|
||||
"quantity": "0",
|
||||
"deleteQuantity": "2",
|
||||
"processStatus": "已完成",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"country": "英国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "全部",
|
||||
"quantity": "",
|
||||
"deleteQuantity": "",
|
||||
"processStatus": "处理失败: 切换国家失败",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"cart_ratios": [{"country": "德国", "ratio": "25%"}, {"country": "英国", "ratio": ""}],
|
||||
"shop_done": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_product_task_process_task_skips_missing_required_data(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_post_result(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(ProductTask, "post_result", fake_post_result)
|
||||
|
||||
ProductTask().process_task({"type": "patrol-delete-run", "data": {"items": [{"shopName": "郭亚芳"}]}})
|
||||
ProductTask().process_task({"type": "patrol-delete-run", "data": {"taskId": 1, "items": []}})
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_product_task_reports_shop_error(monkeypatch):
|
||||
from config import runing_shop, runing_task
|
||||
|
||||
runing_task.clear()
|
||||
runing_shop.clear()
|
||||
calls = []
|
||||
|
||||
def fake_post_result(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
def fake_open_shop(self, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ProductTask, "open_shop", fake_open_shop)
|
||||
monkeypatch.setattr(ProductTask, "post_result", fake_post_result)
|
||||
|
||||
ProductTask().process_task(
|
||||
{
|
||||
"type": "patrol-delete-run",
|
||||
"data": {
|
||||
"taskId": 2,
|
||||
"items": [{"shopName": "郭亚芳", "companyName": "rongchuang123"}],
|
||||
"country_sections": [{"country": "德国", "rows": [{"status": "全部"}]}],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert runing_task[2]["status"] == "completed"
|
||||
assert runing_task[2]["processed_shops"] == 1
|
||||
assert runing_task[2]["success_count"] == 0
|
||||
assert runing_task[2]["failed_count"] == 1
|
||||
assert "郭亚芳" not in runing_shop
|
||||
assert calls == [{"task_id": 2, "shop_name": "郭亚芳", "error": "店铺 郭亚芳 打开失败", "shop_done": True}]
|
||||
|
||||
|
||||
def test_delete_all_listings_from_non_whitelisted_statuses_loops_until_no_candidates(monkeypatch):
|
||||
driver = InventoryManage({})
|
||||
sections = [
|
||||
[
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{"status": "商品信息草稿", "quantity": "2", "deleteQuantity": "", "processStatus": ""},
|
||||
{"status": "全部", "quantity": "9", "deleteQuantity": "", "processStatus": ""},
|
||||
],
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{"status": "商品信息草稿", "quantity": "1", "deleteQuantity": "", "processStatus": ""},
|
||||
{"status": "全部", "quantity": "8", "deleteQuantity": "", "processStatus": ""},
|
||||
],
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""},
|
||||
],
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""},
|
||||
],
|
||||
}
|
||||
],
|
||||
]
|
||||
state = {"index": 0, "selected": [], "deleted": 0}
|
||||
|
||||
def fake_get_listing_status_country_sections(shop_name, country):
|
||||
current = sections[state["index"]]
|
||||
state["index"] += 1
|
||||
return current
|
||||
|
||||
def fake_select_listing_status(status):
|
||||
state["selected"].append(status)
|
||||
|
||||
def fake_get_first_inventory_row(status):
|
||||
return {"sku": f"sku-{state['deleted'] + 1}"}
|
||||
|
||||
def fake_summarize_inventory_row(row):
|
||||
return {"sku": row["sku"], "rawText": row["sku"]}
|
||||
|
||||
def fake_delete_inventory_row(row, status):
|
||||
state["deleted"] += 1
|
||||
return f"{status} 删除成功"
|
||||
|
||||
monkeypatch.setattr(driver, "get_listing_status_country_sections", fake_get_listing_status_country_sections)
|
||||
monkeypatch.setattr(driver, "select_listing_status", fake_select_listing_status)
|
||||
monkeypatch.setattr(driver, "_get_first_inventory_row", fake_get_first_inventory_row)
|
||||
monkeypatch.setattr(driver, "_summarize_inventory_row", fake_summarize_inventory_row)
|
||||
monkeypatch.setattr(driver, "_delete_inventory_row", fake_delete_inventory_row)
|
||||
monkeypatch.setattr(driver, "_wait_inventory_loader", lambda timeout=5: None)
|
||||
|
||||
result = driver.delete_all_listings_from_non_whitelisted_statuses("郭亚芳", "德国")
|
||||
|
||||
assert state["selected"] == ["商品信息草稿", "商品信息草稿"]
|
||||
assert state["deleted"] == 2
|
||||
assert result["deleteCounts"] == {"商品信息草稿": 2}
|
||||
assert result["totalDeleted"] == 2
|
||||
assert result["stopReason"] == "no-candidates"
|
||||
assert result["statusRows"] == [{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""}]
|
||||
assert [item["target"]["sku"] for item in result["results"]] == ["sku-1", "sku-2"]
|
||||
|
||||
|
||||
def test_extract_country_cart_ratio_prefers_ratio2daysago_only():
|
||||
ratio = ProductTask._extract_country_cart_ratio(
|
||||
[
|
||||
{"country": "德国", "ratio2DaysAgo": "25%", "ratio30DaysAgo": "19%"},
|
||||
{"country": "英国", "ratio30DaysAgo": "40%"},
|
||||
],
|
||||
"德国",
|
||||
)
|
||||
missing_ratio2 = ProductTask._extract_country_cart_ratio(
|
||||
[{"country": "英国", "ratio30DaysAgo": "40%"}],
|
||||
"英国",
|
||||
)
|
||||
|
||||
assert ratio == {"country": "德国", "ratio": "25%"}
|
||||
assert missing_ratio2 == {"country": "英国", "ratio": ""}
|
||||
|
||||
|
||||
def test_parse_status_option_rows_keeps_value_and_raw_text():
|
||||
rows = InventoryManage._parse_status_option_rows(
|
||||
[
|
||||
{"raw_text": "在售 (1,234)", "value": "Active"},
|
||||
{"raw_text": "禁止显示", "value": "SearchSuppressed"},
|
||||
],
|
||||
"郭亚芳",
|
||||
"德国",
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{
|
||||
"shop_name": "郭亚芳",
|
||||
"country": "德国",
|
||||
"status": "在售",
|
||||
"quantity": 1234,
|
||||
"value": "Active",
|
||||
"raw_text": "在售 (1,234)",
|
||||
},
|
||||
{
|
||||
"shop_name": "郭亚芳",
|
||||
"country": "德国",
|
||||
"status": "禁止显示",
|
||||
"quantity": None,
|
||||
"value": "SearchSuppressed",
|
||||
"raw_text": "禁止显示",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_status_text_accepts_chinese_parentheses():
|
||||
assert InventoryManage._parse_status_text("不可售(42)") == ("不可售", 42)
|
||||
|
||||
|
||||
def test_parse_status_text_preserves_status_without_quantity():
|
||||
assert InventoryManage._parse_status_text("非在售") == ("非在售", None)
|
||||
|
||||
|
||||
def test_parse_listing_status_section_rows_extracts_rendered_status_tabs():
|
||||
rows = InventoryManage._parse_listing_status_section_rows(
|
||||
[
|
||||
{
|
||||
"raw_text": (
|
||||
"全部(6182) 在售(4097) 在搜索结果中禁止显示(2) 不可售(2083) "
|
||||
"详情页面已删除(2) 定价问题(1) 需要批准(2080) 商品信息草稿 缺少的信息(1)"
|
||||
)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{"status": "全部", "quantity": "6182", "raw_text": "全部(6182)"},
|
||||
{"status": "在售", "quantity": "4097", "raw_text": "在售(4097)"},
|
||||
{"status": "在搜索结果中禁止显示", "quantity": "2", "raw_text": "在搜索结果中禁止显示(2)"},
|
||||
{"status": "不可售", "quantity": "2083", "raw_text": "不可售(2083)"},
|
||||
{"status": "详情页面已删除", "quantity": "2", "raw_text": "详情页面已删除(2)"},
|
||||
{"status": "定价问题", "quantity": "1", "raw_text": "定价问题(1)"},
|
||||
{"status": "需要批准", "quantity": "2080", "raw_text": "需要批准(2080)"},
|
||||
{"status": "商品信息草稿", "quantity": "", "raw_text": "商品信息草稿"},
|
||||
{"status": "缺少的信息", "quantity": "1", "raw_text": "缺少的信息(1)"},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_listing_status_section_rows_supports_order_page_deleted_alias():
|
||||
rows = InventoryManage._parse_listing_status_section_rows(
|
||||
[
|
||||
{
|
||||
"raw_text": "不可售(2083) 订单页面已删除(2) 定价问题(1) 需要批准(2080)"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{"status": "不可售", "quantity": "2083", "raw_text": "不可售(2083)"},
|
||||
{"status": "订单页面已删除", "quantity": "2", "raw_text": "订单页面已删除(2)"},
|
||||
{"status": "定价问题", "quantity": "1", "raw_text": "定价问题(1)"},
|
||||
{"status": "需要批准", "quantity": "2080", "raw_text": "需要批准(2080)"},
|
||||
]
|
||||
|
||||
|
||||
def test_build_country_section_rows_adds_required_placeholder_fields():
|
||||
rows = InventoryManage._build_country_section_rows(
|
||||
[
|
||||
{"status": "正常", "quantity": "12"},
|
||||
{"status": "需要批准", "quantity": "2080"},
|
||||
]
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{"status": "正常", "quantity": "12", "deleteQuantity": "", "processStatus": ""},
|
||||
{"status": "需要批准", "quantity": "2080", "deleteQuantity": "", "processStatus": ""},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_listing_status_section_rows_supports_latest_status_labels():
|
||||
rows = InventoryManage._parse_listing_status_section_rows(
|
||||
[
|
||||
{
|
||||
"raw_text": (
|
||||
"全部(10) 在售(4) 配送问题(1) 缺少报价(2) 已停售(3) "
|
||||
"在搜索结果中禁止显示(0) 需要批准(0)"
|
||||
)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{"status": "全部", "quantity": "10", "raw_text": "全部(10)"},
|
||||
{"status": "在售", "quantity": "4", "raw_text": "在售(4)"},
|
||||
{"status": "配送问题", "quantity": "1", "raw_text": "配送问题(1)"},
|
||||
{"status": "缺少报价", "quantity": "2", "raw_text": "缺少报价(2)"},
|
||||
{"status": "已停售", "quantity": "3", "raw_text": "已停售(3)"},
|
||||
{"status": "在搜索结果中禁止显示", "quantity": "0", "raw_text": "在搜索结果中禁止显示(0)"},
|
||||
{"status": "需要批准", "quantity": "0", "raw_text": "需要批准(0)"},
|
||||
]
|
||||
|
||||
|
||||
def test_build_deletable_status_rows_skips_whitelisted_statuses():
|
||||
rows = [
|
||||
{"status": "全部", "quantity": "12"},
|
||||
{"status": "定价问题", "quantity": "2"},
|
||||
{"status": "需要批准", "quantity": "3"},
|
||||
{"status": "在搜索结果中禁止显示", "quantity": "1"},
|
||||
{"status": "配送问题", "quantity": "5"},
|
||||
{"status": "缺少报价", "quantity": "4"},
|
||||
{"status": "已停售", "quantity": "6"},
|
||||
{"status": "不可售", "quantity": "8"},
|
||||
{"status": "在售", "quantity": "7"},
|
||||
]
|
||||
|
||||
assert InventoryManage._build_deletable_status_rows(rows) == []
|
||||
|
||||
|
||||
def test_build_deletable_status_rows_keeps_non_whitelisted_statuses():
|
||||
rows = [
|
||||
{"status": "商品信息草稿", "quantity": "2"},
|
||||
{"status": "自定义异常状态", "quantity": "5"},
|
||||
{"status": "全部", "quantity": "9"},
|
||||
]
|
||||
|
||||
assert InventoryManage._build_deletable_status_rows(rows) == [
|
||||
{"status": "商品信息草稿", "canonicalStatus": "商品信息草稿", "quantity": 2},
|
||||
{"status": "自定义异常状态", "canonicalStatus": "自定义异常状态", "quantity": 5},
|
||||
]
|
||||
|
||||
|
||||
def test_build_deletable_status_rows_compatibility_map_prevents_whitelist_misdelete():
|
||||
rows = [
|
||||
{"status": "搜尋結果中禁止顯示", "quantity": "4"},
|
||||
{"status": "在搜索中禁止显示结果", "quantity": "1"},
|
||||
{"status": "配送問題", "quantity": "2"},
|
||||
{"status": "缺少報價", "quantity": "3"},
|
||||
]
|
||||
|
||||
assert InventoryManage._build_deletable_status_rows(rows) == []
|
||||
|
||||
|
||||
def test_build_deletable_status_rows_keeps_legacy_non_whitelist_statuses_deletable():
|
||||
rows = [
|
||||
{"status": "详情页面已删除", "quantity": "1"},
|
||||
{"status": "缺少的信息", "quantity": "2"},
|
||||
{"status": "订单页面已删除", "quantity": "3"},
|
||||
]
|
||||
|
||||
assert InventoryManage._build_deletable_status_rows(rows) == [
|
||||
{"status": "详情页面已删除", "canonicalStatus": "详情页面已删除", "quantity": 1},
|
||||
{"status": "缺少的信息", "canonicalStatus": "缺少的信息", "quantity": 2},
|
||||
{"status": "订单页面已删除", "canonicalStatus": "详情页面已删除", "quantity": 3},
|
||||
]
|
||||
|
||||
|
||||
def test_limit_delete_candidates_enforces_single_delete_cap():
|
||||
candidates = [
|
||||
{"status": "商品信息草稿", "canonicalStatus": "商品信息草稿", "quantity": 2},
|
||||
{"status": "自定义异常状态", "canonicalStatus": "自定义异常状态", "quantity": 5},
|
||||
]
|
||||
|
||||
assert InventoryManage._limit_delete_candidates(candidates, max_delete_count=1) == [candidates[0]]
|
||||
|
||||
|
||||
def test_is_delete_success_message_accepts_both_copy_variants():
|
||||
assert InventoryManage._is_delete_success_message("1 个商品已经删除。所作更改需要15分钟才会显示在商品详情页面上")
|
||||
assert InventoryManage._is_delete_success_message("所做更改需要 15 分钟才会显示在商品详情页面上。 1 个商品已删除。")
|
||||
|
||||
|
||||
def test_parse_label_quantity_text_accepts_complete_draft_sample():
|
||||
assert InventoryManage._parse_label_quantity_text("未提交的草稿 (0)") == ("未提交的草稿", 0)
|
||||
|
||||
|
||||
def test_parse_label_quantity_text_preserves_long_colon_label():
|
||||
assert InventoryManage._parse_label_quantity_text("已提交:提供缺少的信息 (1)") == (
|
||||
"已提交:提供缺少的信息",
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_label_quantity_text_accepts_chinese_parentheses():
|
||||
assert InventoryManage._parse_label_quantity_text("已提交:查看限制(0)") == ("已提交:查看限制", 0)
|
||||
|
||||
|
||||
def test_parse_label_quantity_text_accepts_dynamic_text_and_thousands():
|
||||
assert InventoryManage._parse_label_quantity_text("Some dynamic label (1,234)") == (
|
||||
"Some dynamic label",
|
||||
1234,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_label_quantity_text_rejects_empty_missing_quantity_and_numeric_label():
|
||||
assert InventoryManage._parse_label_quantity_text("") is None
|
||||
assert InventoryManage._parse_label_quantity_text("没有数量") is None
|
||||
assert InventoryManage._parse_label_quantity_text("123 (4)") is None
|
||||
assert InventoryManage._parse_label_quantity_text("未提交的草稿 (0) 已提交:提供缺少的信息 (1)") is None
|
||||
|
||||
|
||||
def test_parse_quick_view_tag_rows_supports_raw_and_split_dom_rows():
|
||||
rows = InventoryManage._parse_quick_view_tag_rows(
|
||||
[
|
||||
{"raw_text": "未提交的草稿 (0)"},
|
||||
{"raw_text": "未提交的草稿 (0)"},
|
||||
{"label_text": "已提交:提供缺少的信息", "quantity_text": "1"},
|
||||
{"raw_text": "没有数量"},
|
||||
],
|
||||
"郭亚芳",
|
||||
"德国",
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{
|
||||
"shop_name": "郭亚芳",
|
||||
"country": "德国",
|
||||
"tag": "未提交的草稿",
|
||||
"quantity": 0,
|
||||
"raw_text": "未提交的草稿 (0)",
|
||||
},
|
||||
{
|
||||
"shop_name": "郭亚芳",
|
||||
"country": "德国",
|
||||
"tag": "已提交:提供缺少的信息",
|
||||
"quantity": 1,
|
||||
"raw_text": "已提交:提供缺少的信息 (1)",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_quick_view_tag_rows_filters_navigation_and_pagination_noise():
|
||||
rows = InventoryManage._parse_quick_view_tag_rows(
|
||||
[
|
||||
{"raw_text": "补全草稿 (1)"},
|
||||
{"raw_text": "未提交的草稿 (0)"},
|
||||
{"raw_text": "已提交:提供缺少的信息 (1)"},
|
||||
{"raw_text": "已提交:查看限制 (0)"},
|
||||
{"raw_text": "商品信息草稿 至 0 / 0 (0)"},
|
||||
{"raw_text": "页面 / 0 (0)"},
|
||||
],
|
||||
"郭亚芳",
|
||||
"德国",
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{
|
||||
"shop_name": "郭亚芳",
|
||||
"country": "德国",
|
||||
"tag": "未提交的草稿",
|
||||
"quantity": 0,
|
||||
"raw_text": "未提交的草稿 (0)",
|
||||
},
|
||||
{
|
||||
"shop_name": "郭亚芳",
|
||||
"country": "德国",
|
||||
"tag": "已提交:提供缺少的信息",
|
||||
"quantity": 1,
|
||||
"raw_text": "已提交:提供缺少的信息 (1)",
|
||||
},
|
||||
{
|
||||
"shop_name": "郭亚芳",
|
||||
"country": "德国",
|
||||
"tag": "已提交:查看限制",
|
||||
"quantity": 0,
|
||||
"raw_text": "已提交:查看限制 (0)",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_cart_ratio_rows_extracts_chinese_day_ratios():
|
||||
rows = InventoryManage._parse_cart_ratio_rows(
|
||||
[{"raw_text": "推荐报价百分比 2 天前 25% 30 天前 30%"}],
|
||||
"德国",
|
||||
)
|
||||
|
||||
assert rows == {"country": "德国", "ratio2DaysAgo": "25%", "ratio30DaysAgo": "30%"}
|
||||
|
||||
|
||||
def test_parse_cart_ratio_rows_extracts_english_day_ratios():
|
||||
rows = InventoryManage._parse_cart_ratio_rows(
|
||||
[{"raw_text": "Featured Offer Percentage 2 days ago 25.5% 30 days ago 30%"}],
|
||||
"英国",
|
||||
)
|
||||
|
||||
assert rows == {"country": "英国", "ratio2DaysAgo": "25.5%", "ratio30DaysAgo": "30%"}
|
||||
|
||||
|
||||
def test_parse_cart_ratio_rows_supports_percent_before_day_label():
|
||||
rows = InventoryManage._parse_cart_ratio_rows(
|
||||
[{"raw_text": "25% 2 天前 30% 30 天前"}],
|
||||
"法国",
|
||||
)
|
||||
|
||||
assert rows == {"country": "法国", "ratio2DaysAgo": "25%", "ratio30DaysAgo": "30%"}
|
||||
|
||||
|
||||
def test_parse_cart_ratio_rows_returns_empty_for_missing_day():
|
||||
rows = InventoryManage._parse_cart_ratio_rows(
|
||||
[{"raw_text": "推荐报价百分比 2 天前 25%"}],
|
||||
"意大利",
|
||||
)
|
||||
|
||||
assert rows == {"country": "意大利", "ratio2DaysAgo": "25%", "ratio30DaysAgo": ""}
|
||||
|
||||
|
||||
def test_parse_all_cart_ratio_rows_extracts_all_target_countries_without_switching():
|
||||
rows = InventoryManage._parse_all_cart_ratio_rows(
|
||||
[
|
||||
{
|
||||
"raw_text": (
|
||||
"推荐报价百分比 商城 2 天前 30 天前 "
|
||||
"欧洲 60% 64% "
|
||||
"英国 50% 61% "
|
||||
"德国 57% 65% "
|
||||
"法国 81% 77% "
|
||||
"西班牙 70% 70% "
|
||||
"意大利 70% 62%"
|
||||
)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{"country": "欧洲", "ratio2DaysAgo": "60%", "ratio30DaysAgo": "64%"},
|
||||
{"country": "英国", "ratio2DaysAgo": "50%", "ratio30DaysAgo": "61%"},
|
||||
{"country": "德国", "ratio2DaysAgo": "57%", "ratio30DaysAgo": "65%"},
|
||||
{"country": "法国", "ratio2DaysAgo": "81%", "ratio30DaysAgo": "77%"},
|
||||
{"country": "西班牙", "ratio2DaysAgo": "70%", "ratio30DaysAgo": "70%"},
|
||||
{"country": "意大利", "ratio2DaysAgo": "70%", "ratio30DaysAgo": "62%"},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_all_cart_ratio_rows_prefers_direct_rendered_country_rows():
|
||||
rows = InventoryManage._parse_all_cart_ratio_rows(
|
||||
[
|
||||
{"country": "欧洲", "ratio2DaysAgo": "60 %", "ratio30DaysAgo": "64%", "source": "rendered-country-row"},
|
||||
{"country": "英国", "ratio2DaysAgo": "50%", "ratio30DaysAgo": "61%", "source": "rendered-country-row"},
|
||||
{"country": "德国", "ratio2DaysAgo": "57%", "ratio30DaysAgo": "65%", "source": "rendered-country-row"},
|
||||
]
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{"country": "欧洲", "ratio2DaysAgo": "60%", "ratio30DaysAgo": "64%"},
|
||||
{"country": "英国", "ratio2DaysAgo": "50%", "ratio30DaysAgo": "61%"},
|
||||
{"country": "德国", "ratio2DaysAgo": "57%", "ratio30DaysAgo": "65%"},
|
||||
{"country": "法国", "ratio2DaysAgo": "", "ratio30DaysAgo": ""},
|
||||
{"country": "西班牙", "ratio2DaysAgo": "", "ratio30DaysAgo": ""},
|
||||
{"country": "意大利", "ratio2DaysAgo": "", "ratio30DaysAgo": ""},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_all_cart_ratio_rows_supports_date_headers_without_day_labels():
|
||||
rows = InventoryManage._parse_all_cart_ratio_rows(
|
||||
[
|
||||
{
|
||||
"raw_text": (
|
||||
"推荐报价百分比 商城 24 Apr 27 Mar "
|
||||
"欧洲 60% 64% "
|
||||
"英国 50% 61% "
|
||||
"德国 57% 65% "
|
||||
"法国 81% 77% "
|
||||
"西班牙 70% 70% "
|
||||
"意大利 70% 62%"
|
||||
)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{"country": "欧洲", "ratio2DaysAgo": "60%", "ratio30DaysAgo": "64%"},
|
||||
{"country": "英国", "ratio2DaysAgo": "50%", "ratio30DaysAgo": "61%"},
|
||||
{"country": "德国", "ratio2DaysAgo": "57%", "ratio30DaysAgo": "65%"},
|
||||
{"country": "法国", "ratio2DaysAgo": "81%", "ratio30DaysAgo": "77%"},
|
||||
{"country": "西班牙", "ratio2DaysAgo": "70%", "ratio30DaysAgo": "70%"},
|
||||
{"country": "意大利", "ratio2DaysAgo": "70%", "ratio30DaysAgo": "62%"},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_all_cart_ratio_rows_supports_rendered_geometry_rows():
|
||||
rows = InventoryManage._parse_all_cart_ratio_rows(
|
||||
[
|
||||
{"attrs": {"title": "欧洲"}, "rect_left": 1315.0, "rect_top": 346.5, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "60%", "rect_left": 1510.0, "rect_top": 357.6, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"label_text": "64%", "rect_left": 1693.3, "rect_top": 357.6, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"attrs": {"title": "英国"}, "rect_left": 1315.0, "rect_top": 390.6, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "50%", "rect_left": 1510.1, "rect_top": 401.7, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"label_text": "61%", "rect_left": 1693.3, "rect_top": 401.6, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"attrs": {"title": "德国"}, "rect_left": 1315.0, "rect_top": 433.8, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "57%", "rect_left": 1510.1, "rect_top": 444.9, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"label_text": "65%", "rect_left": 1693.3, "rect_top": 444.9, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"attrs": {"title": "法国"}, "rect_left": 1315.0, "rect_top": 477.0, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "81%", "rect_left": 1510.1, "rect_top": 488.1, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"label_text": "77%", "rect_left": 1693.3, "rect_top": 488.1, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"attrs": {"title": "西班牙"}, "rect_left": 1315.0, "rect_top": 520.2, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "70%", "rect_left": 1510.1, "rect_top": 531.3, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"label_text": "70%", "rect_left": 1693.3, "rect_top": 531.3, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"attrs": {"title": "意大利"}, "rect_left": 1315.0, "rect_top": 563.4, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "70%", "rect_left": 1510.1, "rect_top": 574.5, "rect_width": 32.0, "rect_height": 20.0},
|
||||
{"label_text": "62%", "rect_left": 1693.3, "rect_top": 574.5, "rect_width": 32.0, "rect_height": 20.0},
|
||||
]
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{"country": "欧洲", "ratio2DaysAgo": "60%", "ratio30DaysAgo": "64%"},
|
||||
{"country": "英国", "ratio2DaysAgo": "50%", "ratio30DaysAgo": "61%"},
|
||||
{"country": "德国", "ratio2DaysAgo": "57%", "ratio30DaysAgo": "65%"},
|
||||
{"country": "法国", "ratio2DaysAgo": "81%", "ratio30DaysAgo": "77%"},
|
||||
{"country": "西班牙", "ratio2DaysAgo": "70%", "ratio30DaysAgo": "70%"},
|
||||
{"country": "意大利", "ratio2DaysAgo": "70%", "ratio30DaysAgo": "62%"},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_all_cart_ratio_rows_dedupes_nested_rendered_percentage_nodes():
|
||||
rows = InventoryManage._parse_all_cart_ratio_rows(
|
||||
[
|
||||
{"attrs": {"title": "欧洲"}, "rect_left": 1315.0, "rect_top": 346.5, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "60%", "rect_left": 1510.1, "rect_top": 357.6, "rect_width": 160.0, "rect_height": 20.0},
|
||||
{"label_text": "60%", "rect_left": 1510.1, "rect_top": 357.6, "rect_width": 29.6, "rect_height": 20.0},
|
||||
{"raw_text": "64%", "rect_left": 1681.7, "rect_top": 346.5, "rect_width": 183.2, "rect_height": 43.2},
|
||||
{"label_text": "64%", "rect_left": 1693.3, "rect_top": 357.6, "rect_width": 160.0, "rect_height": 20.0},
|
||||
{"attrs": {"title": "英国"}, "rect_left": 1315.0, "rect_top": 390.6, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "50%", "rect_left": 1510.1, "rect_top": 401.7, "rect_width": 160.0, "rect_height": 18.2},
|
||||
{"label_text": "50%", "rect_left": 1510.1, "rect_top": 401.7, "rect_width": 27.6, "rect_height": 18.2},
|
||||
{"label_text": "61%", "rect_left": 1693.3, "rect_top": 401.6, "rect_width": 27.6, "rect_height": 18.2},
|
||||
{"attrs": {"title": "德国"}, "rect_left": 1315.0, "rect_top": 433.8, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "57%", "rect_left": 1510.1, "rect_top": 444.9, "rect_width": 160.0, "rect_height": 18.2},
|
||||
{"label_text": "57%", "rect_left": 1510.1, "rect_top": 444.9, "rect_width": 27.6, "rect_height": 18.2},
|
||||
{"label_text": "65%", "rect_left": 1693.3, "rect_top": 444.9, "rect_width": 27.6, "rect_height": 18.2},
|
||||
{"attrs": {"title": "法国"}, "rect_left": 1315.0, "rect_top": 477.0, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "81%", "rect_left": 1510.1, "rect_top": 488.1, "rect_width": 160.0, "rect_height": 18.2},
|
||||
{"label_text": "81%", "rect_left": 1510.1, "rect_top": 488.1, "rect_width": 27.6, "rect_height": 18.2},
|
||||
{"label_text": "77%", "rect_left": 1693.3, "rect_top": 488.1, "rect_width": 27.6, "rect_height": 18.2},
|
||||
{"attrs": {"title": "西班牙"}, "rect_left": 1315.0, "rect_top": 520.2, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "70%", "rect_left": 1510.1, "rect_top": 531.3, "rect_width": 160.0, "rect_height": 18.2},
|
||||
{"label_text": "70%", "rect_left": 1510.1, "rect_top": 531.3, "rect_width": 27.6, "rect_height": 18.2},
|
||||
{"label_text": "70%", "rect_left": 1693.3, "rect_top": 531.3, "rect_width": 27.6, "rect_height": 18.2},
|
||||
{"attrs": {"title": "意大利"}, "rect_left": 1315.0, "rect_top": 563.4, "rect_width": 550.0, "rect_height": 43.0},
|
||||
{"raw_text": "70%", "rect_left": 1510.1, "rect_top": 574.5, "rect_width": 160.0, "rect_height": 18.2},
|
||||
{"label_text": "70%", "rect_left": 1510.1, "rect_top": 574.5, "rect_width": 27.6, "rect_height": 18.2},
|
||||
{"raw_text": "62%", "rect_left": 1681.7, "rect_top": 563.4, "rect_width": 183.2, "rect_height": 43.2},
|
||||
{"label_text": "62%", "rect_left": 1693.3, "rect_top": 574.5, "rect_width": 27.6, "rect_height": 18.2},
|
||||
]
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{"country": "欧洲", "ratio2DaysAgo": "60%", "ratio30DaysAgo": "64%"},
|
||||
{"country": "英国", "ratio2DaysAgo": "50%", "ratio30DaysAgo": "61%"},
|
||||
{"country": "德国", "ratio2DaysAgo": "57%", "ratio30DaysAgo": "65%"},
|
||||
{"country": "法国", "ratio2DaysAgo": "81%", "ratio30DaysAgo": "77%"},
|
||||
{"country": "西班牙", "ratio2DaysAgo": "70%", "ratio30DaysAgo": "70%"},
|
||||
{"country": "意大利", "ratio2DaysAgo": "70%", "ratio30DaysAgo": "62%"},
|
||||
]
|
||||
|
||||
|
||||
def test_has_any_cart_ratio_detects_country_table_rows():
|
||||
assert InventoryManage._has_any_cart_ratio(
|
||||
[
|
||||
{
|
||||
"raw_text": (
|
||||
"推荐报价百分比 商城 2 天前 30 天前 "
|
||||
"欧洲 60% 64% 英国 50% 61% 德国 57% 65%"
|
||||
)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_has_any_cart_ratio_detects_date_header_country_table_rows():
|
||||
assert InventoryManage._has_any_cart_ratio(
|
||||
[
|
||||
{
|
||||
"raw_text": (
|
||||
"推荐报价百分比 商城 24 Apr 27 Mar "
|
||||
"欧洲 60% 64% 英国 50% 61% 德国 57% 65%"
|
||||
)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_has_complete_cart_ratio_rows_requires_all_target_countries():
|
||||
assert not InventoryManage._has_complete_cart_ratio_rows(
|
||||
[
|
||||
{
|
||||
"raw_text": (
|
||||
"推荐报价百分比 商城 2 天前 30 天前 "
|
||||
"欧洲 60% 64% 英国 50% 61% 德国 57% 65%"
|
||||
)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_has_complete_cart_ratio_rows_detects_full_country_table():
|
||||
assert InventoryManage._has_complete_cart_ratio_rows(
|
||||
[
|
||||
{
|
||||
"raw_text": (
|
||||
"推荐报价百分比 商城 2 天前 30 天前 "
|
||||
"欧洲 60% 64% "
|
||||
"英国 50% 61% "
|
||||
"德国 57% 65% "
|
||||
"法国 81% 77% "
|
||||
"西班牙 70% 70% "
|
||||
"意大利 70% 62%"
|
||||
)
|
||||
}
|
||||
]
|
||||
)
|
||||
Binary file not shown.
814
app/uv.lock
generated
814
app/uv.lock
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user