Merge patrol delete changes into master

This commit is contained in:
koko
2026-04-30 21:40:35 +08:00
parent 6ea3a960e7
commit 3616d64735
2 changed files with 309 additions and 127 deletions

View File

@@ -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
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 == "无可删商品":
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] + "..."

View File

@@ -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)