diff --git a/app/amazon/patrol_delete.py b/app/amazon/patrol_delete.py index 2c72d4a..59c27b3 100644 --- a/app/amazon/patrol_delete.py +++ b/app/amazon/patrol_delete.py @@ -34,6 +34,10 @@ RECOMMENDED_OFFER_EXPAND_XPATHS = [ SCRIPT_DIR = Path(__file__).with_name("scripts") / "patrol_delete" +def _new_trace_id(prefix: str) -> str: + return f"{prefix}-{int(time.time() * 1000) % 1000000:06d}" + + def _load_patrol_delete_script(name: str) -> str: return (SCRIPT_DIR / name).read_text(encoding="utf-8") @@ -61,9 +65,12 @@ PRODUCT_TASK_MAX_RETRIES = 3 PRODUCT_TASK_RETRY_DELAY = 2 PRODUCT_TASK_OPEN_SHOP_DELAY = 3 PRODUCT_TASK_BROWSER_RESET_DELAY = 2 -DELETE_SUCCESS_MESSAGE_PARTS = ( - re.compile(r"\d+\s*个商品(?:已删除|已经删除)"), - re.compile(r"所[作做]更改需要\s*15\s*分钟才会显示在商品详情页面上"), +DELETE_SUCCESS_COUNT_PATTERNS = ( + re.compile(r"(\d+)\s*个商品(?:已删除|已经删除)"), + re.compile(r"(?:已删除|已经删除)\s*(\d+)\s*个商品"), +) +DELETE_SUCCESS_TEXT_PATTERNS = ( + re.compile(r"一个或多个商品(?:已删除|已经删除)"), ) LISTING_STATUS_DELETE_WHITELIST = { "全部", @@ -195,10 +202,17 @@ class InventoryManage(AmamzonBase): return rows def get_listing_status_country_sections(self, shop_name: str, country: str) -> List[Dict[str, Any]]: + trace_id = _new_trace_id("status") + logger.info(f"trace_id={trace_id} 开始读取商品状态统计: shop={shop_name}, country={country}") rows = self._wait_listing_status_section_rows() parsed_rows = self._parse_listing_status_section_rows(rows) if not parsed_rows: + logger.info(f"trace_id={trace_id} 商品状态统计读取为空: raw_rows={json.dumps(rows, ensure_ascii=False)}") raise RuntimeError(f"未读取到商品状态统计标签: {rows}") + logger.info( + f"trace_id={trace_id} 商品状态统计读取完成: " + f"shop={shop_name}, country={country}, row_count={len(parsed_rows)}" + ) return [{"country": country, "rows": self._build_country_section_rows(parsed_rows)}] def delete_all_listings_from_non_whitelisted_statuses( @@ -206,13 +220,13 @@ class InventoryManage(AmamzonBase): shop_name: str, country: str, max_delete_operations: Optional[int] = None, + trace_id: str = "", ) -> Dict[str, Any]: """循环删除非白名单分类商品,直到当前国家没有可删项。""" + trace_id = trace_id or _new_trace_id("del") logger.info( - "开始批量删除非白名单商品: shop={}, country={}, max_delete_operations={}", - shop_name, - country, - max_delete_operations, + f"trace_id={trace_id} 开始批量删除非白名单商品: " + f"shop={shop_name}, country={country}, max_delete_operations={max_delete_operations}" ) all_results: List[Dict[str, Any]] = [] delete_counts: Dict[str, int] = {} @@ -221,10 +235,10 @@ class InventoryManage(AmamzonBase): total_deleted = 0 stop_reason = "no-candidates" - latest_status_rows = self._read_listing_status_rows_for_delete(shop_name, country) + 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) if not latest_delete_candidates: - logger.info("未找到可删除商品分类: shop={}, country={}", shop_name, country) + logger.info(f"trace_id={trace_id} 未找到可删除商品分类: shop={shop_name}, country={country}") final_status_rows = latest_status_rows return { "shopName": shop_name, @@ -238,34 +252,27 @@ class InventoryManage(AmamzonBase): } logger.info( - "可删除商品分类已生成: shop={}, country={}, candidates={}", - shop_name, - country, - self._format_deletable_candidates(latest_delete_candidates), + f"trace_id={trace_id} 可删除商品分类已生成: shop={shop_name}, country={country}, " + f"candidates={self._format_deletable_candidates(latest_delete_candidates)}" ) stop_reason = "processed-candidates" for candidate in latest_delete_candidates: logger.info( - "开始处理可删除分类: shop={}, country={}, status={}, quantity={}", - shop_name, - country, - candidate["status"], - candidate["quantity"], + f"trace_id={trace_id} 开始处理可删除分类: shop={shop_name}, country={country}, " + f"status={candidate['status']}, quantity={candidate['quantity']}" ) - self.select_listing_status(candidate["status"]) + self.select_listing_status(candidate["status"], trace_id=trace_id) deleted_for_status = 0 status_quantity = candidate["quantity"] while status_quantity <= 0 or deleted_for_status < status_quantity: - bulk_result = self._delete_selected_filtered_inventory_rows(candidate["status"]) + bulk_result = self._delete_selected_filtered_inventory_rows(candidate["status"], trace_id=trace_id) deleted_count = int(bulk_result.get("deletedCount") or 0) if deleted_count <= 0: logger.info( - "当前分类未删除到商品,结束分类处理: shop={}, country={}, status={}, deleted_for_status={}", - shop_name, - country, - candidate["status"], - deleted_for_status, + f"trace_id={trace_id} 当前分类未删除到商品,结束分类处理: " + f"shop={shop_name}, country={country}, status={candidate['status']}, " + f"deleted_for_status={deleted_for_status}" ) break @@ -283,23 +290,18 @@ class InventoryManage(AmamzonBase): total_deleted += deleted_count deleted_for_status += deleted_count logger.info( - "批量删除成功: shop={}, country={}, status={}, deleted_count={}, status_deleted={}, total_deleted={}", - shop_name, - country, - candidate["status"], - deleted_count, - deleted_for_status, - total_deleted, + f"trace_id={trace_id} 批量删除成功: shop={shop_name}, country={country}, " + f"status={candidate['status']}, deleted_count={deleted_count}, " + f"status_deleted={deleted_for_status}, total_deleted={total_deleted}, " + f"success_message={bulk_result.get('successMessage', '')}" ) if max_delete_operations is not None and total_deleted >= max_delete_operations: stop_reason = "max-delete-operations" logger.info( - "达到最大删除次数,准备停止: shop={}, country={}, total_deleted={}, limit={}", - shop_name, - country, - total_deleted, - max_delete_operations, + f"trace_id={trace_id} 达到最大删除次数,准备停止: " + f"shop={shop_name}, country={country}, total_deleted={total_deleted}, " + f"limit={max_delete_operations}" ) break @@ -308,20 +310,14 @@ class InventoryManage(AmamzonBase): if stop_reason == "max-delete-operations": break logger.info( - "可删除分类处理完成: shop={}, country={}, status={}, deleted_for_status={}", - shop_name, - country, - candidate["status"], - deleted_for_status, + f"trace_id={trace_id} 可删除分类处理完成: shop={shop_name}, country={country}, " + f"status={candidate['status']}, deleted_for_status={deleted_for_status}" ) - final_status_rows = self._read_listing_status_rows_for_delete(shop_name, country) + final_status_rows = self._read_listing_status_rows_for_delete(shop_name, country, trace_id=trace_id) logger.info( - "批量删除非白名单商品完成: shop={}, country={}, total_deleted={}, stop_reason={}", - shop_name, - country, - total_deleted, - stop_reason, + f"trace_id={trace_id} 批量删除非白名单商品完成: " + f"shop={shop_name}, country={country}, total_deleted={total_deleted}, stop_reason={stop_reason}" ) return { @@ -335,30 +331,42 @@ class InventoryManage(AmamzonBase): "stopReason": stop_reason, } - def _read_listing_status_rows_for_delete(self, shop_name: str, country: str) -> List[Dict[str, str]]: - logger.info("开始读取商品状态行: shop={}, country={}", shop_name, country) + def _read_listing_status_rows_for_delete( + self, + shop_name: str, + country: str, + trace_id: str = "", + ) -> List[Dict[str, str]]: + trace_id = trace_id or _new_trace_id("rows") + logger.info(f"trace_id={trace_id} 开始读取商品状态行: shop={shop_name}, country={country}") option_rows = self.get_listing_status_dropdown_options(shop_name, country) rows = self._build_country_section_rows(option_rows) - logger.info("商品状态行读取完成: shop={}, country={}, row_count={}", shop_name, country, len(rows)) + logger.info(f"trace_id={trace_id} 商品状态行读取完成: shop={shop_name}, country={country}, row_count={len(rows)}") return rows - def select_listing_status(self, status: str) -> Dict[str, Any]: + def select_listing_status(self, status: str, trace_id: str = "") -> Dict[str, Any]: normalized_status = self._normalize_option_text(status) if not normalized_status: raise RuntimeError("商品状态为空,无法执行筛选") - logger.info("开始筛选商品状态: status={}", normalized_status) + trace_id = trace_id or _new_trace_id("status") + logger.info(f"trace_id={trace_id} 开始筛选商品状态: status={normalized_status}") open_result = self.open_listing_status_dropdown() option_rows = self._wait_listing_status_option_rows() if not option_rows: + logger.info( + f"trace_id={trace_id} 商品状态下拉选项为空: " + f"open_result={json.dumps(open_result, ensure_ascii=False)}" + ) raise RuntimeError(f"商品状态下拉框已打开但未读取到选项: {open_result}") result = self._run_js(LISTING_STATUS_SELECT_OPTION_SCRIPT, {"status": normalized_status}) or {} if not result.get("ok"): + logger.info(f"trace_id={trace_id} 商品状态选项未命中: status={normalized_status}, result={result}") raise RuntimeError(f"未找到商品状态选项[{normalized_status}]: {result}") time.sleep(LISTING_STATUS_SELECT_DELAY) self._wait_inventory_loader() - logger.info("商品状态筛选完成: status={}, result={}", normalized_status, result) + logger.info(f"trace_id={trace_id} 商品状态筛选完成: status={normalized_status}, result={result}") return {"mode": "dropdown-option", **result} def _close_listing_status_dropdown(self) -> None: @@ -394,22 +402,22 @@ class InventoryManage(AmamzonBase): logger.info("状态[{}]未找到可删除商品行: {}", status, exc) return None - def _delete_selected_filtered_inventory_rows(self, status: str) -> Dict[str, Any]: - logger.info("开始批量删除当前筛选结果: status={}", status) + def _delete_selected_filtered_inventory_rows(self, status: str, trace_id: str = "") -> Dict[str, Any]: + trace_id = trace_id or _new_trace_id("del") + logger.info(f"trace_id={trace_id} 开始批量删除当前筛选结果: status={status}") row = self._get_first_inventory_row_or_none(status) if row is None: - logger.info("当前筛选结果没有可删除商品: status={}", status) + logger.info(f"trace_id={trace_id} 当前筛选结果没有可删除商品: status={status}") return {"deletedCount": 0, "target": {}, "successMessage": ""} target = self._summarize_inventory_row(row) select_result = self._run_js(INVENTORY_SELECT_ALL_SCRIPT) or {} if not select_result.get("ok"): + logger.info(f"trace_id={trace_id} 全选商品失败: status={status}, result={select_result}") raise RuntimeError(f"状态[{status}]全选商品失败: {select_result}") logger.info( - "当前筛选结果已全选: status={}, row_count={}, first_sku={}", - status, - select_result.get("rowCount"), - target.get("sku", ""), + f"trace_id={trace_id} 当前筛选结果已全选: status={status}, " + f"row_count={select_result.get('rowCount')}, first_sku={target.get('sku', '')}" ) delete_result: Dict[str, Any] = {} @@ -419,18 +427,22 @@ class InventoryManage(AmamzonBase): if delete_result.get("ok"): break if delete_result.get("menuOpened"): - logger.info("状态[{}]已点击选择组操作,等待删除菜单展开: {}", status, delete_result) + logger.info(f"trace_id={trace_id} 状态[{status}]已点击选择组操作,等待删除菜单展开: {delete_result}") time.sleep(1) continue - logger.info("状态[{}]第{}次点击批量删除未命中: {}", status, attempt + 1, delete_result) + logger.info(f"trace_id={trace_id} 状态[{status}]第{attempt + 1}次点击批量删除未命中: {delete_result}") if not delete_result.get("ok"): + logger.info(f"trace_id={trace_id} 点击批量删除失败: status={status}, result={delete_result}") raise RuntimeError(f"状态[{status}]点击批量删除失败: {delete_result}") - logger.info("批量删除按钮点击成功: status={}, result={}", status, delete_result) + logger.info(f"trace_id={trace_id} 批量删除按钮点击成功: status={status}, result={delete_result}") - self._confirm_delete_modal(status) - success_message = self._wait_delete_success_message(status) + self._confirm_delete_modal(status, trace_id=trace_id) + success_message = self._wait_delete_success_message(status, trace_id=trace_id) deleted_count = self._extract_delete_success_count(success_message) or int(select_result.get("rowCount") or 1) - logger.info("批量删除确认完成: status={}, deleted_count={}, success_message={}", status, deleted_count, success_message) + logger.info( + f"trace_id={trace_id} 批量删除确认完成: status={status}, " + f"deleted_count={deleted_count}, success_message={success_message}" + ) return { "deletedCount": deleted_count, "target": { @@ -456,24 +468,26 @@ 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: + trace_id = trace_id or _new_trace_id("modal") js_result = self._click_confirm_delete_modal_with_js() if js_result.get("ok"): - logger.info("已通过 JS 确认删除弹窗: status={}, result={}", status, js_result) + logger.info(f"trace_id={trace_id} 已通过 JS 确认删除弹窗: status={status}, result={js_result}") return - logger.info("JS 确认删除弹窗未命中,尝试驱动点击: status={}, result={}", status, js_result) + logger.info(f"trace_id={trace_id} JS 确认删除弹窗未命中,尝试驱动点击: status={status}, result={js_result}") confirm_button = self.tab.ele( 'xpath://kat-modal[@data-testid="action-modal"]//kat-button[@variant="primary"]', timeout=5, ) if not confirm_button: + logger.info(f"trace_id={trace_id} 删除确认弹窗未出现: status={status}") raise RuntimeError(f"状态[{status}]未出现删除确认弹窗") confirm_button.wait.displayed(raise_err=False) confirm_button.wait.enabled() confirm_button.click() - logger.info("已通过驱动确认删除弹窗: status={}", status) + logger.info(f"trace_id={trace_id} 已通过驱动确认删除弹窗: status={status}") def _click_confirm_delete_modal_with_js(self) -> Dict[str, Any]: try: @@ -482,7 +496,8 @@ class InventoryManage(AmamzonBase): logger.info("JS 点击删除确认按钮失败,回退到驱动点击: {}", exc) return {"ok": False, "reason": str(exc)} - def _wait_delete_success_message(self, status: str) -> str: + def _wait_delete_success_message(self, status: str, trace_id: str = "") -> str: + trace_id = trace_id or _new_trace_id("toast") deadline = time.time() + DELETE_SUCCESS_TIMEOUT seen_messages = [] while time.time() < deadline: @@ -492,10 +507,11 @@ class InventoryManage(AmamzonBase): if text not in seen_messages: seen_messages.append(text) if self._is_delete_success_message(text): - logger.info("读取到删除成功提示: status={}, message={}", status, text) + logger.info(f"trace_id={trace_id} 读取到删除成功提示: status={status}, message={text}") return text time.sleep(0.3) - raise RuntimeError(f"状态[{status}]删除后未读取到成功提示: {seen_messages}") + logger.info(f"trace_id={trace_id} 删除后未读取到成功提示: status={status}, seen_messages={seen_messages}") + raise RuntimeError("删除后未读取到成功提示") def _collect_delete_success_messages(self) -> List[str]: messages: List[str] = [] @@ -542,12 +558,18 @@ class InventoryManage(AmamzonBase): @staticmethod def _is_delete_success_message(text: str) -> bool: normalized = re.sub(r"\s+", "", text or "") - return all(pattern.search(normalized) for pattern in DELETE_SUCCESS_MESSAGE_PARTS) + if InventoryManage._extract_delete_success_count(normalized) > 0: + return True + return any(pattern.search(normalized) for pattern in DELETE_SUCCESS_TEXT_PATTERNS) @staticmethod def _extract_delete_success_count(text: str) -> int: - match = re.search(r"(\d+)\s*个商品(?:已删除|已经删除)", text or "") - return int(match.group(1)) if match else 0 + normalized = re.sub(r"\s+", "", text or "") + for pattern in DELETE_SUCCESS_COUNT_PATTERNS: + match = pattern.search(normalized) + if match: + return int(match.group(1)) + return 0 def _wait_complete_draft_quick_view_tag_rows(self) -> List[Dict[str, Any]]: deadline = time.time() + QUICK_VIEW_TAGS_TIMEOUT @@ -1237,8 +1259,8 @@ class PatrolDeleteTask: self.running = True def log(self, message: str, level: str = "INFO"): - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[{timestamp}] [ProductTask] [{level}] {message}") + level_name = str(level or "INFO").upper() + logger.log(level_name, f"[ProductTask] {message}") def process_task(self, task_data: dict): """处理巡店删除任务主入口。""" @@ -1475,22 +1497,28 @@ class PatrolDeleteTask: ) -> Dict[str, Any]: from config import runing_task - self.log(f"开始处理国家: {country_name}") + trace_id = _new_trace_id("country") + self.log(f"trace_id={trace_id} 开始处理国家: task_id={task_id}, shop={shop_name}, country={country_name}") show_notification(f"开始处理巡店删除国家: {country_name}", "info") if task_id in runing_task: runing_task[task_id]["current_country"] = country_name latest_status_rows: List[Dict[str, Any]] = [] try: + self.log(f"trace_id={trace_id} 准备切换国家: country={country_name}") self._switch_country_with_retry(driver, country_name) - self.log(f"国家 {country_name} 切换完成,准备进入管理所有库存页面") + self.log(f"trace_id={trace_id} 国家切换完成,准备进入管理所有库存页面: country={country_name}") driver.switch_to_manage_inventory() - self.log(f"国家 {country_name} 已进入管理所有库存页面,开始删除非白名单商品") + self.log(f"trace_id={trace_id} 已进入管理所有库存页面,开始删除非白名单商品: country={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, + trace_id=trace_id, + ) latest_status_rows = delete_result.get("statusRows") or [] self.log( - f"国家 {country_name} 删除步骤完成,deleted={delete_result.get('totalDeleted', 0)}," + f"trace_id={trace_id} 国家 {country_name} 删除步骤完成,deleted={delete_result.get('totalDeleted', 0)}," f"stopReason={delete_result.get('stopReason', '')}" ) country_section = self._build_country_section_result( @@ -1503,7 +1531,7 @@ class PatrolDeleteTask: 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', '')}") + self.log(f"trace_id={trace_id} 国家 {country_name} 购物车比例读取完成: {cart_ratio.get('ratio', '')}") return { "success": True, @@ -1513,7 +1541,7 @@ class PatrolDeleteTask: "reopenRequired": False, } except Exception as exc: - self.log(f"处理国家 {country_name} 失败: {str(exc)}", "ERROR") + self.log(f"trace_id={trace_id} 处理国家 {country_name} 失败: {str(exc)}", "ERROR") cart_ratio = self._read_country_cart_ratio_safe(driver, country_name) failure_rows = latest_status_rows or [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}] country_section = self._build_country_section_result( @@ -1531,14 +1559,17 @@ class PatrolDeleteTask: } def _read_country_cart_ratio_safe(self, driver: InventoryManage, country_name: str) -> Dict[str, str]: + trace_id = _new_trace_id("cart") try: - self.log(f"开始读取国家 {country_name} 购物车比例") - return self._extract_country_cart_ratio( + self.log(f"trace_id={trace_id} 开始读取国家 {country_name} 购物车比例") + cart_ratio = self._extract_country_cart_ratio( driver.get_recommended_offer_cart_ratios().get("cartRatios") or [], country_name, ) + self.log(f"trace_id={trace_id} 国家 {country_name} 购物车比例读取成功: {cart_ratio.get('ratio', '')}") + return cart_ratio except Exception as cart_exc: - self.log(f"读取国家 {country_name} 购物车比例失败: {str(cart_exc)}", "WARNING") + self.log(f"trace_id={trace_id} 读取国家 {country_name} 购物车比例失败: {str(cart_exc)}", "WARNING") return self._empty_cart_ratio(country_name) def _read_complete_draft_country_section_safe( @@ -1547,8 +1578,9 @@ class PatrolDeleteTask: shop_name: str, country_name: str, ) -> Optional[Dict[str, Any]]: + trace_id = _new_trace_id("draft") try: - self.log(f"开始读取国家 {country_name} 补全草稿") + self.log(f"trace_id={trace_id} 开始读取国家 {country_name} 补全草稿") tags = driver.get_complete_draft_quick_view_tags(shop_name, country_name) rows = [] for item in tags: @@ -1556,22 +1588,26 @@ class PatrolDeleteTask: rows.append( { "type": "completeDraft", + "status": PatrolDeleteTask._string_result_field(item.get("tag")), "quantity": quantity, - "deleteQuantity": quantity, } ) - self.log(f"国家 {country_name} 补全草稿读取完成: rows={rows}") + self.log(f"trace_id={trace_id} 国家 {country_name} 补全草稿读取完成: rows={rows}") return {"country": country_name, "rows": rows} if rows else None except Exception as draft_exc: - self.log(f"读取国家 {country_name} 补全草稿失败: {str(draft_exc)}", "WARNING") + self.log(f"trace_id={trace_id} 读取国家 {country_name} 补全草稿失败: {str(draft_exc)}", "WARNING") return None 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): try: - self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{PRODUCT_TASK_MAX_RETRIES} 次)") + self.log( + f"trace_id={trace_id} 尝试切换到国家 {country_name} " + f"(第 {retry + 1}/{PRODUCT_TASK_MAX_RETRIES} 次)" + ) if driver.switch_to_country(country_name): - self.log(f"成功切换到国家 {country_name}") + self.log(f"trace_id={trace_id} 成功切换到国家 {country_name}") return if retry < PRODUCT_TASK_MAX_RETRIES - 1: try: @@ -1582,6 +1618,7 @@ class PatrolDeleteTask: except Exception as exc: if retry >= PRODUCT_TASK_MAX_RETRIES - 1: raise RuntimeError(f"切换国家 {country_name} 失败: {exc}") from exc + self.log(f"trace_id={trace_id} 切换国家 {country_name} 异常,准备重试: {str(exc)}", "WARNING") time.sleep(PRODUCT_TASK_RETRY_DELAY) raise RuntimeError(f"切换国家 {country_name} 失败") @@ -1597,6 +1634,7 @@ class PatrolDeleteTask: """回传巡店删除结果到 Java API。""" from config import DELETE_BRAND_API_BASE + trace_id = _new_trace_id("post") shop_payload: Dict[str, Any] = { "shopName": shop_name, "shopDone": shop_done, @@ -1613,13 +1651,14 @@ class PatrolDeleteTask: payload = {"shops": [shop_payload]} url = f"{DELETE_BRAND_API_BASE}/api/patrol-delete/tasks/{task_id}/result" + self._log_result_payload_summary(task_id, shop_payload, trace_id=trace_id) max_retries = 3 for retry in range(max_retries): try: - self.log(f"尝试回传巡店删除结果 (第 {retry + 1}/{max_retries} 次)") - self.log(f"回传URL: {url}") - self.log(f"回传数据: {payload}") + self.log(f"trace_id={trace_id} 尝试回传巡店删除结果 (第 {retry + 1}/{max_retries} 次)") + self.log(f"trace_id={trace_id} 回传URL: {url}") + self.log(f"trace_id={trace_id} 回传数据: {payload}") response = requests.post( url, json=payload, @@ -1627,27 +1666,60 @@ class PatrolDeleteTask: timeout=30, verify=False, ) - self.log(f"回传结果: {response.text}") + self.log(f"trace_id={trace_id} 回传结果: {response.text}") try: response_data = response.json() except (AttributeError, ValueError): response_data = None api_success = not (isinstance(response_data, dict) and response_data.get("success") is False) + self.log( + f"trace_id={trace_id} 巡店删除结果回传响应: status_code={response.status_code}, " + f"api_success={api_success}, response={response.text}" + ) if response.status_code == 200 and api_success: - self.log(f"巡店删除结果回传成功: {shop_name}") + self.log(f"trace_id={trace_id} 巡店删除结果回传成功: {shop_name}") return self.log( - f"巡店删除结果回传失败,状态码: {response.status_code}, 响应: {response.text}", + f"trace_id={trace_id} 巡店删除结果回传失败,状态码: {response.status_code}, 响应: {response.text}", "WARNING", ) except Exception as exc: - self.log(f"调用巡店删除结果API异常: {str(exc)}", "ERROR") + self.log(f"trace_id={trace_id} 调用巡店删除结果API异常: {str(exc)}", "ERROR") if retry < max_retries - 1: time.sleep(2) raise RuntimeError("巡店删除结果回传最终失败") + def _log_result_payload_summary(self, task_id: int, shop_payload: Dict[str, Any], trace_id: str = "") -> None: + trace_id = trace_id or _new_trace_id("post") + country_sections = shop_payload.get("countrySections") or [] + cart_ratios = shop_payload.get("cartRatios") or [] + countries = [str(section.get("country") or "").strip() for section in country_sections] + row_count = sum(len(section.get("rows") or []) for section in country_sections) + deleted_total = self._sum_result_delete_quantity(country_sections) + self.log( + f"trace_id={trace_id} 巡店删除结果回传摘要: " + f"task_id={task_id}, shop={shop_payload.get('shopName', '')}, " + f"shopDone={shop_payload.get('shopDone', False)}, " + f"hasError={bool(shop_payload.get('error'))}, " + f"countryCount={len(country_sections)}, countries={','.join(countries)}, " + f"rowCount={row_count}, cartRatioCount={len(cart_ratios)}, deletedTotal={deleted_total}" + ) + + @staticmethod + def _sum_result_delete_quantity(country_sections: List[Dict[str, Any]]) -> int: + total = 0 + for section in country_sections or []: + for row in section.get("rows") or []: + text = str(row.get("deleteQuantity") or "").replace(",", "").strip() + if not text: + continue + match = re.search(r"\d+", text) + if match: + total += int(match.group(0)) + return total + @classmethod def _normalize_country_sections_for_result( cls, country_sections: Optional[List[Dict[str, Any]]] @@ -1657,14 +1729,16 @@ class PatrolDeleteTask: country = str(section.get("country") or "").strip() rows = [] for row in section.get("rows") or []: - normalized_row = { - "quantity": cls._string_result_field(row.get("quantity")), - "deleteQuantity": cls._string_result_field(row.get("deleteQuantity")), - } - if not cls._is_complete_draft_result_row(row): + if cls._is_complete_draft_result_row(row): normalized_row = { "status": cls._string_result_field(row.get("status")), - **normalized_row, + "quantity": cls._string_result_field(row.get("quantity")), + } + else: + 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")), "processStatus": cls._string_result_field(row.get("processStatus")), } rows.append(normalized_row) @@ -1820,8 +1894,11 @@ class PatrolDeleteTask: if not process_status and default_process_status and not default_status_applied: process_status = default_process_status default_status_applied = True - delete_quantity = str(delete_count) if delete_count > 0 else "" - if default_process_status == "无可删商品": + if canonical_status in LISTING_STATUS_DELETE_WHITELIST: + delete_quantity = "无需删除" + else: + delete_quantity = str(delete_count) if delete_count > 0 else "" + if default_process_status == "无可删商品" and canonical_status not in LISTING_STATUS_DELETE_WHITELIST: delete_quantity = "0" rows.append( { @@ -1837,7 +1914,7 @@ class PatrolDeleteTask: { "status": "全部", "quantity": "", - "deleteQuantity": "0" if default_process_status == "无可删商品" else "", + "deleteQuantity": "无需删除" if default_process_status == "无可删商品" else "", "processStatus": default_process_status, } ] @@ -1866,6 +1943,33 @@ class PatrolDeleteTask: @staticmethod def _short_error(message: str, limit: int = 80) -> str: compact = re.sub(r"\s+", " ", message or "").strip() + if "删除后未读取到成功提示" in compact: + return "删除后未读取到成功提示" + if any( + keyword in compact + for keyword in ("与页面的连接已断开", "target frame detached", "disconnected", "connection disconnected") + ): + return "浏览器连接中断" + if any(keyword in compact for keyword in ("stop_requested", "暂停请求", "用户取消", "任务取消", "取消任务", "interrupted")): + return "任务已中断" + if "切换国家" in compact and "失败" in compact: + return "切换国家失败" + + simplified_errors = ( + ("浏览器页面不存在", "浏览器页面不存在"), + ("当前页面对象不支持执行 JavaScript", "当前页面无法执行 JavaScript"), + ("未找到可用商品状态下拉框", "未找到商品状态下拉框"), + ("商品状态下拉框已打开但未读取到选项", "未读取到商品状态下拉选项"), + ("未读取到商品状态统计标签", "未读取到商品状态统计标签"), + ("未找到商品状态选项", "未找到商品状态选项"), + ("商品状态为空", "商品状态为空"), + ("全选商品失败", "全选商品失败"), + ("点击批量删除失败", "点击批量删除失败"), + ("未出现删除确认弹窗", "未出现删除确认弹窗"), + ) + for keyword, label in simplified_errors: + if keyword in compact: + return label if len(compact) <= limit: return compact return compact[: limit - 3] + "..." diff --git a/app/tests/test_patrol_delete.py b/app/tests/test_patrol_delete.py index b59b617..0647762 100644 --- a/app/tests/test_patrol_delete.py +++ b/app/tests/test_patrol_delete.py @@ -105,7 +105,7 @@ def test_patrol_delete_task_post_result_retries_business_failure(monkeypatch): assert len(calls) == 3 -def test_normalize_country_sections_omits_status_fields_for_complete_draft_rows(): +def test_normalize_country_sections_keeps_only_status_and_quantity_for_complete_draft_rows(): rows = PatrolDeleteTask._normalize_country_sections_for_result( [ { @@ -132,7 +132,7 @@ def test_normalize_country_sections_omits_status_fields_for_complete_draft_rows( { "country": "意大利", "rows": [ - {"quantity": "12", "deleteQuantity": "3"}, + {"status": "商品信息草稿", "quantity": "12"}, {"status": "缺少的信息", "quantity": "1", "deleteQuantity": "1", "processStatus": "已完成"}, ], } @@ -143,15 +143,15 @@ def test_replace_complete_draft_rows_uses_quick_view_rows(): country_section = { "country": "西班牙", "rows": [ - {"status": "全部", "quantity": "6230", "deleteQuantity": "0", "processStatus": "无可删商品"}, + {"status": "全部", "quantity": "6230", "deleteQuantity": "无需删除", "processStatus": "无可删商品"}, {"status": "商品信息草稿", "quantity": "", "deleteQuantity": "0", "processStatus": ""}, ], } complete_draft_section = { "country": "西班牙", "rows": [ - {"type": "completeDraft", "quantity": "1", "deleteQuantity": "1"}, - {"type": "completeDraft", "quantity": "0", "deleteQuantity": "0"}, + {"type": "completeDraft", "status": "未提交的草稿", "quantity": "1"}, + {"type": "completeDraft", "status": "已提交:提供缺少的信息", "quantity": "0"}, ], } @@ -160,18 +160,18 @@ def test_replace_complete_draft_rows_uses_quick_view_rows(): assert merged == { "country": "西班牙", "rows": [ - {"status": "全部", "quantity": "6230", "deleteQuantity": "0", "processStatus": "无可删商品"}, - {"type": "completeDraft", "quantity": "1", "deleteQuantity": "1"}, - {"type": "completeDraft", "quantity": "0", "deleteQuantity": "0"}, + {"status": "全部", "quantity": "6230", "deleteQuantity": "无需删除", "processStatus": "无可删商品"}, + {"type": "completeDraft", "status": "未提交的草稿", "quantity": "1"}, + {"type": "completeDraft", "status": "已提交:提供缺少的信息", "quantity": "0"}, ], } assert PatrolDeleteTask._normalize_country_sections_for_result([merged]) == [ { "country": "西班牙", "rows": [ - {"status": "全部", "quantity": "6230", "deleteQuantity": "0", "processStatus": "无可删商品"}, - {"quantity": "1", "deleteQuantity": "1"}, - {"quantity": "0", "deleteQuantity": "0"}, + {"status": "全部", "quantity": "6230", "deleteQuantity": "无需删除", "processStatus": "无可删商品"}, + {"status": "未提交的草稿", "quantity": "1"}, + {"status": "已提交:提供缺少的信息", "quantity": "0"}, ], } ] @@ -356,15 +356,15 @@ def test_delete_all_listings_from_non_whitelisted_statuses_deletes_each_row_in_s ] state = {"index": 0, "selected": [], "deleted": 0} - def fake_read_listing_status_rows_for_delete(shop_name, country): + def fake_read_listing_status_rows_for_delete(shop_name, country, **kwargs): current = status_rows[min(state["index"], len(status_rows) - 1)] state["index"] += 1 return current - def fake_select_listing_status(status): + def fake_select_listing_status(status, **kwargs): state["selected"].append(status) - def fake_delete_selected_filtered_inventory_rows(status): + def fake_delete_selected_filtered_inventory_rows(status, **kwargs): state["deleted"] += 2 return { "deletedCount": 2, @@ -656,10 +656,62 @@ def test_is_delete_success_message_accepts_both_copy_variants(): assert InventoryManage._is_delete_success_message("所做更改需要 15 分钟才会显示在商品详情页面上。 1 个商品已删除。") +def test_is_delete_success_message_accepts_deleted_count_without_delay_notice(): + assert InventoryManage._is_delete_success_message("1 个商品已删除。") + assert InventoryManage._is_delete_success_message("已删除 2 个商品。") + assert not InventoryManage._is_delete_success_message("0 个商品已删除。") + + +def test_is_delete_success_message_accepts_deleted_without_exact_count(): + assert InventoryManage._is_delete_success_message("一个或多个商品已删除。") + assert InventoryManage._is_delete_success_message("一个或多个商品已经删除") + assert InventoryManage._extract_delete_success_count("一个或多个商品已删除。") == 0 + + def test_extract_delete_success_count_accepts_success_message(): assert InventoryManage._extract_delete_success_count("所做更改需要 15 分钟才会显示在商品详情页面上。 3 个商品已删除。") == 3 +def test_short_error_simplifies_delete_success_message_timeout(): + message = ( + "状态[详情页面已删除]删除后未读取到成功提示: " + "['所做更改需要 15 分钟才会显示在商品详情页面上。 一个或多个商品已删除', " + "'莆田市城厢区从奇偶贸易...']" + ) + + assert PatrolDeleteTask._short_error(message) == "删除后未读取到成功提示" + + +def test_short_error_simplifies_common_patrol_delete_failures(): + examples = { + "检测到任务 12 的暂停请求,停止处理": "任务已中断", + "用户取消任务": "任务已中断", + "与页面的连接已断开: target frame detached": "浏览器连接中断", + "切换国家 德国 失败: element not found": "切换国家失败", + "未找到可用商品状态下拉框: {'ok': False, 'text': '...'}": "未找到商品状态下拉框", + "商品状态下拉框已打开但未读取到选项: {'ok': True}": "未读取到商品状态下拉选项", + "未找到商品状态选项[详情页面已删除]: {'rows': ['...']}": "未找到商品状态选项", + "状态[详情页面已删除]全选商品失败: {'ok': False, 'reason': '...'}": "全选商品失败", + "状态[详情页面已删除]点击批量删除失败: {'menuOpened': False, 'html': '...'}": "点击批量删除失败", + "状态[详情页面已删除]未出现删除确认弹窗": "未出现删除确认弹窗", + "当前页面对象不支持执行 JavaScript,无法探测 Shadow DOM": "当前页面无法执行 JavaScript", + } + + for message, expected in examples.items(): + assert PatrolDeleteTask._short_error(message) == expected + + +def test_build_country_section_result_uses_simplified_delete_timeout_failure_reason(): + section = PatrolDeleteTask._build_country_section_result( + country_name="英国", + status_rows=[{"status": "详情页面已删除", "quantity": "1"}], + delete_counts={}, + default_process_status=f"处理失败: {PatrolDeleteTask._short_error('状态[详情页面已删除]删除后未读取到成功提示: []')}", + ) + + assert section["rows"][0]["processStatus"] == "处理失败: 删除后未读取到成功提示" + + def test_build_country_section_result_sets_zero_delete_quantity_when_no_deletable_products(): section = PatrolDeleteTask._build_country_section_result( country_name="德国", @@ -670,7 +722,7 @@ def test_build_country_section_result_sets_zero_delete_quantity_when_no_deletabl assert section == { "country": "德国", - "rows": [{"status": "全部", "quantity": "12", "deleteQuantity": "0", "processStatus": "无可删商品"}], + "rows": [{"status": "全部", "quantity": "12", "deleteQuantity": "无需删除", "processStatus": "无可删商品"}], } @@ -684,10 +736,36 @@ def test_build_country_section_result_sets_zero_delete_quantity_for_empty_no_del assert section == { "country": "德国", - "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "0", "processStatus": "无可删商品"}], + "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "无需删除", "processStatus": "无可删商品"}], } +def test_build_country_section_result_marks_all_whitelisted_statuses_as_no_delete_needed(): + whitelist_statuses = [ + "全部", + "定价问题", + "需要批准", + "在搜索结果中禁止显示", + "配送问题", + "缺少报价", + "已停售", + "不可售", + "在售", + ] + + section = PatrolDeleteTask._build_country_section_result( + country_name="爱尔兰", + status_rows=[{"status": status, "quantity": "1"} for status in whitelist_statuses] + + [{"status": "缺少的信息", "quantity": "2"}], + delete_counts={"缺少的信息": 2}, + ) + + rows_by_status = {row["status"]: row for row in section["rows"]} + for status in whitelist_statuses: + assert rows_by_status[status]["deleteQuantity"] == "无需删除" + assert rows_by_status["缺少的信息"]["deleteQuantity"] == "2" + + def test_parse_label_quantity_text_accepts_complete_draft_sample(): assert InventoryManage._parse_label_quantity_text("未提交的草稿 (0)") == ("未提交的草稿", 0)