diff --git a/.gitignore b/.gitignore index ff4665c..7ac8b0c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ backend-java/.idea/ backend-java/*.iml # ===== backend / app ===== +__pycache__/ backend/tmp/ backend/__pycache__ app/__pycache__ diff --git a/app/amazon/base.py b/app/amazon/base.py index 107f2cf..ce6a7c6 100644 --- a/app/amazon/base.py +++ b/app/amazon/base.py @@ -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} 次后仍未成功启动") diff --git a/app/amazon/patrol_delete.py b/app/amazon/patrol_delete.py index a3408c0..8713496 100644 --- a/app/amazon/patrol_delete.py +++ b/app/amazon/patrol_delete.py @@ -5,7 +5,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 @@ -63,6 +63,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") @@ -82,6 +85,10 @@ DELETE_SUCCESS_COUNT_PATTERNS = ( DELETE_SUCCESS_TEXT_PATTERNS = ( re.compile(r"一个或多个商品(?:已删除|已经删除)"), ) +COMPLETE_DRAFT_DELETE_SUCCESS_PATTERNS = ( + re.compile(r"商品信息草稿(?:已删除|已经删除)"), + re.compile(r"已删除\s*\d*\s*个?\s*商品信息草稿"), +) LISTING_STATUS_DELETE_WHITELIST = { "全部", "定价问题", @@ -170,6 +177,231 @@ 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 = "", + delete_conditions: Optional[Iterable[Any]] = None, + allowed_statuses: Optional[Iterable[str]] = None, + ) -> Dict[str, Any]: + """按补全草稿快速查看的准确标签数量,删除非白名单标签下方商品。""" + trace_id = trace_id or _new_trace_id("draft") + effective_conditions = delete_conditions if delete_conditions is not None else allowed_statuses + logger.info( + f"trace_id={trace_id} 开始删除非白名单补全草稿: " + f"shop={shop_name}, country={country}, max_delete_operations={max_delete_operations}, " + f"delete_conditions={self._format_delete_conditions(effective_conditions)}" + ) + initial_tags = self.get_complete_draft_quick_view_tags(shop_name, country) + delete_candidates = self._build_deletable_complete_draft_tag_rows(initial_tags, effective_conditions) + 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: @@ -234,14 +466,16 @@ class InventoryManage(AmamzonBase): country: str, max_delete_operations: Optional[int] = None, trace_id: str = "", - delete_conditions: Optional[List[Dict[str, Any]]] = None, + delete_conditions: Optional[Iterable[Any]] = None, + allowed_statuses: Optional[Iterable[str]] = None, ) -> Dict[str, Any]: """循环删除非白名单分类商品,直到当前国家没有可删项。""" trace_id = trace_id or _new_trace_id("del") + effective_conditions = delete_conditions if delete_conditions is not None else allowed_statuses logger.info( f"trace_id={trace_id} 开始批量删除非白名单商品: " f"shop={shop_name}, country={country}, max_delete_operations={max_delete_operations}, " - f"delete_conditions={self._format_delete_conditions(delete_conditions)}" + f"delete_conditions={self._format_delete_conditions(effective_conditions)}" ) all_results: List[Dict[str, Any]] = [] delete_counts: Dict[str, int] = {} @@ -253,7 +487,7 @@ class InventoryManage(AmamzonBase): latest_status_rows = self._read_listing_status_rows_for_delete(shop_name, country, trace_id=trace_id) latest_delete_candidates = self._build_deletable_status_rows( latest_status_rows, - delete_conditions=delete_conditions, + delete_conditions=effective_conditions, ) if not latest_delete_candidates: logger.info(f"trace_id={trace_id} 未找到可删除商品分类: shop={shop_name}, country={country}") @@ -595,6 +829,11 @@ class InventoryManage(AmamzonBase): return True return any(pattern.search(normalized) for pattern in DELETE_SUCCESS_TEXT_PATTERNS) + @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: normalized = re.sub(r"\s+", "", text or "") @@ -791,9 +1030,15 @@ class InventoryManage(AmamzonBase): def _build_deletable_status_rows( cls, rows: Iterable[Dict[str, Any]], - delete_conditions: Optional[List[Dict[str, Any]]] = None, + delete_conditions: Optional[Iterable[Any]] = None, ) -> List[Dict[str, Any]]: condition_texts = cls._normalize_delete_condition_texts(delete_conditions) + if not condition_texts: + return [] + inventory_status_enabled = cls._matches_any_delete_condition( + ("库存状态删除", "商品状态删除", "库存状态"), + condition_texts, + ) candidates = [] for row in rows: status = cls._normalize_option_text(row.get("status") or "") @@ -805,7 +1050,7 @@ class InventoryManage(AmamzonBase): canonical_status = cls._canonical_listing_status(status) if not quantity_text or quantity <= 0 or canonical_status in LISTING_STATUS_DELETE_WHITELIST: continue - if condition_texts and not cls._matches_delete_conditions(status, condition_texts): + if not inventory_status_enabled and not cls._matches_delete_conditions(status, condition_texts): continue candidates.append( @@ -820,17 +1065,29 @@ class InventoryManage(AmamzonBase): @classmethod def _normalize_delete_condition_texts( cls, - delete_conditions: Optional[List[Dict[str, Any]]], + delete_conditions: Optional[Iterable[Any]], ) -> List[str]: texts = [] + if delete_conditions is None: + return texts + if isinstance(delete_conditions, (str, bytes, dict)): + delete_conditions = [delete_conditions] for condition in delete_conditions or []: if isinstance(condition, dict): - raw_text = condition.get("conditionText") or condition.get("condition_text") or condition.get("text") + raw_text = next( + ( + condition.get(key) + for key in ("conditionText", "condition_text", "status", "text", "label", "name") + if condition.get(key) + ), + "", + ) else: raw_text = condition - text = cls._normalize_option_text(str(raw_text or "")).lower() - if text: - texts.append(text) + for part in re.split(r"[,,;;、\r\n]+", str(raw_text or "")): + text = cls._normalize_option_text(part).lower() + if text: + texts.append(text) return list(dict.fromkeys(texts)) @classmethod @@ -846,9 +1103,49 @@ class InventoryManage(AmamzonBase): ) @classmethod - def _format_delete_conditions(cls, delete_conditions: Optional[List[Dict[str, Any]]]) -> str: + def _matches_any_delete_condition(cls, statuses: Iterable[str], condition_texts: List[str]) -> bool: + return any(cls._matches_delete_conditions(status, condition_texts) for status in statuses) + + @classmethod + def _format_delete_conditions(cls, delete_conditions: Optional[Iterable[Any]]) -> str: texts = cls._normalize_delete_condition_texts(delete_conditions) - return ", ".join(texts) if texts else "default" + return ", ".join(texts) if texts else "未指定" + + @classmethod + def _build_deletable_complete_draft_tag_rows( + cls, + rows: Iterable[Dict[str, Any]], + delete_conditions: Optional[Iterable[Any]] = None, + ) -> List[Dict[str, Any]]: + condition_texts = cls._normalize_delete_condition_texts(delete_conditions) + if not condition_texts: + return [] + complete_draft_enabled = bool( + cls._matches_any_delete_condition( + ("补全草稿删除", "补全草稿", "商品信息草稿"), + condition_texts, + ) + ) + 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 not quantity_text or quantity <= 0 or canonical_tag in LISTING_STATUS_DELETE_WHITELIST: + continue + if not complete_draft_enabled and not cls._matches_delete_conditions(tag, condition_texts): + continue + candidates.append( + { + "tag": tag, + "canonicalTag": canonical_tag, + "quantity": quantity, + } + ) + return candidates @staticmethod def _format_deletable_candidates(candidates: Iterable[Dict[str, Any]]) -> str: @@ -864,6 +1161,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() @@ -1344,7 +1656,7 @@ class PatrolDeleteTask: items = data.get("items", []) country_sections = data.get("country_sections", []) cart_ratios = data.get("cart_ratios", []) - delete_conditions = data.get("delete_conditions") or data.get("deleteConditions") or [] + delete_conditions = self._resolve_delete_condition_texts(data) if not task_id: self.log("任务ID为空,跳过", "WARNING") @@ -1373,7 +1685,7 @@ class PatrolDeleteTask: self.log( f"开始处理巡店删除任务 {task_id},共 {len(items)} 个店铺,{total_countries} 个国家," - f"删除条件 {len(delete_conditions)} 条" + 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): @@ -1409,7 +1721,7 @@ class PatrolDeleteTask: shop_item: Dict[str, Any], country_sections: List[Dict[str, Any]], cart_ratios: List[Dict[str, Any]], - delete_conditions: Optional[List[Dict[str, Any]]] = None, + delete_conditions: Optional[List[str]] = None, ): """处理单个店铺。""" from config import runing_shop, runing_task @@ -1435,7 +1747,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 @@ -1574,7 +1889,7 @@ class PatrolDeleteTask: task_id: int, shop_name: str, country_name: str, - delete_conditions: Optional[List[Dict[str, Any]]] = None, + delete_conditions: Optional[List[str]] = None, ) -> Dict[str, Any]: from config import runing_task @@ -1609,17 +1924,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"trace_id={trace_id} 国家 {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: @@ -1680,6 +2004,51 @@ class PatrolDeleteTask: self.log(f"trace_id={trace_id} 读取国家 {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, + delete_conditions=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: trace_id = _new_trace_id("switch") for retry in range(PRODUCT_TASK_MAX_RETRIES): @@ -1704,6 +2073,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, @@ -1814,8 +2218,8 @@ class PatrolDeleteTask: for row in section.get("rows") or []: if cls._is_complete_draft_result_row(row): normalized_row = { - "status": cls._string_result_field(row.get("status")), "quantity": cls._string_result_field(row.get("quantity")), + "deleteQuantity": cls._string_result_field(row.get("deleteQuantity")), } else: normalized_row = { diff --git a/app/amazon/scripts/patrol_delete/complete_draft_delete_selected.js b/app/amazon/scripts/patrol_delete/complete_draft_delete_selected.js new file mode 100644 index 0000000..7a8ce61 --- /dev/null +++ b/app/amazon/scripts/patrol_delete/complete_draft_delete_selected.js @@ -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), +}; diff --git a/app/amazon/scripts/patrol_delete/complete_draft_select_all.js b/app/amazon/scripts/patrol_delete/complete_draft_select_all.js new file mode 100644 index 0000000..c8c319a --- /dev/null +++ b/app/amazon/scripts/patrol_delete/complete_draft_select_all.js @@ -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), +}; diff --git a/app/amazon/scripts/patrol_delete/complete_draft_select_tag.js b/app/amazon/scripts/patrol_delete/complete_draft_select_tag.js new file mode 100644 index 0000000..1dc213d --- /dev/null +++ b/app/amazon/scripts/patrol_delete/complete_draft_select_tag.js @@ -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), +};