Fix patrol delete condition handling

This commit is contained in:
koko
2026-05-01 22:36:37 +08:00
parent 28a752265c
commit 74a7cd22b0
14 changed files with 769 additions and 26 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ backend-java/.idea/
backend-java/*.iml
# ===== backend / app =====
__pycache__/
backend/tmp/
backend/__pycache__
app/__pycache__

View File

@@ -1,5 +1,6 @@
import json
import os
import re
import subprocess
import time
import uuid
@@ -20,7 +21,7 @@ except ImportError:
STATUS_OK = "0"
STATUS_LOGIN_FAILED = "-10003"
DEFAULT_SOCKET_PORT = 19890
DEFAULT_SOCKET_PORT = 20000
CLIENT_API_TIMEOUT = 120
PORT_CHECK_TIMEOUT = 2
UPDATE_CORE_RETRY_DELAY = 2
@@ -30,6 +31,7 @@ CLIENT_READY_TIMEOUT = 10
CLIENT_READY_INTERVAL = 0.5
CLIENT_POST_START_DELAY = 5
PROCESS_KILL_DELAY = 3
ZINIAO_WEBDRIVER_LOG_TAIL_LINES = 80
DOC_LOAD_TIMEOUT = 30
COUNTRY_INITIAL_LOAD_TIMEOUT = 120
@@ -152,6 +154,49 @@ class ZiniaoDriver:
time.sleep(CLIENT_READY_INTERVAL)
return False
@staticmethod
def _redact_ziniao_log_line(line: str) -> str:
return re.sub(r'("password"\s*:\s*)"[^"]*"', r'\1"***"', line)
@staticmethod
def _ziniao_webdriver_log_path() -> Optional[str]:
appdata = os.getenv("APPDATA")
if not appdata:
return None
filename = f"webdriver.{time.strftime('%Y%m%d')}.log"
return os.path.join(
appdata,
"ziniaobrowser",
"instances",
"userdata1",
"logs",
"client",
filename,
)
def _log_ziniao_webdriver_tail(self) -> None:
log_path = self._ziniao_webdriver_log_path()
if not log_path:
logger.warning("无法读取紫鸟 webdriver 日志: APPDATA 环境变量为空")
return
if not os.path.exists(log_path):
logger.warning("紫鸟 webdriver 日志不存在:{}", log_path)
return
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as log_file:
lines = log_file.readlines()
except OSError as exc:
logger.warning("读取紫鸟 webdriver 日志失败:{} | {}", log_path, exc)
return
tail_lines = lines[-ZINIAO_WEBDRIVER_LOG_TAIL_LINES:]
tail_text = "".join(self._redact_ziniao_log_line(line) for line in tail_lines).strip()
if tail_text:
logger.error("紫鸟 webdriver 日志尾部({}):\n{}", log_path, tail_text)
else:
logger.warning("紫鸟 webdriver 日志为空:{}", log_path)
def get_zinaio_exe(self, protocol_name: str = "superbrowser"):
"""从 Windows 注册表读取紫鸟客户端可执行文件路径.
@@ -402,7 +447,7 @@ class ZiniaoDriver:
for retry_count in range(CLIENT_START_RETRIES):
logger.info("{}/{} 次尝试启动紫鸟客户端", retry_count + 1, CLIENT_START_RETRIES)
subprocess.Popen(cmd)
process = subprocess.Popen(cmd)
if self._wait_until_client_ready(CLIENT_READY_TIMEOUT):
logger.info("紫鸟客户端启动成功,第 {} 次尝试", retry_count + 1)
@@ -411,6 +456,9 @@ class ZiniaoDriver:
return
logger.warning("{} 次尝试启动失败,10秒内未检测到客户端启动", retry_count + 1)
if process.poll() is not None:
logger.warning("紫鸟客户端进程已退出,returncode={}", process.returncode)
self._log_ziniao_webdriver_tail()
logger.error("紫鸟客户端启动失败,已重试 {}", CLIENT_START_RETRIES)
raise RuntimeError(f"客户端启动失败:重试 {CLIENT_START_RETRIES} 次后仍未成功启动")

View File

@@ -4,7 +4,7 @@ import time
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from typing import Any, Dict, Iterable, List, Optional, Set
import requests
from loguru import logger
@@ -38,6 +38,19 @@ def _load_patrol_delete_script(name: str) -> str:
return (SCRIPT_DIR / name).read_text(encoding="utf-8")
def _new_trace_id(prefix: str = "trace") -> str:
return f"{prefix}-{int(time.time() * 1000)}"
def _safe_log_text(value: Any, limit: int = 2000) -> str:
try:
text = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value)
except Exception:
text = repr(value)
text = re.sub(r"\s+", " ", text).strip()
return text if len(text) <= limit else text[: limit - 3] + "..."
LISTING_STATUS_DROPDOWN_OPEN_SCRIPT = _load_patrol_delete_script("listing_status_dropdown_open.js")
LISTING_STATUS_DROPDOWN_OPTIONS_SCRIPT = _load_patrol_delete_script("listing_status_dropdown_options.js")
LISTING_STATUS_DROPDOWN_CLOSE_SCRIPT = _load_patrol_delete_script("listing_status_dropdown_close.js")
@@ -49,6 +62,9 @@ DELETE_CONFIRM_MODAL_CLICK_SCRIPT = _load_patrol_delete_script("delete_confirm_m
DELETE_SUCCESS_MESSAGES_SCRIPT = _load_patrol_delete_script("delete_success_messages.js")
COMPLETE_DRAFT_QUICK_VIEW_TAGS_SCRIPT = _load_patrol_delete_script("complete_draft_quick_view_tags.js")
COMPLETE_DRAFT_QUICK_VIEW_DIAGNOSTIC_SCRIPT = _load_patrol_delete_script("complete_draft_quick_view_diagnostic.js")
COMPLETE_DRAFT_SELECT_TAG_SCRIPT = _load_patrol_delete_script("complete_draft_select_tag.js")
COMPLETE_DRAFT_SELECT_ALL_SCRIPT = _load_patrol_delete_script("complete_draft_select_all.js")
COMPLETE_DRAFT_DELETE_SELECTED_SCRIPT = _load_patrol_delete_script("complete_draft_delete_selected.js")
RECOMMENDED_OFFER_PERCENTAGE_OPEN_SCRIPT = _load_patrol_delete_script("recommended_offer_percentage_open.js")
RECOMMENDED_OFFER_PERCENTAGE_ROWS_SCRIPT = _load_patrol_delete_script("recommended_offer_percentage_rows.js")
RECOMMENDED_OFFER_PERCENTAGE_DIAGNOSTIC_SCRIPT = _load_patrol_delete_script("recommended_offer_percentage_diagnostic.js")
@@ -65,6 +81,10 @@ DELETE_SUCCESS_MESSAGE_PARTS = (
re.compile(r"\d+\s*个商品(?:已删除|已经删除)"),
re.compile(r"所[作做]更改需要\s*15\s*分钟才会显示在商品详情页面上"),
)
COMPLETE_DRAFT_DELETE_SUCCESS_PATTERNS = (
re.compile(r"商品信息草稿(?:已删除|已经删除)"),
re.compile(r"已删除\s*\d*\s*个?\s*商品信息草稿"),
)
LISTING_STATUS_DELETE_WHITELIST = {
"全部",
"定价问题",
@@ -153,6 +173,229 @@ class InventoryManage(AmamzonBase):
raise RuntimeError(f"补全草稿快速查看已打开但未读取到标签: {open_result}")
return parsed_rows
def delete_complete_drafts_from_non_whitelisted_tags(
self,
shop_name: str,
country: str,
max_delete_operations: Optional[int] = None,
trace_id: str = "",
allowed_statuses: Optional[Iterable[str]] = None,
) -> Dict[str, Any]:
"""按补全草稿快速查看的准确标签数量,删除非白名单标签下方商品。"""
trace_id = trace_id or _new_trace_id("draft")
logger.info(
f"trace_id={trace_id} 开始删除非白名单补全草稿: "
f"shop={shop_name}, country={country}, max_delete_operations={max_delete_operations}, "
f"allowed_statuses={list(allowed_statuses) if allowed_statuses is not None else None}"
)
initial_tags = self.get_complete_draft_quick_view_tags(shop_name, country)
delete_candidates = self._build_deletable_complete_draft_tag_rows(initial_tags, allowed_statuses)
results: List[Dict[str, Any]] = []
delete_counts: Dict[str, int] = {}
total_deleted = 0
stop_reason = "no-candidates"
if not delete_candidates:
logger.info(f"trace_id={trace_id} 补全草稿没有可删除标签: shop={shop_name}, country={country}")
return {
"shopName": shop_name,
"country": country,
"draftRows": initial_tags,
"deleteCandidates": delete_candidates,
"results": results,
"deleteCounts": delete_counts,
"totalDeleted": total_deleted,
"stopReason": stop_reason,
}
stop_reason = "processed-candidates"
for candidate in delete_candidates:
tag = candidate["tag"]
quantity = candidate["quantity"]
canonical_tag = candidate["canonicalTag"]
logger.info(
f"trace_id={trace_id} 开始删除补全草稿标签下方商品: "
f"shop={shop_name}, country={country}, tag={tag}, quantity={quantity}"
)
self.select_complete_draft_tag(tag, trace_id=trace_id)
deleted_for_tag = 0
while quantity <= 0 or deleted_for_tag < quantity:
remaining_limit = None
if max_delete_operations is not None:
remaining_limit = max(0, max_delete_operations - total_deleted)
if remaining_limit <= 0:
stop_reason = "max-delete-operations"
break
select_result = self._select_all_complete_draft_rows(
tag,
trace_id=trace_id,
max_select_count=remaining_limit,
)
selected_count = int(select_result.get("selectedCount") or select_result.get("rowCount") or 0)
if select_result.get("empty"):
logger.info(
f"trace_id={trace_id} 补全草稿标签下方已无商品,跳过删除: "
f"shop={shop_name}, country={country}, tag={tag}"
)
break
if selected_count <= 0:
logger.info(
f"trace_id={trace_id} 补全草稿标签下方没有可删除商品: "
f"shop={shop_name}, country={country}, tag={tag}"
)
break
delete_result = self._click_complete_draft_delete_selected(tag, trace_id=trace_id)
self._confirm_delete_modal(tag, trace_id=trace_id)
self._wait_complete_draft_rows_after_delete(tag, select_result, trace_id=trace_id)
deleted_count = min(selected_count, quantity - deleted_for_tag) if quantity > 0 else selected_count
deleted_for_tag += deleted_count
total_deleted += deleted_count
delete_counts[canonical_tag] = delete_counts.get(canonical_tag, 0) + deleted_count
results.append(
{
"tag": tag,
"canonicalTag": canonical_tag,
"tagQuantity": quantity,
"deletedCount": deleted_count,
"selectResult": select_result,
"deleteResult": delete_result,
}
)
logger.info(
f"trace_id={trace_id} 补全草稿标签删除成功: "
f"shop={shop_name}, country={country}, tag={tag}, "
f"deleted_count={deleted_count}, tag_deleted={deleted_for_tag}, total_deleted={total_deleted}"
)
if max_delete_operations is not None and total_deleted >= max_delete_operations:
stop_reason = "max-delete-operations"
break
if quantity > 0 and deleted_for_tag >= quantity:
break
if stop_reason == "max-delete-operations":
break
final_tags = self.get_complete_draft_quick_view_tags(shop_name, country)
return {
"shopName": shop_name,
"country": country,
"draftRows": final_tags,
"deleteCandidates": delete_candidates,
"results": results,
"deleteCounts": delete_counts,
"totalDeleted": total_deleted,
"stopReason": stop_reason,
}
def select_complete_draft_tag(self, tag: str, trace_id: str = "") -> Dict[str, Any]:
normalized_tag = self._normalize_option_text(tag)
if not normalized_tag:
raise RuntimeError("补全草稿标签为空,无法执行筛选")
trace_id = trace_id or _new_trace_id("draft")
result = self._run_js(COMPLETE_DRAFT_SELECT_TAG_SCRIPT, {"tag": normalized_tag}) or {}
if not result.get("ok"):
logger.info(
f"trace_id={trace_id} 补全草稿快速查看标签未命中: "
f"tag={normalized_tag}, result={_safe_log_text(result)}"
)
raise RuntimeError(f"未找到补全草稿标签[{normalized_tag}]: {result}")
time.sleep(LISTING_STATUS_SELECT_DELAY)
return result
def _select_all_complete_draft_rows(
self,
tag: str,
trace_id: str = "",
max_select_count: Optional[int] = None,
) -> Dict[str, Any]:
trace_id = trace_id or _new_trace_id("draft")
deadline = time.time() + INVENTORY_ROW_TIMEOUT
result: Dict[str, Any] = {}
saw_empty = False
while time.time() < deadline:
payload = {"maxSelectCount": max_select_count} if max_select_count is not None else None
result = self._run_js(COMPLETE_DRAFT_SELECT_ALL_SCRIPT, payload) if payload else self._run_js(COMPLETE_DRAFT_SELECT_ALL_SCRIPT)
result = result or {}
if result.get("ok"):
time.sleep(0.5)
logger.info(f"trace_id={trace_id} 补全草稿已全选: tag={tag}, result={_safe_log_text(result)}")
return result
if result.get("reason") == "no complete draft rows":
saw_empty = True
time.sleep(0.5)
continue
if result.get("reason") and result.get("reason") != "no complete draft rows":
break
time.sleep(0.5)
if not result.get("ok"):
if saw_empty and result.get("reason") == "no complete draft rows":
logger.info(f"trace_id={trace_id} 补全草稿标签下方无商品行: tag={tag}, result={_safe_log_text(result)}")
return {**result, "ok": True, "empty": True, "selectedCount": 0, "rowCount": 0}
logger.info(
f"trace_id={trace_id} 补全草稿全选失败: tag={tag}, result={_safe_log_text(result)}"
)
raise RuntimeError(f"补全草稿标签[{tag}]全选失败: {result}")
return result
def _click_complete_draft_delete_selected(self, tag: str, trace_id: str = "") -> Dict[str, Any]:
trace_id = trace_id or _new_trace_id("draft")
result: Dict[str, Any] = {}
for attempt in range(8):
result = self._run_js(COMPLETE_DRAFT_DELETE_SELECTED_SCRIPT) or {}
if result.get("ok"):
break
if result.get("menuOpened"):
logger.info(
f"trace_id={trace_id} 补全草稿选择组操作菜单已打开,等待删除选项出现: "
f"tag={tag}, attempt={attempt + 1}, result={_safe_log_text(result)}"
)
time.sleep(0.8)
continue
break
if not result.get("ok"):
logger.info(
f"trace_id={trace_id} 补全草稿点击删除失败: tag={tag}, result={_safe_log_text(result)}"
)
raise RuntimeError(f"补全草稿标签[{tag}]点击删除失败: {result}")
time.sleep(0.5)
logger.info(f"trace_id={trace_id} 补全草稿删除按钮点击成功: tag={tag}, result={_safe_log_text(result)}")
return result
def _wait_complete_draft_rows_after_delete(
self,
tag: str,
before_result: Dict[str, Any],
trace_id: str = "",
) -> None:
trace_id = trace_id or _new_trace_id("draft")
before_count = int(before_result.get("rowCount") or 0)
deadline = time.time() + DELETE_SUCCESS_TIMEOUT
last_result: Dict[str, Any] = {}
while time.time() < deadline:
time.sleep(0.5)
result = self._run_js(COMPLETE_DRAFT_SELECT_ALL_SCRIPT, {"probeOnly": True}) or {}
last_result = result
row_count = int(result.get("rowCount") or 0)
if row_count < before_count or row_count == 0:
logger.info(
f"trace_id={trace_id} 补全草稿删除后行数已变化: "
f"tag={tag}, before={before_count}, after={row_count}"
)
return
for text in self._collect_delete_success_messages():
if self._is_complete_draft_delete_success_message(text) or self._is_delete_success_message(text):
logger.info(f"trace_id={trace_id} 补全草稿删除读取到成功提示: tag={tag}, message={_safe_log_text(text)}")
return
logger.info(
f"trace_id={trace_id} 补全草稿删除后未确认行数变化: "
f"tag={tag}, before={before_count}, last_result={_safe_log_text(last_result)}"
)
raise RuntimeError("补全草稿删除后未确认行数变化")
def open_recommended_offer_percentage_detail(self) -> Dict[str, Any]:
"""打开 Seller Central 首页中的推荐报价百分比明细。"""
if self.tab is None:
@@ -206,13 +449,15 @@ class InventoryManage(AmamzonBase):
shop_name: str,
country: str,
max_delete_operations: Optional[int] = None,
allowed_statuses: Optional[Iterable[str]] = None,
) -> Dict[str, Any]:
"""循环删除非白名单分类商品,直到当前国家没有可删项。"""
logger.info(
"开始批量删除非白名单商品: shop={}, country={}, max_delete_operations={}",
"开始批量删除非白名单商品: shop={}, country={}, max_delete_operations={}, allowed_statuses={}",
shop_name,
country,
max_delete_operations,
list(allowed_statuses) if allowed_statuses is not None else None,
)
all_results: List[Dict[str, Any]] = []
delete_counts: Dict[str, int] = {}
@@ -222,7 +467,7 @@ class InventoryManage(AmamzonBase):
stop_reason = "no-candidates"
latest_status_rows = self._read_listing_status_rows_for_delete(shop_name, country)
latest_delete_candidates = self._build_deletable_status_rows(latest_status_rows)
latest_delete_candidates = self._build_deletable_status_rows(latest_status_rows, allowed_statuses)
if not latest_delete_candidates:
logger.info("未找到可删除商品分类: shop={}, country={}", shop_name, country)
final_status_rows = latest_status_rows
@@ -456,7 +701,7 @@ class InventoryManage(AmamzonBase):
text = ""
return {"sku": sku, "rawText": text[:400]}
def _confirm_delete_modal(self, status: str) -> None:
def _confirm_delete_modal(self, status: str, trace_id: str = "") -> None:
js_result = self._click_confirm_delete_modal_with_js()
if js_result.get("ok"):
logger.info("已通过 JS 确认删除弹窗: status={}, result={}", status, js_result)
@@ -544,6 +789,11 @@ class InventoryManage(AmamzonBase):
normalized = re.sub(r"\s+", "", text or "")
return all(pattern.search(normalized) for pattern in DELETE_SUCCESS_MESSAGE_PARTS)
@staticmethod
def _is_complete_draft_delete_success_message(text: str) -> bool:
normalized = re.sub(r"\s+", "", text or "")
return any(pattern.search(normalized) for pattern in COMPLETE_DRAFT_DELETE_SUCCESS_PATTERNS)
@staticmethod
def _extract_delete_success_count(text: str) -> int:
match = re.search(r"(\d+)\s*个商品(?:已删除|已经删除)", text or "")
@@ -733,7 +983,12 @@ class InventoryManage(AmamzonBase):
]
@classmethod
def _build_deletable_status_rows(cls, rows: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
def _build_deletable_status_rows(
cls,
rows: Iterable[Dict[str, Any]],
allowed_statuses: Optional[Iterable[str]] = None,
) -> List[Dict[str, Any]]:
allowed_status_set = cls._normalize_allowed_status_set(allowed_statuses)
candidates = []
for row in rows:
status = cls._normalize_option_text(row.get("status") or "")
@@ -743,6 +998,8 @@ class InventoryManage(AmamzonBase):
quantity_text = cls._normalize_quantity_text(row.get("quantity"))
quantity = int(quantity_text) if quantity_text else 0
canonical_status = cls._canonical_listing_status(status)
if allowed_status_set is not None and canonical_status not in allowed_status_set:
continue
if not quantity_text or quantity <= 0 or canonical_status in LISTING_STATUS_DELETE_WHITELIST:
continue
@@ -755,6 +1012,37 @@ class InventoryManage(AmamzonBase):
)
return candidates
@classmethod
def _build_deletable_complete_draft_tag_rows(
cls,
rows: Iterable[Dict[str, Any]],
allowed_statuses: Optional[Iterable[str]] = None,
) -> List[Dict[str, Any]]:
allowed_status_set = cls._normalize_allowed_status_set(allowed_statuses)
complete_draft_enabled = bool(
allowed_status_set and {"补全草稿", "商品信息草稿"}.intersection(allowed_status_set)
)
candidates = []
for row in rows:
tag = cls._normalize_option_text(row.get("tag") or row.get("status") or "")
if not tag:
continue
quantity_text = cls._normalize_quantity_text(row.get("quantity"))
quantity = int(quantity_text) if quantity_text else 0
canonical_tag = cls._canonical_listing_status(tag)
if allowed_status_set is not None and not complete_draft_enabled and canonical_tag not in allowed_status_set:
continue
if not quantity_text or quantity <= 0 or canonical_tag in LISTING_STATUS_DELETE_WHITELIST:
continue
candidates.append(
{
"tag": tag,
"canonicalTag": canonical_tag,
"quantity": quantity,
}
)
return candidates
@staticmethod
def _format_deletable_candidates(candidates: Iterable[Dict[str, Any]]) -> str:
summary = []
@@ -769,6 +1057,21 @@ class InventoryManage(AmamzonBase):
normalized = cls._normalize_option_text(status)
return LISTING_STATUS_COMPATIBILITY_MAP.get(normalized, normalized)
@classmethod
def _normalize_allowed_status_set(cls, statuses: Optional[Iterable[str]]) -> Optional[Set[str]]:
if statuses is None:
return None
allowed = set()
for status in statuses:
text = cls._normalize_option_text(str(status or ""))
if not text:
continue
for part in re.split(r"[,;;、\r\n]+", text):
normalized = cls._normalize_option_text(part)
if normalized:
allowed.add(cls._canonical_listing_status(normalized))
return allowed
@staticmethod
def _normalize_option_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
@@ -1249,6 +1552,7 @@ class PatrolDeleteTask:
items = data.get("items", [])
country_sections = data.get("country_sections", [])
cart_ratios = data.get("cart_ratios", [])
delete_conditions = self._resolve_delete_condition_texts(data)
if not task_id:
self.log("任务ID为空跳过", "WARNING")
@@ -1274,7 +1578,10 @@ class PatrolDeleteTask:
"stop_requested": False,
}
self.log(f"开始处理巡店删除任务 {task_id},共 {len(items)} 个店铺,{total_countries} 个国家")
self.log(
f"开始处理巡店删除任务 {task_id},共 {len(items)} 个店铺,{total_countries} 个国家,"
f"删除条件={delete_conditions if delete_conditions is not None else '未指定'}"
)
for index, shop_item in enumerate(items, 1):
if runing_task[task_id].get("stop_requested", False):
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理", "WARNING")
@@ -1283,7 +1590,7 @@ class PatrolDeleteTask:
shop_name = shop_item.get("shopName", "未知店铺")
self.log(f"[{index}/{len(items)}] 开始处理店铺: {shop_name}")
self.process_shop(task_id, shop_item, country_sections, cart_ratios)
self.process_shop(task_id, shop_item, country_sections, cart_ratios, delete_conditions)
runing_task[task_id]["processed_shops"] += 1
if runing_task[task_id].get("stop_requested", False):
@@ -1309,6 +1616,7 @@ class PatrolDeleteTask:
shop_item: Dict[str, Any],
country_sections: List[Dict[str, Any]],
cart_ratios: List[Dict[str, Any]],
delete_conditions: Optional[List[str]] = None,
):
"""处理单个店铺。"""
from config import runing_shop, runing_task
@@ -1334,7 +1642,10 @@ class PatrolDeleteTask:
country_names = self._ordered_countries(payload_country_sections)
aggregated_sections = self._build_country_section_templates(payload_country_sections)
aggregated_cart_ratios = self._build_cart_ratio_templates(country_names, payload_cart_ratios)
self.log(f"店铺 {shop_name} 待处理国家: {', '.join(country_names)}")
self.log(
f"店铺 {shop_name} 待处理国家: {', '.join(country_names)}"
f"删除条件={delete_conditions if delete_conditions is not None else '未指定'}"
)
driver: Optional[InventoryManage] = None
requires_browser_reset = False
@@ -1362,6 +1673,7 @@ class PatrolDeleteTask:
task_id=task_id,
shop_name=shop_name,
country_name=country_name,
delete_conditions=delete_conditions,
)
country_results.append(country_result)
aggregated_sections[country_name] = country_result["countrySection"]
@@ -1472,6 +1784,7 @@ class PatrolDeleteTask:
task_id: int,
shop_name: str,
country_name: str,
delete_conditions: Optional[List[str]] = None,
) -> Dict[str, Any]:
from config import runing_task
@@ -1487,7 +1800,11 @@ class PatrolDeleteTask:
driver.switch_to_manage_inventory()
self.log(f"国家 {country_name} 已进入管理所有库存页面,开始删除非白名单商品")
delete_result = driver.delete_all_listings_from_non_whitelisted_statuses(shop_name, country_name)
delete_result = driver.delete_all_listings_from_non_whitelisted_statuses(
shop_name,
country_name,
allowed_statuses=delete_conditions,
)
latest_status_rows = delete_result.get("statusRows") or []
self.log(
f"国家 {country_name} 删除步骤完成deleted={delete_result.get('totalDeleted', 0)}"
@@ -1499,17 +1816,26 @@ class PatrolDeleteTask:
delete_counts=delete_result.get("deleteCounts") or {},
default_process_status="无可删商品" if delete_result.get("totalDeleted", 0) == 0 else "",
)
complete_draft_section = self._read_complete_draft_country_section_safe(driver, shop_name, country_name)
complete_draft_delete_result = self._delete_complete_draft_country_section_safe(
driver,
shop_name,
country_name,
delete_conditions,
)
complete_draft_section = complete_draft_delete_result.get("countrySection")
country_section = self._replace_complete_draft_rows(country_section, complete_draft_section)
cart_ratio = self._read_country_cart_ratio_safe(driver, country_name)
self.log(f"国家 {country_name} 购物车比例读取完成: {cart_ratio.get('ratio', '')}")
total_deleted = int(delete_result.get("totalDeleted") or 0) + int(
complete_draft_delete_result.get("deletedCount") or 0
)
return {
"success": True,
"countrySection": country_section,
"cartRatio": cart_ratio,
"deletedCount": delete_result.get("totalDeleted", 0),
"deletedCount": total_deleted,
"reopenRequired": False,
}
except Exception as exc:
@@ -1566,6 +1892,51 @@ class PatrolDeleteTask:
self.log(f"读取国家 {country_name} 补全草稿失败: {str(draft_exc)}", "WARNING")
return None
def _delete_complete_draft_country_section_safe(
self,
driver: InventoryManage,
shop_name: str,
country_name: str,
delete_conditions: Optional[List[str]] = None,
) -> Dict[str, Any]:
trace_id = _new_trace_id("draft")
try:
self.log(f"trace_id={trace_id} 开始处理国家 {country_name} 补全草稿删除")
delete_result = driver.delete_complete_drafts_from_non_whitelisted_tags(
shop_name,
country_name,
trace_id=trace_id,
allowed_statuses=delete_conditions,
)
rows = []
delete_counts = delete_result.get("deleteCounts") or {}
for item in delete_result.get("draftRows") or []:
status = PatrolDeleteTask._string_result_field(item.get("tag") or item.get("status"))
canonical_status = InventoryManage._canonical_listing_status(status)
deleted_count = int(delete_counts.get(canonical_status) or 0)
rows.append(
{
"type": "completeDraft",
"status": status,
"quantity": InventoryManage._normalize_quantity_text(item.get("quantity")),
"deleteQuantity": str(deleted_count),
}
)
section = {"country": country_name, "rows": rows} if rows else None
self.log(
f"trace_id={trace_id} 国家 {country_name} 补全草稿删除处理完成: "
f"deleted={delete_result.get('totalDeleted', 0)}, rows={rows}"
)
return {
"countrySection": section,
"deletedCount": int(delete_result.get("totalDeleted") or 0),
"deleteResult": delete_result,
}
except Exception as draft_exc:
self.log(f"trace_id={trace_id} 删除国家 {country_name} 补全草稿失败: {str(draft_exc)}", "WARNING")
fallback_section = self._read_complete_draft_country_section_safe(driver, shop_name, country_name)
return {"countrySection": fallback_section, "deletedCount": 0, "error": str(draft_exc)}
def _switch_country_with_retry(self, driver: InventoryManage, country_name: str) -> None:
for retry in range(PRODUCT_TASK_MAX_RETRIES):
try:
@@ -1585,6 +1956,41 @@ class PatrolDeleteTask:
time.sleep(PRODUCT_TASK_RETRY_DELAY)
raise RuntimeError(f"切换国家 {country_name} 失败")
@classmethod
def _resolve_delete_condition_texts(cls, data: Dict[str, Any]) -> Optional[List[str]]:
if "delete_conditions" in data:
raw_conditions = data.get("delete_conditions")
elif "deleteConditions" in data:
raw_conditions = data.get("deleteConditions")
else:
return []
return cls._parse_delete_condition_texts(raw_conditions)
@classmethod
def _parse_delete_condition_texts(cls, raw_conditions: Any) -> List[str]:
if raw_conditions is None:
return []
if not isinstance(raw_conditions, list):
raw_conditions = [raw_conditions]
texts: List[str] = []
for item in raw_conditions:
text = ""
if isinstance(item, dict):
for key in ("conditionText", "condition_text", "status", "text", "label", "name"):
value = item.get(key)
if value:
text = str(value)
break
elif item is not None:
text = str(item)
for part in re.split(r"[,;;、\r\n]+", text):
normalized = InventoryManage._normalize_option_text(part)
if normalized and normalized not in texts:
texts.append(normalized)
return texts
def post_result(
self,
task_id: int,

View File

@@ -0,0 +1,128 @@
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 clickElement(el) {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
if (el.focus) el.focus();
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
}
el.click();
}
function summarize(el) {
return {
tag: el.tagName,
text: textOf(el).slice(0, 500),
attrs: Array.from(el.attributes || []).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {}),
outerHTML: (el.outerHTML || '').slice(0, 1000),
};
}
const view = document.querySelector('[data-testid="complete-drafts-view"]');
if (!view) return { ok: false, reason: 'complete drafts view not found' };
const selectedRows = Array.from(view.querySelectorAll('.ag-center-cols-container [role="row"]'))
.filter((row) => isVisible(row) && (row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')));
if (!selectedRows.length) return { ok: false, reason: 'no selected complete draft rows' };
const bulkDropdown = view.querySelector('#bulk-dropdown');
if (!bulkDropdown) {
return {
ok: false,
reason: 'bulk dropdown not found',
selectedCount: selectedRows.length,
};
}
const toggle = bulkDropdown.shadowRoot && bulkDropdown.shadowRoot.querySelector('button[part="dropdown-button-toggle-button"], button');
clickElement(toggle || bulkDropdown);
function findBulkDeleteAction() {
const all = [];
const seen = new Set();
function walk(root) {
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) all.push(node);
if (node.shadowRoot) walk(node.shadowRoot);
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
for (const assigned of node.assignedElements({ flatten: true })) walk(assigned);
}
node = walker.nextNode();
}
}
walk(document);
const exactAction = all.find((el) => {
const action = String(el.getAttribute && el.getAttribute('data-action') || '');
const text = textOf(el);
return isVisible(el) && action === 'DELETE_DRAFTS' && /删除商品信息草稿/.test(text);
});
if (exactAction) return exactAction;
return all.find((el) => {
const text = textOf(el);
const attrs = Array.from(el.attributes || []).map((attr) => `${attr.name}=${attr.value}`).join(' ');
const haystack = `${text} ${attrs}`;
if (!isVisible(el) || !/删除商品信息草稿/.test(haystack)) return false;
if (/^(HTML|BODY|KAT-DATA-GRID)$/i.test(el.tagName || '') && text.length > 80) return false;
if (/^DIV$/i.test(el.tagName || '') && text !== '删除商品信息草稿') return false;
if ((el.tagName || '').toLowerCase() === 'kat-icon') return false;
return /^(BUTTON|KAT-BUTTON|KAT-OPTION|KAT-DROPDOWN-OPTION|LI|A|SPAN|DIV)$/i.test(el.tagName || '');
});
}
const deleteAction = findBulkDeleteAction();
if (!deleteAction) {
const candidates = [];
const seen = new Set();
function walkCandidates(root) {
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 && isVisible(node)) {
const text = textOf(node);
const attrs = Array.from(node.attributes || []).map((attr) => `${attr.name}=${attr.value}`).join(' ');
if (/删除|草稿|选择组操作|menu|option/i.test(`${text} ${attrs}`)) candidates.push(summarize(node));
}
if (node.shadowRoot) walkCandidates(node.shadowRoot);
node = walker.nextNode();
}
}
walkCandidates(document);
return {
ok: false,
reason: 'bulk delete draft action not found',
menuOpened: true,
selectedCount: selectedRows.length,
bulkDropdown: summarize(bulkDropdown),
candidates: candidates.slice(0, 50),
};
}
clickElement(deleteAction);
return {
ok: true,
selectedCount: selectedRows.length,
bulkDropdown: summarize(bulkDropdown),
clicked: summarize(deleteAction),
rows: selectedRows.slice(0, 10).map(summarize),
};

View File

@@ -0,0 +1,100 @@
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 clickElement(el) {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
if (el.focus) el.focus();
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
}
el.click();
}
function summarizeRow(row) {
return {
rowId: row.getAttribute('row-id') || '',
text: textOf(row).slice(0, 500),
selected: row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected'),
};
}
const payload = arguments[0] || {};
const view = document.querySelector('[data-testid="complete-drafts-view"]');
if (!view) return { ok: false, reason: 'complete drafts view not found' };
const rows = Array.from(view.querySelectorAll('.ag-center-cols-container [role="row"]'))
.filter((row) => isVisible(row));
const rowCount = rows.length;
const selectedBefore = rows.filter((row) => row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')).length;
const maxSelectCountRaw = Number(payload.maxSelectCount || 0);
const maxSelectCount = Number.isFinite(maxSelectCountRaw) && maxSelectCountRaw > 0 ? Math.floor(maxSelectCountRaw) : 0;
if (payload.probeOnly) {
return {
ok: true,
probeOnly: true,
rowCount,
selectedCount: selectedBefore,
rows: rows.slice(0, 10).map(summarizeRow),
};
}
if (rowCount <= 0) {
return { ok: false, reason: 'no complete draft rows', rowCount, selectedCount: selectedBefore };
}
const headerCheckbox = view.querySelector('.ag-header-select-all input[type="checkbox"]');
if (!headerCheckbox) {
return { ok: false, reason: 'complete draft select-all checkbox not found', rowCount, selectedCount: selectedBefore };
}
if (maxSelectCount > 0 && maxSelectCount < rowCount) {
if (headerCheckbox.checked) {
clickElement(headerCheckbox);
}
for (const row of rows) {
if (row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')) {
const checkbox = row.querySelector('input[type="checkbox"]');
if (checkbox && checkbox.checked) clickElement(checkbox);
}
}
for (const row of rows.slice(0, maxSelectCount)) {
const checkbox = row.querySelector('input[type="checkbox"]');
if (checkbox && !checkbox.checked) {
clickElement(checkbox);
} else if (!checkbox) {
clickElement(row);
}
}
} else if (!headerCheckbox.checked || selectedBefore < rowCount) {
clickElement(headerCheckbox);
}
let selectedRows = Array.from(view.querySelectorAll('.ag-center-cols-container [role="row"]'))
.filter((row) => isVisible(row) && (row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')));
if (selectedRows.length <= 0) {
const firstRowCheckbox = rows[0] && rows[0].querySelector('input[type="checkbox"]');
if (firstRowCheckbox && !firstRowCheckbox.checked) {
clickElement(firstRowCheckbox);
}
}
selectedRows = Array.from(view.querySelectorAll('.ag-center-cols-container [role="row"]'))
.filter((row) => isVisible(row) && (row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')));
return {
ok: selectedRows.length > 0,
rowCount,
selectedCount: selectedRows.length,
headerChecked: headerCheckbox.checked,
rows: selectedRows.slice(0, 10).map(summarizeRow),
};

View File

@@ -0,0 +1,59 @@
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 clickElement(el) {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
if (el.focus) el.focus();
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
}
el.click();
}
function summarize(el) {
return {
tag: el.tagName,
text: textOf(el),
className: el.className || '',
outerHTML: (el.outerHTML || '').slice(0, 1000),
};
}
const targetTag = String((arguments[0] && arguments[0].tag) || '').replace(/\s+/g, ' ').trim();
if (!targetTag) return { ok: false, reason: 'target tag empty' };
const view = document.querySelector('[data-testid="complete-drafts-view"]');
if (!view) return { ok: false, reason: 'complete drafts view not found' };
const chips = Array.from(view.querySelectorAll('.JanusChip-module__janusChipContainer--gRjdp'));
const chip = chips.find((el) => {
const text = textOf(el);
return text === targetTag || text.startsWith(`${targetTag} (`) || text.startsWith(`${targetTag}`);
});
if (!chip) {
return {
ok: false,
reason: 'complete draft chip not found',
tag: targetTag,
chips: chips.map(summarize),
};
}
if (!/active/.test(String(chip.className || ''))) {
clickElement(chip);
}
return {
ok: true,
tag: targetTag,
clicked: summarize(chip),
};

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

View File

@@ -5,8 +5,8 @@
<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-C66x_2Dh.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-BtdhG28Q.js">
<link rel="modulepreload" crossorigin href="/assets/brand-Lp7YWffz.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
<link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css">
</head>

View File

@@ -5,8 +5,8 @@
<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-C66x_2Dh.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-BtdhG28Q.js">
<link rel="modulepreload" crossorigin href="/assets/brand-Lp7YWffz.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css">
</head>

View File

@@ -5,10 +5,11 @@
<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-C66x_2Dh.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-BtdhG28Q.js">
<link rel="modulepreload" crossorigin href="/assets/brand-Lp7YWffz.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BP3XWKAC.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BfMLVSQU.css">
</head>
<body>
<div id="app"></div>

View File

@@ -5,8 +5,8 @@
<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-C66x_2Dh.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-BtdhG28Q.js">
<link rel="modulepreload" crossorigin href="/assets/brand-Lp7YWffz.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
<link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css">
</head>