2289 lines
98 KiB
Python
2289 lines
98 KiB
Python
import json
|
||
import re
|
||
import time
|
||
from copy import deepcopy
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||
|
||
import requests
|
||
from loguru import logger
|
||
|
||
from amazon.base import AmamzonBase, kill_process
|
||
from amazon.tool import get_shop_info, show_notification
|
||
|
||
|
||
DROPDOWN_OPEN_DELAY = 0.8
|
||
DROPDOWN_OPTIONS_TIMEOUT = 6
|
||
LISTING_STATUS_DROPDOWN_READY_TIMEOUT = 20
|
||
QUICK_VIEW_OPEN_DELAY = 1
|
||
QUICK_VIEW_TAGS_TIMEOUT = 8
|
||
QUICK_VIEW_ENTRY_TIMEOUT = 15
|
||
COMPLETE_DRAFT_PAGE_URL = "https://sellercentral.amazon.co.uk/myinventory/inventory/views/drafts?subview=unsubmitted-drafts"
|
||
RECOMMENDED_OFFER_HOME_URL = "https://sellercentral.amazon.co.uk/home"
|
||
RECOMMENDED_OFFER_OPEN_DELAY = 1
|
||
RECOMMENDED_OFFER_ROWS_TIMEOUT = 10
|
||
RECOMMENDED_OFFER_COUNTRIES = ["欧洲", "英国", "德国", "法国", "西班牙", "意大利"]
|
||
RECOMMENDED_OFFER_EXPAND_XPATHS = [
|
||
'xpath://div[@data-key="KPI_CARD_BUYBOX"]//casino-button[@data-actionname="expand"]',
|
||
'xpath://casino-card[@id="KPI_CARD_BUYBOX"]//casino-button[@data-actionname="expand"]',
|
||
'xpath://div[@data-key="KPI_CARD_BUYBOX"]//*[@aria-label="chevron-down"]',
|
||
]
|
||
|
||
|
||
SCRIPT_DIR = Path(__file__).with_name("scripts") / "patrol_delete"
|
||
|
||
|
||
def _load_patrol_delete_script(name: str) -> str:
|
||
return (SCRIPT_DIR / name).read_text(encoding="utf-8")
|
||
|
||
|
||
def _new_trace_id(prefix: str = "trace") -> str:
|
||
return f"{prefix}-{int(time.time() * 1000)}"
|
||
|
||
|
||
def _safe_log_text(value: Any, limit: int = 2000) -> str:
|
||
try:
|
||
text = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value)
|
||
except Exception:
|
||
text = repr(value)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
return text if len(text) <= limit else text[: limit - 3] + "..."
|
||
|
||
|
||
LISTING_STATUS_DROPDOWN_OPEN_SCRIPT = _load_patrol_delete_script("listing_status_dropdown_open.js")
|
||
LISTING_STATUS_DROPDOWN_OPTIONS_SCRIPT = _load_patrol_delete_script("listing_status_dropdown_options.js")
|
||
LISTING_STATUS_DROPDOWN_CLOSE_SCRIPT = _load_patrol_delete_script("listing_status_dropdown_close.js")
|
||
LISTING_STATUS_SELECT_OPTION_SCRIPT = _load_patrol_delete_script("listing_status_select_option.js")
|
||
LISTING_STATUS_SECTION_SCRIPT = _load_patrol_delete_script("listing_status_section.js")
|
||
INVENTORY_SELECT_ALL_SCRIPT = _load_patrol_delete_script("inventory_select_all.js")
|
||
INVENTORY_DELETE_SELECTED_SCRIPT = _load_patrol_delete_script("inventory_delete_selected.js")
|
||
DELETE_CONFIRM_MODAL_CLICK_SCRIPT = _load_patrol_delete_script("delete_confirm_modal_click.js")
|
||
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")
|
||
|
||
LISTING_STATUS_SELECT_DELAY = 0.8
|
||
INVENTORY_ROW_TIMEOUT = 10
|
||
INVENTORY_LOADER_TIMEOUT = 5
|
||
DELETE_SUCCESS_TIMEOUT = 20
|
||
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*分钟才会显示在商品详情页面上"),
|
||
)
|
||
COMPLETE_DRAFT_DELETE_SUCCESS_PATTERNS = (
|
||
re.compile(r"商品信息草稿(?:已删除|已经删除)"),
|
||
re.compile(r"已删除\s*\d*\s*个?\s*商品信息草稿"),
|
||
)
|
||
LISTING_STATUS_DELETE_WHITELIST = {
|
||
"全部",
|
||
"定价问题",
|
||
"需要批准",
|
||
"在搜索结果中禁止显示",
|
||
"配送问题",
|
||
"缺少报价",
|
||
"已停售",
|
||
"不可售",
|
||
"在售",
|
||
}
|
||
LISTING_STATUS_COMPATIBILITY_MAP = {
|
||
"搜尋結果中禁止顯示": "在搜索结果中禁止显示",
|
||
"搜索结果中禁止显示": "在搜索结果中禁止显示",
|
||
"在搜索中禁止显示结果": "在搜索结果中禁止显示",
|
||
"配送問題": "配送问题",
|
||
"缺少報價": "缺少报价",
|
||
"订单页面已删除": "详情页面已删除",
|
||
}
|
||
|
||
|
||
class InventoryManage(AmamzonBase):
|
||
mark_name = "管理所有库存"
|
||
|
||
def switch_to_manage_inventory(self):
|
||
"""
|
||
切换至管理所有库存页面
|
||
1、等待 //navigation-favorites-bar[@class="hydrated"] 出现
|
||
"""
|
||
logger.info("开始切换到管理所有库存页面")
|
||
navigation = self.tab.ele('xpath://navigation-favorites-bar[@class="hydrated"]')
|
||
navigation.wait.displayed(raise_err=False)
|
||
page_btn = navigation.sr('xpath://internal-fav-bar-links[@data-internal="navigation"]').sr(
|
||
'xpath://a[@data-page-id="ezdpc-gui-inventory-mons"]'
|
||
)
|
||
page_btn.wait.displayed(raise_err=False)
|
||
page_btn.click(timeout=5)
|
||
|
||
self.tab.wait.doc_loaded()
|
||
search_region = self.tab.ele('xpath://div[@id="searchBoxContainer"]//kat-input-group')
|
||
search_region.wait.displayed(raise_err=False)
|
||
self._wait_inventory_loader(timeout=INVENTORY_LOADER_TIMEOUT)
|
||
logger.info("已进入管理所有库存页面")
|
||
|
||
def open_listing_status_dropdown(self) -> Dict[str, Any]:
|
||
"""只打开实测识别出的商品状态下拉框,不点击任何选项。"""
|
||
deadline = time.time() + LISTING_STATUS_DROPDOWN_READY_TIMEOUT
|
||
result: Dict[str, Any] = {}
|
||
while time.time() < deadline:
|
||
self.tab.wait.doc_loaded(raise_err=False)
|
||
self._wait_inventory_loader(timeout=2)
|
||
result = self._run_js(LISTING_STATUS_DROPDOWN_OPEN_SCRIPT) or {}
|
||
if result.get("ok"):
|
||
time.sleep(DROPDOWN_OPEN_DELAY)
|
||
return result
|
||
if result.get("reason") != "listing status dropdown disabled/loading":
|
||
logger.info("商品状态下拉框暂不可用,继续等待: {}", json.dumps(result, ensure_ascii=False))
|
||
time.sleep(0.5)
|
||
raise RuntimeError(f"未找到可用商品状态下拉框: {result}")
|
||
|
||
def get_listing_status_dropdown_options(self, shop_name: str, country: str) -> List[Dict[str, Any]]:
|
||
"""读取商品状态下拉框展开区域中的选项。"""
|
||
open_result = self.open_listing_status_dropdown()
|
||
option_rows = self._wait_listing_status_option_rows()
|
||
parsed_rows = self._parse_status_option_rows(option_rows, shop_name, country)
|
||
self._close_listing_status_dropdown()
|
||
if not parsed_rows:
|
||
raise RuntimeError(f"商品状态下拉框已打开但未读取到选项: {open_result}")
|
||
return parsed_rows
|
||
|
||
def open_complete_draft_quick_view(self) -> Dict[str, Any]:
|
||
"""直接打开补全草稿页面。"""
|
||
if self.tab is None:
|
||
raise RuntimeError("浏览器页面不存在,请先打开店铺")
|
||
self.tab.get(COMPLETE_DRAFT_PAGE_URL)
|
||
self.tab.wait.doc_loaded(raise_err=False)
|
||
time.sleep(QUICK_VIEW_OPEN_DELAY)
|
||
return {"ok": True, "url": COMPLETE_DRAFT_PAGE_URL, "title": getattr(self.tab, "title", "")}
|
||
|
||
def get_complete_draft_quick_view_tags(self, shop_name: str, country: str) -> List[Dict[str, Any]]:
|
||
"""读取补全草稿快速查看区域中的全部“标签 + 数量”项。"""
|
||
open_result = self.open_complete_draft_quick_view()
|
||
tag_rows = self._wait_complete_draft_quick_view_tag_rows()
|
||
parsed_rows = self._parse_quick_view_tag_rows(tag_rows, shop_name, country)
|
||
if not parsed_rows:
|
||
raise RuntimeError(f"补全草稿快速查看已打开但未读取到标签: {open_result}")
|
||
return parsed_rows
|
||
|
||
def delete_complete_drafts_from_non_whitelisted_tags(
|
||
self,
|
||
shop_name: str,
|
||
country: str,
|
||
max_delete_operations: Optional[int] = None,
|
||
trace_id: str = "",
|
||
allowed_statuses: Optional[Iterable[str]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""按补全草稿快速查看的准确标签数量,删除非白名单标签下方商品。"""
|
||
trace_id = trace_id or _new_trace_id("draft")
|
||
logger.info(
|
||
f"trace_id={trace_id} 开始删除非白名单补全草稿: "
|
||
f"shop={shop_name}, country={country}, max_delete_operations={max_delete_operations}, "
|
||
f"allowed_statuses={list(allowed_statuses) if allowed_statuses is not None else None}"
|
||
)
|
||
initial_tags = self.get_complete_draft_quick_view_tags(shop_name, country)
|
||
delete_candidates = self._build_deletable_complete_draft_tag_rows(initial_tags, allowed_statuses)
|
||
results: List[Dict[str, Any]] = []
|
||
delete_counts: Dict[str, int] = {}
|
||
total_deleted = 0
|
||
stop_reason = "no-candidates"
|
||
|
||
if not delete_candidates:
|
||
logger.info(f"trace_id={trace_id} 补全草稿没有可删除标签: shop={shop_name}, country={country}")
|
||
return {
|
||
"shopName": shop_name,
|
||
"country": country,
|
||
"draftRows": initial_tags,
|
||
"deleteCandidates": delete_candidates,
|
||
"results": results,
|
||
"deleteCounts": delete_counts,
|
||
"totalDeleted": total_deleted,
|
||
"stopReason": stop_reason,
|
||
}
|
||
|
||
stop_reason = "processed-candidates"
|
||
for candidate in delete_candidates:
|
||
tag = candidate["tag"]
|
||
quantity = candidate["quantity"]
|
||
canonical_tag = candidate["canonicalTag"]
|
||
logger.info(
|
||
f"trace_id={trace_id} 开始删除补全草稿标签下方商品: "
|
||
f"shop={shop_name}, country={country}, tag={tag}, quantity={quantity}"
|
||
)
|
||
self.select_complete_draft_tag(tag, trace_id=trace_id)
|
||
deleted_for_tag = 0
|
||
|
||
while quantity <= 0 or deleted_for_tag < quantity:
|
||
remaining_limit = None
|
||
if max_delete_operations is not None:
|
||
remaining_limit = max(0, max_delete_operations - total_deleted)
|
||
if remaining_limit <= 0:
|
||
stop_reason = "max-delete-operations"
|
||
break
|
||
select_result = self._select_all_complete_draft_rows(
|
||
tag,
|
||
trace_id=trace_id,
|
||
max_select_count=remaining_limit,
|
||
)
|
||
selected_count = int(select_result.get("selectedCount") or select_result.get("rowCount") or 0)
|
||
if select_result.get("empty"):
|
||
logger.info(
|
||
f"trace_id={trace_id} 补全草稿标签下方已无商品,跳过删除: "
|
||
f"shop={shop_name}, country={country}, tag={tag}"
|
||
)
|
||
break
|
||
if selected_count <= 0:
|
||
logger.info(
|
||
f"trace_id={trace_id} 补全草稿标签下方没有可删除商品: "
|
||
f"shop={shop_name}, country={country}, tag={tag}"
|
||
)
|
||
break
|
||
|
||
delete_result = self._click_complete_draft_delete_selected(tag, trace_id=trace_id)
|
||
self._confirm_delete_modal(tag, trace_id=trace_id)
|
||
self._wait_complete_draft_rows_after_delete(tag, select_result, trace_id=trace_id)
|
||
deleted_count = min(selected_count, quantity - deleted_for_tag) if quantity > 0 else selected_count
|
||
deleted_for_tag += deleted_count
|
||
total_deleted += deleted_count
|
||
delete_counts[canonical_tag] = delete_counts.get(canonical_tag, 0) + deleted_count
|
||
results.append(
|
||
{
|
||
"tag": tag,
|
||
"canonicalTag": canonical_tag,
|
||
"tagQuantity": quantity,
|
||
"deletedCount": deleted_count,
|
||
"selectResult": select_result,
|
||
"deleteResult": delete_result,
|
||
}
|
||
)
|
||
logger.info(
|
||
f"trace_id={trace_id} 补全草稿标签删除成功: "
|
||
f"shop={shop_name}, country={country}, tag={tag}, "
|
||
f"deleted_count={deleted_count}, tag_deleted={deleted_for_tag}, total_deleted={total_deleted}"
|
||
)
|
||
|
||
if max_delete_operations is not None and total_deleted >= max_delete_operations:
|
||
stop_reason = "max-delete-operations"
|
||
break
|
||
if quantity > 0 and deleted_for_tag >= quantity:
|
||
break
|
||
|
||
if stop_reason == "max-delete-operations":
|
||
break
|
||
|
||
final_tags = self.get_complete_draft_quick_view_tags(shop_name, country)
|
||
return {
|
||
"shopName": shop_name,
|
||
"country": country,
|
||
"draftRows": final_tags,
|
||
"deleteCandidates": delete_candidates,
|
||
"results": results,
|
||
"deleteCounts": delete_counts,
|
||
"totalDeleted": total_deleted,
|
||
"stopReason": stop_reason,
|
||
}
|
||
|
||
def select_complete_draft_tag(self, tag: str, trace_id: str = "") -> Dict[str, Any]:
|
||
normalized_tag = self._normalize_option_text(tag)
|
||
if not normalized_tag:
|
||
raise RuntimeError("补全草稿标签为空,无法执行筛选")
|
||
trace_id = trace_id or _new_trace_id("draft")
|
||
result = self._run_js(COMPLETE_DRAFT_SELECT_TAG_SCRIPT, {"tag": normalized_tag}) or {}
|
||
if not result.get("ok"):
|
||
logger.info(
|
||
f"trace_id={trace_id} 补全草稿快速查看标签未命中: "
|
||
f"tag={normalized_tag}, result={_safe_log_text(result)}"
|
||
)
|
||
raise RuntimeError(f"未找到补全草稿标签[{normalized_tag}]: {result}")
|
||
time.sleep(LISTING_STATUS_SELECT_DELAY)
|
||
return result
|
||
|
||
def _select_all_complete_draft_rows(
|
||
self,
|
||
tag: str,
|
||
trace_id: str = "",
|
||
max_select_count: Optional[int] = None,
|
||
) -> Dict[str, Any]:
|
||
trace_id = trace_id or _new_trace_id("draft")
|
||
deadline = time.time() + INVENTORY_ROW_TIMEOUT
|
||
result: Dict[str, Any] = {}
|
||
saw_empty = False
|
||
while time.time() < deadline:
|
||
payload = {"maxSelectCount": max_select_count} if max_select_count is not None else None
|
||
result = self._run_js(COMPLETE_DRAFT_SELECT_ALL_SCRIPT, payload) if payload else self._run_js(COMPLETE_DRAFT_SELECT_ALL_SCRIPT)
|
||
result = result or {}
|
||
if result.get("ok"):
|
||
time.sleep(0.5)
|
||
logger.info(f"trace_id={trace_id} 补全草稿已全选: tag={tag}, result={_safe_log_text(result)}")
|
||
return result
|
||
if result.get("reason") == "no complete draft rows":
|
||
saw_empty = True
|
||
time.sleep(0.5)
|
||
continue
|
||
if result.get("reason") and result.get("reason") != "no complete draft rows":
|
||
break
|
||
time.sleep(0.5)
|
||
|
||
if not result.get("ok"):
|
||
if saw_empty and result.get("reason") == "no complete draft rows":
|
||
logger.info(f"trace_id={trace_id} 补全草稿标签下方无商品行: tag={tag}, result={_safe_log_text(result)}")
|
||
return {**result, "ok": True, "empty": True, "selectedCount": 0, "rowCount": 0}
|
||
logger.info(
|
||
f"trace_id={trace_id} 补全草稿全选失败: tag={tag}, result={_safe_log_text(result)}"
|
||
)
|
||
raise RuntimeError(f"补全草稿标签[{tag}]全选失败: {result}")
|
||
return result
|
||
|
||
def _click_complete_draft_delete_selected(self, tag: str, trace_id: str = "") -> Dict[str, Any]:
|
||
trace_id = trace_id or _new_trace_id("draft")
|
||
result: Dict[str, Any] = {}
|
||
for attempt in range(8):
|
||
result = self._run_js(COMPLETE_DRAFT_DELETE_SELECTED_SCRIPT) or {}
|
||
if result.get("ok"):
|
||
break
|
||
if result.get("menuOpened"):
|
||
logger.info(
|
||
f"trace_id={trace_id} 补全草稿选择组操作菜单已打开,等待删除选项出现: "
|
||
f"tag={tag}, attempt={attempt + 1}, result={_safe_log_text(result)}"
|
||
)
|
||
time.sleep(0.8)
|
||
continue
|
||
break
|
||
if not result.get("ok"):
|
||
logger.info(
|
||
f"trace_id={trace_id} 补全草稿点击删除失败: tag={tag}, result={_safe_log_text(result)}"
|
||
)
|
||
raise RuntimeError(f"补全草稿标签[{tag}]点击删除失败: {result}")
|
||
time.sleep(0.5)
|
||
logger.info(f"trace_id={trace_id} 补全草稿删除按钮点击成功: tag={tag}, result={_safe_log_text(result)}")
|
||
return result
|
||
|
||
def _wait_complete_draft_rows_after_delete(
|
||
self,
|
||
tag: str,
|
||
before_result: Dict[str, Any],
|
||
trace_id: str = "",
|
||
) -> None:
|
||
trace_id = trace_id or _new_trace_id("draft")
|
||
before_count = int(before_result.get("rowCount") or 0)
|
||
deadline = time.time() + DELETE_SUCCESS_TIMEOUT
|
||
last_result: Dict[str, Any] = {}
|
||
while time.time() < deadline:
|
||
time.sleep(0.5)
|
||
result = self._run_js(COMPLETE_DRAFT_SELECT_ALL_SCRIPT, {"probeOnly": True}) or {}
|
||
last_result = result
|
||
row_count = int(result.get("rowCount") or 0)
|
||
if row_count < before_count or row_count == 0:
|
||
logger.info(
|
||
f"trace_id={trace_id} 补全草稿删除后行数已变化: "
|
||
f"tag={tag}, before={before_count}, after={row_count}"
|
||
)
|
||
return
|
||
for text in self._collect_delete_success_messages():
|
||
if self._is_complete_draft_delete_success_message(text) or self._is_delete_success_message(text):
|
||
logger.info(f"trace_id={trace_id} 补全草稿删除读取到成功提示: tag={tag}, message={_safe_log_text(text)}")
|
||
return
|
||
logger.info(
|
||
f"trace_id={trace_id} 补全草稿删除后未确认行数变化: "
|
||
f"tag={tag}, before={before_count}, last_result={_safe_log_text(last_result)}"
|
||
)
|
||
raise RuntimeError("补全草稿删除后未确认行数变化")
|
||
|
||
def open_recommended_offer_percentage_detail(self) -> Dict[str, Any]:
|
||
"""打开 Seller Central 首页中的推荐报价百分比明细。"""
|
||
if self.tab is None:
|
||
raise RuntimeError("浏览器页面不存在,请先打开店铺")
|
||
self.tab.get(RECOMMENDED_OFFER_HOME_URL)
|
||
self.tab.wait.doc_loaded(raise_err=False)
|
||
result = self._open_recommended_offer_percentage_detail_with_driver()
|
||
if not result.get("ok"):
|
||
result = self._run_js(RECOMMENDED_OFFER_PERCENTAGE_OPEN_SCRIPT) or {}
|
||
if not result.get("ok"):
|
||
raise RuntimeError(f"未找到推荐报价百分比入口: {result}")
|
||
time.sleep(RECOMMENDED_OFFER_OPEN_DELAY)
|
||
return result
|
||
|
||
def get_recommended_offer_cart_ratios(self) -> Dict[str, List[Dict[str, str]]]:
|
||
"""打开一次推荐报价百分比下拉,读取全部目标商城的明细。"""
|
||
open_result = self.open_recommended_offer_percentage_detail()
|
||
rows = self._wait_recommended_offer_percentage_rows()
|
||
cart_ratios = self._parse_all_cart_ratio_rows(rows)
|
||
|
||
incomplete = [item["country"] for item in cart_ratios if not item["ratio2DaysAgo"] or not item["ratio30DaysAgo"]]
|
||
if incomplete:
|
||
logger.info(
|
||
"推荐报价百分比未完整读取: countries={}, open_result={}, rows={}",
|
||
",".join(incomplete),
|
||
json.dumps(open_result, ensure_ascii=False),
|
||
json.dumps(rows, ensure_ascii=False),
|
||
)
|
||
|
||
return {"cartRatios": cart_ratios}
|
||
|
||
def _wait_listing_status_option_rows(self) -> List[Dict[str, Any]]:
|
||
deadline = time.time() + DROPDOWN_OPTIONS_TIMEOUT
|
||
rows = []
|
||
while time.time() < deadline:
|
||
rows = self._run_js(LISTING_STATUS_DROPDOWN_OPTIONS_SCRIPT) or []
|
||
if rows:
|
||
return rows
|
||
time.sleep(0.3)
|
||
return rows
|
||
|
||
def get_listing_status_country_sections(self, shop_name: str, country: str) -> List[Dict[str, Any]]:
|
||
rows = self._wait_listing_status_section_rows()
|
||
parsed_rows = self._parse_listing_status_section_rows(rows)
|
||
if not parsed_rows:
|
||
raise RuntimeError(f"未读取到商品状态统计标签: {rows}")
|
||
return [{"country": country, "rows": self._build_country_section_rows(parsed_rows)}]
|
||
|
||
def delete_all_listings_from_non_whitelisted_statuses(
|
||
self,
|
||
shop_name: str,
|
||
country: str,
|
||
max_delete_operations: Optional[int] = None,
|
||
allowed_statuses: Optional[Iterable[str]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""循环删除非白名单分类商品,直到当前国家没有可删项。"""
|
||
logger.info(
|
||
"开始批量删除非白名单商品: shop={}, country={}, max_delete_operations={}, allowed_statuses={}",
|
||
shop_name,
|
||
country,
|
||
max_delete_operations,
|
||
list(allowed_statuses) if allowed_statuses is not None else None,
|
||
)
|
||
all_results: List[Dict[str, Any]] = []
|
||
delete_counts: Dict[str, int] = {}
|
||
latest_status_rows: List[Dict[str, str]] = []
|
||
latest_delete_candidates: List[Dict[str, Any]] = []
|
||
total_deleted = 0
|
||
stop_reason = "no-candidates"
|
||
|
||
latest_status_rows = self._read_listing_status_rows_for_delete(shop_name, country)
|
||
latest_delete_candidates = self._build_deletable_status_rows(latest_status_rows, allowed_statuses)
|
||
if not latest_delete_candidates:
|
||
logger.info("未找到可删除商品分类: shop={}, country={}", shop_name, country)
|
||
final_status_rows = latest_status_rows
|
||
return {
|
||
"shopName": shop_name,
|
||
"country": country,
|
||
"statusRows": final_status_rows,
|
||
"deleteCandidates": latest_delete_candidates,
|
||
"results": all_results,
|
||
"deleteCounts": delete_counts,
|
||
"totalDeleted": total_deleted,
|
||
"stopReason": stop_reason,
|
||
}
|
||
|
||
logger.info(
|
||
"可删除商品分类已生成: shop={}, country={}, candidates={}",
|
||
shop_name,
|
||
country,
|
||
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"],
|
||
)
|
||
self.select_listing_status(candidate["status"])
|
||
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"])
|
||
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,
|
||
)
|
||
break
|
||
|
||
result = {
|
||
"status": candidate["status"],
|
||
"canonicalStatus": candidate["canonicalStatus"],
|
||
"statusQuantity": candidate["quantity"],
|
||
"target": bulk_result.get("target") or {},
|
||
"successMessage": bulk_result.get("successMessage", ""),
|
||
}
|
||
all_results.append(result)
|
||
delete_counts[candidate["canonicalStatus"]] = (
|
||
delete_counts.get(candidate["canonicalStatus"], 0) + deleted_count
|
||
)
|
||
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,
|
||
)
|
||
|
||
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,
|
||
)
|
||
break
|
||
|
||
self._wait_inventory_loader()
|
||
|
||
if stop_reason == "max-delete-operations":
|
||
break
|
||
logger.info(
|
||
"可删除分类处理完成: shop={}, country={}, status={}, deleted_for_status={}",
|
||
shop_name,
|
||
country,
|
||
candidate["status"],
|
||
deleted_for_status,
|
||
)
|
||
|
||
final_status_rows = self._read_listing_status_rows_for_delete(shop_name, country)
|
||
logger.info(
|
||
"批量删除非白名单商品完成: shop={}, country={}, total_deleted={}, stop_reason={}",
|
||
shop_name,
|
||
country,
|
||
total_deleted,
|
||
stop_reason,
|
||
)
|
||
|
||
return {
|
||
"shopName": shop_name,
|
||
"country": country,
|
||
"statusRows": final_status_rows,
|
||
"deleteCandidates": latest_delete_candidates,
|
||
"results": all_results,
|
||
"deleteCounts": delete_counts,
|
||
"totalDeleted": total_deleted,
|
||
"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)
|
||
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))
|
||
return rows
|
||
|
||
def select_listing_status(self, status: str) -> Dict[str, Any]:
|
||
normalized_status = self._normalize_option_text(status)
|
||
if not normalized_status:
|
||
raise RuntimeError("商品状态为空,无法执行筛选")
|
||
|
||
logger.info("开始筛选商品状态: status={}", normalized_status)
|
||
open_result = self.open_listing_status_dropdown()
|
||
option_rows = self._wait_listing_status_option_rows()
|
||
if not option_rows:
|
||
raise RuntimeError(f"商品状态下拉框已打开但未读取到选项: {open_result}")
|
||
|
||
result = self._run_js(LISTING_STATUS_SELECT_OPTION_SCRIPT, {"status": normalized_status}) or {}
|
||
if not result.get("ok"):
|
||
raise RuntimeError(f"未找到商品状态选项[{normalized_status}]: {result}")
|
||
time.sleep(LISTING_STATUS_SELECT_DELAY)
|
||
self._wait_inventory_loader()
|
||
logger.info("商品状态筛选完成: status={}, result={}", normalized_status, result)
|
||
return {"mode": "dropdown-option", **result}
|
||
|
||
def _close_listing_status_dropdown(self) -> None:
|
||
try:
|
||
self._run_js(LISTING_STATUS_DROPDOWN_CLOSE_SCRIPT)
|
||
except Exception as exc:
|
||
logger.info("关闭商品状态下拉框失败,继续执行: {}", exc)
|
||
time.sleep(0.2)
|
||
|
||
def _wait_inventory_loader(self, timeout: int = INVENTORY_LOADER_TIMEOUT) -> None:
|
||
try:
|
||
loader = self.tab.ele("xpath://div[contains(@class,'Loader-module__loader')]", timeout=1)
|
||
except Exception:
|
||
loader = None
|
||
if loader:
|
||
loader.wait.deleted(timeout=timeout, raise_err=False)
|
||
time.sleep(0.5)
|
||
|
||
def _get_first_inventory_row(self, status: str):
|
||
deadline = time.time() + INVENTORY_ROW_TIMEOUT
|
||
while time.time() < deadline:
|
||
self._wait_inventory_loader(timeout=2)
|
||
rows = self.tab.eles("xpath://div[@data-sku]", timeout=2)
|
||
if rows:
|
||
return rows[0]
|
||
time.sleep(0.4)
|
||
raise RuntimeError(f"状态[{status}]筛选后未找到商品行")
|
||
|
||
def _get_first_inventory_row_or_none(self, status: str):
|
||
try:
|
||
return self._get_first_inventory_row(status)
|
||
except RuntimeError as exc:
|
||
logger.info("状态[{}]未找到可删除商品行: {}", status, exc)
|
||
return None
|
||
|
||
def _delete_selected_filtered_inventory_rows(self, status: str) -> Dict[str, Any]:
|
||
logger.info("开始批量删除当前筛选结果: status={}", status)
|
||
row = self._get_first_inventory_row_or_none(status)
|
||
if row is None:
|
||
logger.info("当前筛选结果没有可删除商品: 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"):
|
||
raise RuntimeError(f"状态[{status}]全选商品失败: {select_result}")
|
||
logger.info(
|
||
"当前筛选结果已全选: status={}, row_count={}, first_sku={}",
|
||
status,
|
||
select_result.get("rowCount"),
|
||
target.get("sku", ""),
|
||
)
|
||
|
||
delete_result: Dict[str, Any] = {}
|
||
for attempt in range(8):
|
||
time.sleep(0.8 if attempt else 0.3)
|
||
delete_result = self._run_js(INVENTORY_DELETE_SELECTED_SCRIPT) or {}
|
||
if delete_result.get("ok"):
|
||
break
|
||
if delete_result.get("menuOpened"):
|
||
logger.info("状态[{}]已点击选择组操作,等待删除菜单展开: {}", status, delete_result)
|
||
time.sleep(1)
|
||
continue
|
||
logger.info("状态[{}]第{}次点击批量删除未命中: {}", status, attempt + 1, delete_result)
|
||
if not delete_result.get("ok"):
|
||
raise RuntimeError(f"状态[{status}]点击批量删除失败: {delete_result}")
|
||
logger.info("批量删除按钮点击成功: status={}, result={}", status, delete_result)
|
||
|
||
self._confirm_delete_modal(status)
|
||
success_message = self._wait_delete_success_message(status)
|
||
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)
|
||
return {
|
||
"deletedCount": deleted_count,
|
||
"target": {
|
||
**target,
|
||
"selectedRows": select_result.get("rowCount"),
|
||
"selectResult": select_result,
|
||
"deleteResult": delete_result,
|
||
},
|
||
"successMessage": success_message,
|
||
}
|
||
|
||
@classmethod
|
||
def _summarize_inventory_row(cls, row) -> Dict[str, str]:
|
||
sku = ""
|
||
text = ""
|
||
try:
|
||
sku = row.attr("data-sku") or ""
|
||
except Exception:
|
||
sku = ""
|
||
try:
|
||
text = cls._normalize_option_text(getattr(row, "text", "") or "")
|
||
except Exception:
|
||
text = ""
|
||
return {"sku": sku, "rawText": text[:400]}
|
||
|
||
def _confirm_delete_modal(self, status: str, trace_id: str = "") -> None:
|
||
js_result = self._click_confirm_delete_modal_with_js()
|
||
if js_result.get("ok"):
|
||
logger.info("已通过 JS 确认删除弹窗: status={}, result={}", status, js_result)
|
||
return
|
||
|
||
logger.info("JS 确认删除弹窗未命中,尝试驱动点击: status={}, result={}", status, js_result)
|
||
confirm_button = self.tab.ele(
|
||
'xpath://kat-modal[@data-testid="action-modal"]//kat-button[@variant="primary"]',
|
||
timeout=5,
|
||
)
|
||
if not confirm_button:
|
||
raise RuntimeError(f"状态[{status}]未出现删除确认弹窗")
|
||
|
||
confirm_button.wait.displayed(raise_err=False)
|
||
confirm_button.wait.enabled()
|
||
confirm_button.click()
|
||
logger.info("已通过驱动确认删除弹窗: status={}", status)
|
||
|
||
def _click_confirm_delete_modal_with_js(self) -> Dict[str, Any]:
|
||
try:
|
||
return self._run_js(DELETE_CONFIRM_MODAL_CLICK_SCRIPT) or {}
|
||
except Exception as exc:
|
||
logger.info("JS 点击删除确认按钮失败,回退到驱动点击: {}", exc)
|
||
return {"ok": False, "reason": str(exc)}
|
||
|
||
def _wait_delete_success_message(self, status: str) -> str:
|
||
deadline = time.time() + DELETE_SUCCESS_TIMEOUT
|
||
seen_messages = []
|
||
while time.time() < deadline:
|
||
for text in self._collect_delete_success_messages():
|
||
if not text:
|
||
continue
|
||
if text not in seen_messages:
|
||
seen_messages.append(text)
|
||
if self._is_delete_success_message(text):
|
||
logger.info("读取到删除成功提示: status={}, message={}", status, text)
|
||
return text
|
||
time.sleep(0.3)
|
||
raise RuntimeError(f"状态[{status}]删除后未读取到成功提示: {seen_messages}")
|
||
|
||
def _collect_delete_success_messages(self) -> List[str]:
|
||
messages: List[str] = []
|
||
try:
|
||
alerts = self.tab.eles("xpath://kat-alert[not(@dismissed)]", timeout=1)
|
||
for alert in alerts:
|
||
text = self._extract_alert_text(alert)
|
||
if text and text not in messages:
|
||
messages.append(text)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
js_messages = self._run_js(DELETE_SUCCESS_MESSAGES_SCRIPT) or []
|
||
for text in js_messages:
|
||
normalized = self._normalize_option_text(str(text or ""))
|
||
if normalized and normalized not in messages:
|
||
messages.append(normalized)
|
||
except Exception as exc:
|
||
logger.info("JS 读取删除提示失败,继续等待: {}", exc)
|
||
|
||
return messages
|
||
|
||
@classmethod
|
||
def _extract_alert_text(cls, alert) -> str:
|
||
parts = []
|
||
try:
|
||
text = cls._normalize_option_text(getattr(alert, "text", "") or "")
|
||
if text:
|
||
parts.append(text)
|
||
except Exception:
|
||
pass
|
||
|
||
for attr_name in ("description", "text", "label", "header"):
|
||
try:
|
||
value = cls._normalize_option_text(alert.attr(attr_name) or "")
|
||
except Exception:
|
||
value = ""
|
||
if value and value not in parts:
|
||
parts.append(value)
|
||
|
||
return " ".join(parts).strip()
|
||
|
||
@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)
|
||
|
||
@staticmethod
|
||
def _is_complete_draft_delete_success_message(text: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", text or "")
|
||
return any(pattern.search(normalized) for pattern in COMPLETE_DRAFT_DELETE_SUCCESS_PATTERNS)
|
||
|
||
@staticmethod
|
||
def _extract_delete_success_count(text: str) -> int:
|
||
match = re.search(r"(\d+)\s*个商品(?:已删除|已经删除)", text or "")
|
||
return int(match.group(1)) if match else 0
|
||
|
||
def _wait_complete_draft_quick_view_tag_rows(self) -> List[Dict[str, Any]]:
|
||
deadline = time.time() + QUICK_VIEW_TAGS_TIMEOUT
|
||
rows = []
|
||
last_result: Dict[str, Any] = {}
|
||
while time.time() < deadline:
|
||
result = self._run_js(COMPLETE_DRAFT_QUICK_VIEW_TAGS_SCRIPT) or {}
|
||
last_result = result
|
||
rows = result.get("rows") or []
|
||
if rows:
|
||
return rows
|
||
time.sleep(0.3)
|
||
diagnostics = self._run_js(COMPLETE_DRAFT_QUICK_VIEW_DIAGNOSTIC_SCRIPT) or {}
|
||
logger.info("补全草稿快速查看未读取到标签,最后一次 DOM 探测:\n{}", json.dumps(last_result, ensure_ascii=False, indent=2))
|
||
logger.info("补全草稿快速查看诊断信息:\n{}", json.dumps(diagnostics, ensure_ascii=False, indent=2))
|
||
return rows
|
||
|
||
def _wait_recommended_offer_percentage_rows(self) -> List[Dict[str, Any]]:
|
||
deadline = time.time() + RECOMMENDED_OFFER_ROWS_TIMEOUT
|
||
rows = []
|
||
last_result: Dict[str, Any] = {}
|
||
while time.time() < deadline:
|
||
result = self._run_js(RECOMMENDED_OFFER_PERCENTAGE_ROWS_SCRIPT) or {}
|
||
last_result = result
|
||
rows = list(result.get("rows") or [])
|
||
if self._has_complete_cart_ratio_rows(rows):
|
||
return rows
|
||
time.sleep(0.3)
|
||
logger.info("推荐报价百分比明细未读取到目标日期,最后一次 DOM 探测:\n{}", json.dumps(last_result, ensure_ascii=False, indent=2))
|
||
diagnostics = self._run_js(RECOMMENDED_OFFER_PERCENTAGE_DIAGNOSTIC_SCRIPT) or {}
|
||
compact = {
|
||
"url": diagnostics.get("url"),
|
||
"title": diagnostics.get("title"),
|
||
"buyBoxFound": diagnostics.get("buyBoxFound"),
|
||
"buyBoxLeafMatches": diagnostics.get("buyBoxLeafMatches") or [],
|
||
}
|
||
logger.info("推荐报价百分比诊断信息:\n{}", json.dumps(compact, ensure_ascii=False, indent=2))
|
||
return rows
|
||
|
||
def _run_js(self, script: str, *args):
|
||
if self.tab is None:
|
||
raise RuntimeError("浏览器页面不存在,请先打开店铺并进入管理所有库存页面")
|
||
if hasattr(self.tab, "run_js"):
|
||
return self.tab.run_js(script, *args)
|
||
if hasattr(self.tab, "run_js_loaded"):
|
||
return self.tab.run_js_loaded(script, *args)
|
||
raise RuntimeError("当前页面对象不支持执行 JavaScript,无法探测 Shadow DOM")
|
||
|
||
def _open_recommended_offer_percentage_detail_with_driver(self) -> Dict[str, Any]:
|
||
if self.tab is None:
|
||
return {"ok": False, "reason": "tab missing"}
|
||
|
||
for xpath in RECOMMENDED_OFFER_EXPAND_XPATHS:
|
||
try:
|
||
button = self.tab.ele(xpath, timeout=3)
|
||
except Exception as exc:
|
||
logger.info("定位推荐报价展开按钮失败: xpath={}, error={}", xpath, exc)
|
||
button = None
|
||
if not button:
|
||
continue
|
||
|
||
try:
|
||
button.click()
|
||
return {"ok": True, "source": "driver", "xpath": xpath}
|
||
except Exception as exc:
|
||
logger.info("点击推荐报价展开按钮失败: xpath={}, error={}", xpath, exc)
|
||
|
||
return {"ok": False, "reason": "expand button not found by driver"}
|
||
|
||
def _wait_listing_status_section_rows(self) -> List[Dict[str, Any]]:
|
||
deadline = time.time() + DROPDOWN_OPTIONS_TIMEOUT
|
||
rows = []
|
||
while time.time() < deadline:
|
||
result = self._run_js(LISTING_STATUS_SECTION_SCRIPT) or {}
|
||
rows = result.get("rows") or []
|
||
if self._has_listing_status_rows(rows):
|
||
return rows
|
||
time.sleep(0.3)
|
||
return rows
|
||
|
||
@staticmethod
|
||
def _has_listing_status_rows(rows: Iterable[Dict[str, Any]]) -> bool:
|
||
parsed_rows = [row for row in rows if (row.get("status") or "").strip()]
|
||
quantity_count = sum(1 for row in parsed_rows if str(row.get("quantity") or "").strip())
|
||
return len(parsed_rows) >= 3 and quantity_count >= 2
|
||
|
||
@classmethod
|
||
def _parse_status_option_rows(
|
||
cls, option_rows: Iterable[Dict[str, Any]], shop_name: str, country: str
|
||
) -> List[Dict[str, Any]]:
|
||
rows = []
|
||
for option in option_rows:
|
||
raw_text = cls._normalize_option_text(option.get("raw_text") or "")
|
||
value = option.get("value")
|
||
if not raw_text and not value:
|
||
continue
|
||
|
||
status, quantity = cls._parse_status_text(raw_text or str(value))
|
||
if not status:
|
||
continue
|
||
|
||
rows.append(
|
||
{
|
||
"shop_name": shop_name,
|
||
"country": country,
|
||
"status": status,
|
||
"quantity": quantity,
|
||
"value": value,
|
||
"raw_text": raw_text,
|
||
}
|
||
)
|
||
return rows
|
||
|
||
@classmethod
|
||
def _parse_listing_status_section_rows(cls, rows: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
parsed_rows = []
|
||
seen = set()
|
||
|
||
for row in rows:
|
||
status = cls._normalize_option_text(row.get("status") or "")
|
||
quantity = cls._normalize_quantity_text(row.get("quantity"))
|
||
raw_text = cls._normalize_option_text(row.get("raw_text") or "")
|
||
|
||
if not status and raw_text:
|
||
for item in cls._extract_listing_status_rows_from_text(raw_text):
|
||
key = (item["status"], item["quantity"])
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
parsed_rows.append(item)
|
||
continue
|
||
|
||
if not status:
|
||
continue
|
||
|
||
key = (status, quantity)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
parsed_rows.append({"status": status, "quantity": quantity, "raw_text": raw_text or status})
|
||
|
||
return parsed_rows
|
||
|
||
@classmethod
|
||
def _extract_listing_status_rows_from_text(cls, raw_text: str) -> List[Dict[str, Any]]:
|
||
text = cls._normalize_option_text(raw_text)
|
||
if not text:
|
||
return []
|
||
|
||
pattern = re.compile(
|
||
r"(全部|在搜索结果中禁止显示|配送问题|缺少报价|已停售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|不可售|在售)"
|
||
r"\s*(?:[((]\s*([\d,]+)\s*[))])?"
|
||
)
|
||
rows = []
|
||
for match in pattern.finditer(text):
|
||
rows.append(
|
||
{
|
||
"status": match.group(1).strip(),
|
||
"quantity": cls._normalize_quantity_text(match.group(2)),
|
||
"raw_text": cls._normalize_option_text(match.group(0)),
|
||
}
|
||
)
|
||
return rows
|
||
|
||
@staticmethod
|
||
def _normalize_quantity_text(quantity: Any) -> str:
|
||
if quantity is None:
|
||
return ""
|
||
text = re.sub(r"[^\d,]", "", str(quantity))
|
||
return text.replace(",", "")
|
||
|
||
@classmethod
|
||
def _build_country_section_rows(cls, rows: Iterable[Dict[str, Any]]) -> List[Dict[str, str]]:
|
||
return [
|
||
{
|
||
"status": cls._normalize_option_text(row.get("status") or ""),
|
||
"quantity": cls._normalize_quantity_text(row.get("quantity")),
|
||
"deleteQuantity": "",
|
||
"processStatus": "",
|
||
}
|
||
for row in rows
|
||
if cls._normalize_option_text(row.get("status") or "")
|
||
]
|
||
|
||
@classmethod
|
||
def _build_deletable_status_rows(
|
||
cls,
|
||
rows: Iterable[Dict[str, Any]],
|
||
allowed_statuses: Optional[Iterable[str]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
allowed_status_set = cls._normalize_allowed_status_set(allowed_statuses)
|
||
candidates = []
|
||
for row in rows:
|
||
status = cls._normalize_option_text(row.get("status") or "")
|
||
if not status:
|
||
continue
|
||
|
||
quantity_text = cls._normalize_quantity_text(row.get("quantity"))
|
||
quantity = int(quantity_text) if quantity_text else 0
|
||
canonical_status = cls._canonical_listing_status(status)
|
||
if allowed_status_set is not None and canonical_status not in allowed_status_set:
|
||
continue
|
||
if not quantity_text or quantity <= 0 or canonical_status in LISTING_STATUS_DELETE_WHITELIST:
|
||
continue
|
||
|
||
candidates.append(
|
||
{
|
||
"status": status,
|
||
"canonicalStatus": canonical_status,
|
||
"quantity": quantity,
|
||
}
|
||
)
|
||
return candidates
|
||
|
||
@classmethod
|
||
def _build_deletable_complete_draft_tag_rows(
|
||
cls,
|
||
rows: Iterable[Dict[str, Any]],
|
||
allowed_statuses: Optional[Iterable[str]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
allowed_status_set = cls._normalize_allowed_status_set(allowed_statuses)
|
||
complete_draft_enabled = bool(
|
||
allowed_status_set and {"补全草稿", "商品信息草稿"}.intersection(allowed_status_set)
|
||
)
|
||
candidates = []
|
||
for row in rows:
|
||
tag = cls._normalize_option_text(row.get("tag") or row.get("status") or "")
|
||
if not tag:
|
||
continue
|
||
quantity_text = cls._normalize_quantity_text(row.get("quantity"))
|
||
quantity = int(quantity_text) if quantity_text else 0
|
||
canonical_tag = cls._canonical_listing_status(tag)
|
||
if allowed_status_set is not None and not complete_draft_enabled and canonical_tag not in allowed_status_set:
|
||
continue
|
||
if not quantity_text or quantity <= 0 or canonical_tag in LISTING_STATUS_DELETE_WHITELIST:
|
||
continue
|
||
candidates.append(
|
||
{
|
||
"tag": tag,
|
||
"canonicalTag": canonical_tag,
|
||
"quantity": quantity,
|
||
}
|
||
)
|
||
return candidates
|
||
|
||
@staticmethod
|
||
def _format_deletable_candidates(candidates: Iterable[Dict[str, Any]]) -> str:
|
||
summary = []
|
||
for candidate in candidates:
|
||
status = str(candidate.get("status") or "")
|
||
quantity = candidate.get("quantity")
|
||
summary.append(f"{status}({quantity})")
|
||
return ", ".join(summary) if summary else "无"
|
||
|
||
@classmethod
|
||
def _canonical_listing_status(cls, status: str) -> str:
|
||
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()
|
||
|
||
@staticmethod
|
||
def _parse_status_text(raw_text: str) -> tuple[str, Optional[int]]:
|
||
text = re.sub(r"\s+", " ", raw_text).strip()
|
||
if not text:
|
||
return "", None
|
||
|
||
match = re.search(r"^(.*?)\s*[((]\s*([\d,]+)\s*[))]\s*$", text)
|
||
if match:
|
||
return match.group(1).strip(), int(match.group(2).replace(",", ""))
|
||
|
||
match = re.search(r"^(.*?)\s+([\d,]+)\s*$", text)
|
||
if match and not re.search(r"\d", match.group(1)):
|
||
return match.group(1).strip(), int(match.group(2).replace(",", ""))
|
||
|
||
return text, None
|
||
|
||
@classmethod
|
||
def _parse_quick_view_tag_rows(
|
||
cls, rows: Iterable[Dict[str, Any]], shop_name: str, country: str
|
||
) -> List[Dict[str, Any]]:
|
||
parsed_rows = []
|
||
seen = set()
|
||
|
||
for row in rows:
|
||
raw_text = cls._normalize_option_text(row.get("raw_text") or "")
|
||
label_text = cls._normalize_option_text(row.get("label_text") or "")
|
||
quantity_text = cls._normalize_option_text(str(row.get("quantity_text") or ""))
|
||
|
||
parsed = cls._parse_label_quantity_text(raw_text)
|
||
if parsed is None and label_text and quantity_text:
|
||
parsed = cls._parse_label_quantity_text(f"{label_text} ({quantity_text})")
|
||
if parsed is None:
|
||
continue
|
||
|
||
tag, quantity = parsed
|
||
if not cls._is_complete_draft_tag_candidate(tag):
|
||
continue
|
||
key = (tag, quantity)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
|
||
parsed_rows.append(
|
||
{
|
||
"shop_name": shop_name,
|
||
"country": country,
|
||
"tag": tag,
|
||
"quantity": quantity,
|
||
"raw_text": raw_text or f"{label_text} ({quantity_text})",
|
||
}
|
||
)
|
||
|
||
return parsed_rows
|
||
|
||
@staticmethod
|
||
def _is_complete_draft_tag_candidate(tag: str) -> bool:
|
||
normalized_tag = re.sub(r"\s+", " ", tag).strip()
|
||
if not normalized_tag:
|
||
return False
|
||
if normalized_tag == "补全草稿":
|
||
return False
|
||
if "/" in normalized_tag:
|
||
return False
|
||
if re.search(r"\b页面\b", normalized_tag):
|
||
return False
|
||
return True
|
||
|
||
@staticmethod
|
||
def _parse_label_quantity_text(raw_text: str) -> Optional[tuple[str, int]]:
|
||
text = re.sub(r"\s+", " ", raw_text).strip()
|
||
if not text:
|
||
return None
|
||
|
||
match = re.search(r"^(.*?)\s*[((]\s*([\d,]+)\s*[))]\s*$", text)
|
||
if not match:
|
||
return None
|
||
|
||
tag = match.group(1).strip()
|
||
if not tag or re.fullmatch(r"[\d,]+", tag):
|
||
return None
|
||
if re.search(r"[((]\s*[\d,]+\s*[))]", tag):
|
||
return None
|
||
|
||
return tag, int(match.group(2).replace(",", ""))
|
||
|
||
@classmethod
|
||
def _parse_cart_ratio_rows(cls, rows: Iterable[Dict[str, Any]], country: str) -> Dict[str, str]:
|
||
ratio = cls._empty_cart_ratio(country)
|
||
texts = cls._cart_ratio_texts(rows)
|
||
|
||
for text in texts:
|
||
if not ratio["ratio2DaysAgo"]:
|
||
ratio["ratio2DaysAgo"] = cls._extract_ratio_for_days(text, 2)
|
||
if not ratio["ratio30DaysAgo"]:
|
||
ratio["ratio30DaysAgo"] = cls._extract_ratio_for_days(text, 30)
|
||
if ratio["ratio2DaysAgo"] and ratio["ratio30DaysAgo"]:
|
||
return ratio
|
||
|
||
combined_text = " ".join(texts)
|
||
if not ratio["ratio2DaysAgo"]:
|
||
ratio["ratio2DaysAgo"] = cls._extract_ratio_for_days(combined_text, 2)
|
||
if not ratio["ratio30DaysAgo"]:
|
||
ratio["ratio30DaysAgo"] = cls._extract_ratio_for_days(combined_text, 30)
|
||
return ratio
|
||
|
||
@classmethod
|
||
def _parse_all_cart_ratio_rows(cls, rows: Iterable[Dict[str, Any]]) -> List[Dict[str, str]]:
|
||
rect_rows = cls._parse_rect_based_cart_ratio_rows(rows)
|
||
if rect_rows:
|
||
return rect_rows
|
||
|
||
direct_rows = cls._parse_direct_cart_ratio_rows(rows)
|
||
if direct_rows:
|
||
return direct_rows
|
||
|
||
texts = cls._cart_ratio_texts(rows)
|
||
combined_text = cls._normalize_option_text(" ".join(texts))
|
||
return [cls._parse_country_cart_ratio(texts, combined_text, country) for country in RECOMMENDED_OFFER_COUNTRIES]
|
||
|
||
@classmethod
|
||
def _parse_rect_based_cart_ratio_rows(cls, rows: Iterable[Dict[str, Any]]) -> List[Dict[str, str]]:
|
||
countries = []
|
||
percentages = []
|
||
|
||
for row in rows:
|
||
attrs = row.get("attrs") or {}
|
||
candidate = str(
|
||
attrs.get("title")
|
||
or row.get("label_text")
|
||
or row.get("raw_text")
|
||
or ""
|
||
).strip()
|
||
if not candidate or len(candidate) > 20:
|
||
continue
|
||
|
||
left = row.get("rect_left")
|
||
top = row.get("rect_top")
|
||
width = row.get("rect_width")
|
||
height = row.get("rect_height")
|
||
if not all(isinstance(value, (int, float)) for value in (left, top, width, height)):
|
||
continue
|
||
if width <= 0 or height <= 0:
|
||
continue
|
||
|
||
country_match = next(
|
||
(
|
||
target
|
||
for target in RECOMMENDED_OFFER_COUNTRIES
|
||
if any(candidate.lower() == alias.lower() for alias in cls._country_aliases(target))
|
||
),
|
||
None,
|
||
)
|
||
if country_match:
|
||
countries.append(
|
||
{
|
||
"country": country_match,
|
||
"x": float(left),
|
||
"y": float(top) + float(height) / 2,
|
||
"h": float(height),
|
||
"area": float(width) * float(height),
|
||
}
|
||
)
|
||
continue
|
||
|
||
if re.fullmatch(r"\d+(?:\.\d+)?\s*%", candidate):
|
||
percentages.append(
|
||
{
|
||
"value": cls._normalize_ratio_text(candidate),
|
||
"x": float(left),
|
||
"y": float(top) + float(height) / 2,
|
||
"area": float(width) * float(height),
|
||
}
|
||
)
|
||
|
||
if not countries or not percentages:
|
||
return []
|
||
|
||
deduped_countries = []
|
||
for item in sorted(countries, key=lambda row: (row["y"], row["x"], row["area"])):
|
||
exists = any(
|
||
row["country"] == item["country"] and abs(row["y"] - item["y"]) < 8
|
||
for row in deduped_countries
|
||
)
|
||
if not exists:
|
||
deduped_countries.append(item)
|
||
|
||
parsed = []
|
||
for row in deduped_countries:
|
||
row_y_tolerance = max(12.0, min(24.0, row["h"] / 2 + 4.0))
|
||
same_row = sorted(
|
||
(
|
||
item
|
||
for item in percentages
|
||
if item["x"] > row["x"] + 20 and abs(item["y"] - row["y"]) <= row_y_tolerance
|
||
),
|
||
key=lambda item: (item["x"], item["y"], item["area"]),
|
||
)
|
||
|
||
unique = []
|
||
for item in same_row:
|
||
if not any(abs(existing["x"] - item["x"]) < 24 for existing in unique):
|
||
unique.append(item)
|
||
|
||
parsed.append(
|
||
{
|
||
"country": row["country"],
|
||
"ratio2DaysAgo": unique[0]["value"] if len(unique) >= 1 else "",
|
||
"ratio30DaysAgo": unique[1]["value"] if len(unique) >= 2 else "",
|
||
}
|
||
)
|
||
|
||
if not any(item["ratio2DaysAgo"] or item["ratio30DaysAgo"] for item in parsed):
|
||
return []
|
||
|
||
by_country = {item["country"]: item for item in parsed}
|
||
return [by_country.get(country, cls._empty_cart_ratio(country)) for country in RECOMMENDED_OFFER_COUNTRIES]
|
||
|
||
@classmethod
|
||
def _parse_direct_cart_ratio_rows(cls, rows: Iterable[Dict[str, Any]]) -> List[Dict[str, str]]:
|
||
by_country: Dict[str, Dict[str, str]] = {}
|
||
for row in rows:
|
||
country = str(row.get("country") or "").strip()
|
||
if country not in RECOMMENDED_OFFER_COUNTRIES:
|
||
continue
|
||
|
||
ratio = by_country.setdefault(country, cls._empty_cart_ratio(country))
|
||
ratio2 = cls._normalize_ratio_text(str(row.get("ratio2DaysAgo") or "")) if row.get("ratio2DaysAgo") else ""
|
||
ratio30 = cls._normalize_ratio_text(str(row.get("ratio30DaysAgo") or "")) if row.get("ratio30DaysAgo") else ""
|
||
if ratio2 and not ratio["ratio2DaysAgo"]:
|
||
ratio["ratio2DaysAgo"] = ratio2
|
||
if ratio30 and not ratio["ratio30DaysAgo"]:
|
||
ratio["ratio30DaysAgo"] = ratio30
|
||
|
||
if not by_country:
|
||
return []
|
||
return [by_country.get(country, cls._empty_cart_ratio(country)) for country in RECOMMENDED_OFFER_COUNTRIES]
|
||
|
||
@classmethod
|
||
def _parse_country_cart_ratio(cls, texts: List[str], combined_text: str, country: str) -> Dict[str, str]:
|
||
ratio = cls._empty_cart_ratio(country)
|
||
|
||
for text in texts:
|
||
segment = cls._extract_country_segment(text, country)
|
||
if not segment:
|
||
continue
|
||
if not ratio["ratio2DaysAgo"]:
|
||
ratio["ratio2DaysAgo"] = cls._extract_ratio_for_days(segment, 2)
|
||
if not ratio["ratio30DaysAgo"]:
|
||
ratio["ratio30DaysAgo"] = cls._extract_ratio_for_days(segment, 30)
|
||
if ratio["ratio2DaysAgo"] and ratio["ratio30DaysAgo"]:
|
||
return ratio
|
||
|
||
segment = cls._extract_country_segment(combined_text, country)
|
||
if segment:
|
||
if not ratio["ratio2DaysAgo"]:
|
||
ratio["ratio2DaysAgo"] = cls._extract_ratio_for_days(segment, 2)
|
||
if not ratio["ratio30DaysAgo"]:
|
||
ratio["ratio30DaysAgo"] = cls._extract_ratio_for_days(segment, 30)
|
||
if not ratio["ratio2DaysAgo"] or not ratio["ratio30DaysAgo"]:
|
||
header_ratio = cls._extract_ratio_pair_from_country_table(segment, combined_text)
|
||
if not ratio["ratio2DaysAgo"]:
|
||
ratio["ratio2DaysAgo"] = header_ratio["ratio2DaysAgo"]
|
||
if not ratio["ratio30DaysAgo"]:
|
||
ratio["ratio30DaysAgo"] = header_ratio["ratio30DaysAgo"]
|
||
return ratio
|
||
|
||
@classmethod
|
||
def _extract_country_segment(cls, text: str, country: str) -> str:
|
||
normalized = cls._normalize_option_text(text)
|
||
if not normalized:
|
||
return ""
|
||
|
||
matches = sorted(
|
||
(
|
||
(alias, match.start(), match.end())
|
||
for alias in cls._country_aliases(country)
|
||
for match in re.finditer(re.escape(alias), normalized, flags=re.IGNORECASE)
|
||
),
|
||
key=lambda item: item[1],
|
||
)
|
||
if not matches:
|
||
return ""
|
||
|
||
all_boundaries = sorted(
|
||
(
|
||
(match.start(), match.end())
|
||
for target_country in RECOMMENDED_OFFER_COUNTRIES
|
||
for alias in cls._country_aliases(target_country)
|
||
for match in re.finditer(re.escape(alias), normalized, flags=re.IGNORECASE)
|
||
),
|
||
key=lambda item: item[0],
|
||
)
|
||
|
||
for _, start, end in matches:
|
||
next_boundary = next((boundary_start for boundary_start, _ in all_boundaries if boundary_start > start), len(normalized))
|
||
segment = normalized[start:next_boundary].strip()
|
||
if cls._extract_ratio_for_days(segment, 2) or cls._extract_ratio_for_days(segment, 30):
|
||
return segment
|
||
trailing_segment = normalized[end:next_boundary].strip()
|
||
if cls._extract_ratio_for_days(trailing_segment, 2) or cls._extract_ratio_for_days(trailing_segment, 30):
|
||
return trailing_segment
|
||
if re.search(r"\d+(?:\.\d+)?\s*%", segment):
|
||
return segment
|
||
if re.search(r"\d+(?:\.\d+)?\s*%", trailing_segment):
|
||
return trailing_segment
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _country_aliases(country: str) -> List[str]:
|
||
aliases = {
|
||
"欧洲": ["欧洲", "Europe"],
|
||
"英国": ["英国", "UK", "United Kingdom"],
|
||
"德国": ["德国", "Germany"],
|
||
"法国": ["法国", "France"],
|
||
"西班牙": ["西班牙", "Spain"],
|
||
"意大利": ["意大利", "Italy"],
|
||
}
|
||
return aliases.get(country, [country])
|
||
|
||
@classmethod
|
||
def _has_any_cart_ratio(cls, rows: Iterable[Dict[str, Any]]) -> bool:
|
||
return any(
|
||
item["ratio2DaysAgo"] or item["ratio30DaysAgo"] for item in cls._parse_all_cart_ratio_rows(rows)
|
||
)
|
||
|
||
@classmethod
|
||
def _has_complete_cart_ratio_rows(cls, rows: Iterable[Dict[str, Any]]) -> bool:
|
||
return all(
|
||
item["ratio2DaysAgo"] and item["ratio30DaysAgo"]
|
||
for item in cls._parse_all_cart_ratio_rows(rows)
|
||
)
|
||
|
||
@classmethod
|
||
def _extract_ratio_pair_from_country_table(cls, segment: str, full_text: str) -> Dict[str, str]:
|
||
ratio = {"ratio2DaysAgo": "", "ratio30DaysAgo": ""}
|
||
if not cls._looks_like_cart_ratio_table(full_text):
|
||
return ratio
|
||
|
||
percentages = [cls._normalize_ratio_text(match.group(0)) for match in re.finditer(r"\d+(?:\.\d+)?\s*%", segment)]
|
||
if len(percentages) >= 1:
|
||
ratio["ratio2DaysAgo"] = percentages[0]
|
||
if len(percentages) >= 2:
|
||
ratio["ratio30DaysAgo"] = percentages[1]
|
||
return ratio
|
||
|
||
@classmethod
|
||
def _looks_like_cart_ratio_table(cls, text: str) -> bool:
|
||
normalized = cls._normalize_option_text(text)
|
||
if not normalized:
|
||
return False
|
||
|
||
if cls._extract_ratio_for_days(normalized, 2) and cls._extract_ratio_for_days(normalized, 30):
|
||
return True
|
||
|
||
if not re.search(r"推荐报价|featured offer|buy box", normalized, flags=re.IGNORECASE):
|
||
return False
|
||
|
||
country_hits = sum(
|
||
1
|
||
for country in RECOMMENDED_OFFER_COUNTRIES
|
||
if any(alias.lower() in normalized.lower() for alias in cls._country_aliases(country))
|
||
)
|
||
if country_hits >= 2:
|
||
return True
|
||
|
||
date_patterns = [
|
||
r"\b\d{1,2}\s+[A-Za-z]{3,9}\b",
|
||
r"\b[A-Za-z]{3,9}\s+\d{1,2}\b",
|
||
r"\b\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?\b",
|
||
r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}\b",
|
||
]
|
||
return any(re.search(pattern, normalized, flags=re.IGNORECASE) for pattern in date_patterns)
|
||
|
||
@staticmethod
|
||
def _empty_cart_ratio(country: str) -> Dict[str, str]:
|
||
return {"country": country, "ratio2DaysAgo": "", "ratio30DaysAgo": ""}
|
||
|
||
@classmethod
|
||
def _cart_ratio_texts(cls, rows: Iterable[Dict[str, Any]]) -> List[str]:
|
||
texts = []
|
||
seen = set()
|
||
for row in rows:
|
||
parts = [
|
||
str(row.get("raw_text") or ""),
|
||
str(row.get("label_text") or ""),
|
||
str(row.get("percentage_text") or ""),
|
||
]
|
||
text = cls._normalize_option_text(" ".join(part for part in parts if part))
|
||
if not text or text in seen:
|
||
continue
|
||
seen.add(text)
|
||
texts.append(text)
|
||
return texts
|
||
|
||
@classmethod
|
||
def _extract_ratio_for_days(cls, raw_text: str, days: int) -> str:
|
||
text = cls._normalize_option_text(raw_text)
|
||
if not text:
|
||
return ""
|
||
|
||
day_matches = list(cls._iter_day_matches(text, days))
|
||
if not day_matches:
|
||
return ""
|
||
|
||
percent_matches = list(re.finditer(r"\d+(?:\.\d+)?\s*%", text))
|
||
if not percent_matches:
|
||
return ""
|
||
|
||
all_day_matches = sorted(
|
||
[match for target_days in (2, 30) for match in cls._iter_day_matches(text, target_days)],
|
||
key=lambda match: match.start(),
|
||
)
|
||
prefer_before = percent_matches[0].start() < all_day_matches[0].start() if all_day_matches else False
|
||
|
||
for day_match in day_matches:
|
||
next_day_start = next(
|
||
(match.start() for match in all_day_matches if match.start() > day_match.start()),
|
||
len(text),
|
||
)
|
||
previous_day_end = max(
|
||
[match.end() for match in all_day_matches if match.end() < day_match.start()],
|
||
default=0,
|
||
)
|
||
before = [match for match in percent_matches if previous_day_end <= match.end() <= day_match.start()]
|
||
after = [match for match in percent_matches if day_match.end() <= match.start() < next_day_start]
|
||
nearest_before = before[-1] if before else None
|
||
nearest_after = after[0] if after else None
|
||
if nearest_before and nearest_after:
|
||
if prefer_before:
|
||
return cls._normalize_ratio_text(nearest_before.group(0))
|
||
return cls._normalize_ratio_text(nearest_after.group(0))
|
||
if nearest_before:
|
||
return cls._normalize_ratio_text(nearest_before.group(0))
|
||
if nearest_after:
|
||
return cls._normalize_ratio_text(nearest_after.group(0))
|
||
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _iter_day_matches(text: str, days: int) -> Iterable[re.Match[str]]:
|
||
patterns = [
|
||
rf"(?<!\d){days}\s*天前",
|
||
rf"(?<!\d){days}\s*日前",
|
||
rf"(?<!\d){days}\s*days?\s*ago",
|
||
rf"(?<!\d){days}\s*d\s*ago",
|
||
]
|
||
for pattern in patterns:
|
||
yield from re.finditer(pattern, text, flags=re.IGNORECASE)
|
||
|
||
@staticmethod
|
||
def _normalize_ratio_text(text: str) -> str:
|
||
return re.sub(r"\s+", "", text)
|
||
|
||
|
||
class PatrolDeleteTask:
|
||
"""巡店删除任务处理类。"""
|
||
|
||
mark_name = "巡店删除"
|
||
|
||
def __init__(self, user_info: dict = None):
|
||
self.user_info = user_info or {}
|
||
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}")
|
||
|
||
def process_task(self, task_data: dict):
|
||
"""处理巡店删除任务主入口。"""
|
||
task_id = None
|
||
try:
|
||
data = task_data.get("data", {})
|
||
task_id = data.get("taskId")
|
||
items = data.get("items", [])
|
||
country_sections = data.get("country_sections", [])
|
||
cart_ratios = data.get("cart_ratios", [])
|
||
delete_conditions = self._resolve_delete_condition_texts(data)
|
||
|
||
if not task_id:
|
||
self.log("任务ID为空,跳过", "WARNING")
|
||
return
|
||
if not items:
|
||
self.log("店铺列表为空,跳过", "WARNING")
|
||
return
|
||
|
||
from config import runing_task
|
||
|
||
total_countries = sum(len(self._resolve_country_sections(item, country_sections)) for item in items)
|
||
runing_task[task_id] = {
|
||
"status": "running",
|
||
"start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"total_shops": len(items),
|
||
"total_countries": total_countries,
|
||
"processed_shops": 0,
|
||
"processed_countries": 0,
|
||
"success_count": 0,
|
||
"failed_count": 0,
|
||
"failed_countries": 0,
|
||
"deleted_listings_count": 0,
|
||
"stop_requested": False,
|
||
}
|
||
|
||
self.log(
|
||
f"开始处理巡店删除任务 {task_id},共 {len(items)} 个店铺,{total_countries} 个国家,"
|
||
f"删除条件={delete_conditions if delete_conditions is not None else '未指定'}"
|
||
)
|
||
for index, shop_item in enumerate(items, 1):
|
||
if runing_task[task_id].get("stop_requested", False):
|
||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理", "WARNING")
|
||
runing_task[task_id]["status"] = "stopped"
|
||
return
|
||
|
||
shop_name = shop_item.get("shopName", "未知店铺")
|
||
self.log(f"[{index}/{len(items)}] 开始处理店铺: {shop_name}")
|
||
self.process_shop(task_id, shop_item, country_sections, cart_ratios, delete_conditions)
|
||
runing_task[task_id]["processed_shops"] += 1
|
||
|
||
if runing_task[task_id].get("stop_requested", False):
|
||
runing_task[task_id]["status"] = "stopped"
|
||
self.log(f"任务 {task_id} 已被暂停!")
|
||
else:
|
||
runing_task[task_id]["status"] = "completed"
|
||
self.log(f"任务 {task_id} 处理完成!")
|
||
except Exception as exc:
|
||
import traceback
|
||
|
||
self.log(f"任务处理失败: {traceback.format_exc()}", "ERROR")
|
||
if task_id:
|
||
from config import runing_task
|
||
|
||
if task_id in runing_task:
|
||
runing_task[task_id]["status"] = "failed"
|
||
runing_task[task_id]["error"] = str(exc)
|
||
|
||
def process_shop(
|
||
self,
|
||
task_id: int,
|
||
shop_item: Dict[str, Any],
|
||
country_sections: List[Dict[str, Any]],
|
||
cart_ratios: List[Dict[str, Any]],
|
||
delete_conditions: Optional[List[str]] = None,
|
||
):
|
||
"""处理单个店铺。"""
|
||
from config import runing_shop, runing_task
|
||
|
||
shop_name = shop_item.get("shopName", "")
|
||
company_name = shop_item.get("companyName", "")
|
||
if not shop_name:
|
||
self.log("店铺名称为空,跳过", "WARNING")
|
||
return
|
||
if not company_name:
|
||
raise RuntimeError(f"店铺 {shop_name} 的公司名称为空")
|
||
|
||
runing_task[task_id]["current_shop"] = shop_name
|
||
runing_shop[shop_name] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
self.log(f"店铺 {shop_name} 已添加到执行列表")
|
||
|
||
try:
|
||
payload_country_sections = self._resolve_country_sections(shop_item, country_sections)
|
||
payload_cart_ratios = self._resolve_cart_ratios(shop_item, cart_ratios)
|
||
if not payload_country_sections:
|
||
raise RuntimeError(f"店铺 {shop_name} 缺少国家模板,无法执行巡店删除")
|
||
|
||
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)},"
|
||
f"删除条件={delete_conditions if delete_conditions is not None else '未指定'}"
|
||
)
|
||
|
||
driver: Optional[InventoryManage] = None
|
||
requires_browser_reset = False
|
||
country_results = []
|
||
for country_name in country_names:
|
||
if runing_task[task_id].get("stop_requested", False):
|
||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理店铺 {shop_name}", "WARNING")
|
||
break
|
||
|
||
if driver is None or requires_browser_reset:
|
||
if driver is not None:
|
||
self._safe_close_store(driver)
|
||
driver = self.open_shop(
|
||
max_retries=PRODUCT_TASK_MAX_RETRIES,
|
||
company_name=company_name,
|
||
shop_name=shop_name,
|
||
iskill=requires_browser_reset,
|
||
)
|
||
requires_browser_reset = False
|
||
if driver is None:
|
||
raise RuntimeError(f"店铺 {shop_name} 打开失败")
|
||
|
||
country_result = self.process_country(
|
||
driver=driver,
|
||
task_id=task_id,
|
||
shop_name=shop_name,
|
||
country_name=country_name,
|
||
delete_conditions=delete_conditions,
|
||
)
|
||
country_results.append(country_result)
|
||
aggregated_sections[country_name] = country_result["countrySection"]
|
||
aggregated_cart_ratios[country_name] = country_result["cartRatio"]
|
||
runing_task[task_id]["processed_countries"] += 1
|
||
runing_task[task_id]["deleted_listings_count"] += country_result["deletedCount"]
|
||
if not country_result["success"]:
|
||
runing_task[task_id]["failed_countries"] += 1
|
||
requires_browser_reset = country_result["reopenRequired"]
|
||
self.log(
|
||
f"国家 {country_name} 处理结束,success={country_result['success']},"
|
||
f"deleted={country_result['deletedCount']},reopenRequired={requires_browser_reset}"
|
||
)
|
||
|
||
if driver is not None:
|
||
self.log(f"店铺 {shop_name} 国家处理完成,正在关闭店铺")
|
||
self._safe_close_store(driver)
|
||
|
||
self.log(f"店铺 {shop_name} 处理结果已汇总,准备回传")
|
||
self.post_result(
|
||
task_id=task_id,
|
||
shop_name=shop_name,
|
||
country_sections=[aggregated_sections[country] for country in country_names],
|
||
cart_ratios=[aggregated_cart_ratios[country] for country in country_names],
|
||
shop_done=True,
|
||
)
|
||
if any(result["success"] for result in country_results):
|
||
runing_task[task_id]["success_count"] += 1
|
||
else:
|
||
runing_task[task_id]["failed_count"] += 1
|
||
self.log(f"店铺 {shop_name} 处理完成,成功国家数: {sum(1 for result in country_results if result['success'])}/{len(country_names)}")
|
||
except Exception as exc:
|
||
self.log(f"处理店铺 {shop_name} 失败: {str(exc)}", "ERROR")
|
||
runing_task[task_id]["failed_count"] += 1
|
||
try:
|
||
self.post_result(task_id=task_id, shop_name=shop_name, error=str(exc), shop_done=True)
|
||
except Exception as post_exc:
|
||
self.log(f"回传店铺失败结果失败: {str(post_exc)}", "ERROR")
|
||
finally:
|
||
if shop_name in runing_shop:
|
||
del runing_shop[shop_name]
|
||
self.log(f"店铺 {shop_name} 已从执行列表中移除")
|
||
|
||
def open_shop(self, max_retries: int, company_name: str, shop_name: str, iskill: bool = False) -> Optional[InventoryManage]:
|
||
error_info = ""
|
||
driver: Optional[InventoryManage] = None
|
||
browser = None
|
||
for retry in range(max_retries):
|
||
try:
|
||
self.log(f"尝试打开店铺 {shop_name} (第 {retry + 1}/{max_retries} 次)")
|
||
|
||
if iskill:
|
||
self.log("重试前先杀掉浏览器进程...")
|
||
kill_process("v6")
|
||
kill_process("v5")
|
||
time.sleep(PRODUCT_TASK_BROWSER_RESET_DELAY)
|
||
|
||
user_info = {**self.user_info, "company": company_name}
|
||
driver = InventoryManage(user_info)
|
||
browser = driver.open_shop(shop_name)
|
||
|
||
if not browser or browser == "店铺不存在":
|
||
self.log(f"打开店铺失败: {browser}", "WARNING")
|
||
driver = None
|
||
continue
|
||
|
||
if driver.need_login():
|
||
self.log(f"店铺 {shop_name} 需要登录,正在登录...")
|
||
response = get_shop_info(shop_name)
|
||
credential_payload = response.json() if response is not None else {}
|
||
password = str(((credential_payload.get("data") or {}).get("password") or "")).strip()
|
||
if not password:
|
||
message = f"获取店铺 {shop_name} 凭证失败"
|
||
self.log(message, "ERROR")
|
||
show_notification(message, "error")
|
||
driver = None
|
||
continue
|
||
|
||
if not driver.login(password):
|
||
self.log(f"店铺 {shop_name} 登录失败", "WARNING")
|
||
driver = None
|
||
continue
|
||
|
||
browser = driver.open_shop(shop_name)
|
||
if not browser or browser == "店铺不存在":
|
||
self.log(f"登录后打开店铺失败: {browser}", "WARNING")
|
||
driver = None
|
||
continue
|
||
|
||
self.log(f"成功打开店铺 {shop_name}")
|
||
return driver
|
||
except Exception as exc:
|
||
error_info = str(exc)
|
||
self.log(f"打开店铺异常: {error_info}", "ERROR")
|
||
driver = None
|
||
if retry < max_retries - 1:
|
||
time.sleep(PRODUCT_TASK_RETRY_DELAY)
|
||
|
||
if retry < max_retries - 1:
|
||
time.sleep(PRODUCT_TASK_OPEN_SHOP_DELAY)
|
||
|
||
self.log(f"店铺 {shop_name} 打开失败,已重试 {max_retries} 次,错误: {error_info}", "ERROR")
|
||
return driver
|
||
|
||
def process_country(
|
||
self,
|
||
driver: InventoryManage,
|
||
task_id: int,
|
||
shop_name: str,
|
||
country_name: str,
|
||
delete_conditions: Optional[List[str]] = None,
|
||
) -> Dict[str, Any]:
|
||
from config import runing_task
|
||
|
||
self.log(f"开始处理国家: {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._switch_country_with_retry(driver, country_name)
|
||
self.log(f"国家 {country_name} 切换完成,准备进入管理所有库存页面")
|
||
driver.switch_to_manage_inventory()
|
||
self.log(f"国家 {country_name} 已进入管理所有库存页面,开始删除非白名单商品")
|
||
|
||
delete_result = driver.delete_all_listings_from_non_whitelisted_statuses(
|
||
shop_name,
|
||
country_name,
|
||
allowed_statuses=delete_conditions,
|
||
)
|
||
latest_status_rows = delete_result.get("statusRows") or []
|
||
self.log(
|
||
f"国家 {country_name} 删除步骤完成,deleted={delete_result.get('totalDeleted', 0)},"
|
||
f"stopReason={delete_result.get('stopReason', '')}"
|
||
)
|
||
country_section = self._build_country_section_result(
|
||
country_name=country_name,
|
||
status_rows=latest_status_rows,
|
||
delete_counts=delete_result.get("deleteCounts") or {},
|
||
default_process_status="无可删商品" if delete_result.get("totalDeleted", 0) == 0 else "",
|
||
)
|
||
complete_draft_delete_result = self._delete_complete_draft_country_section_safe(
|
||
driver,
|
||
shop_name,
|
||
country_name,
|
||
delete_conditions,
|
||
)
|
||
complete_draft_section = complete_draft_delete_result.get("countrySection")
|
||
country_section = self._replace_complete_draft_rows(country_section, complete_draft_section)
|
||
|
||
cart_ratio = self._read_country_cart_ratio_safe(driver, country_name)
|
||
self.log(f"国家 {country_name} 购物车比例读取完成: {cart_ratio.get('ratio', '')}")
|
||
|
||
total_deleted = int(delete_result.get("totalDeleted") or 0) + int(
|
||
complete_draft_delete_result.get("deletedCount") or 0
|
||
)
|
||
return {
|
||
"success": True,
|
||
"countrySection": country_section,
|
||
"cartRatio": cart_ratio,
|
||
"deletedCount": total_deleted,
|
||
"reopenRequired": False,
|
||
}
|
||
except Exception as exc:
|
||
self.log(f"处理国家 {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(
|
||
country_name=country_name,
|
||
status_rows=failure_rows,
|
||
delete_counts={},
|
||
default_process_status=f"处理失败: {self._short_error(str(exc))}",
|
||
)
|
||
return {
|
||
"success": False,
|
||
"countrySection": country_section,
|
||
"cartRatio": cart_ratio,
|
||
"deletedCount": 0,
|
||
"reopenRequired": self._should_reopen_browser(str(exc)),
|
||
}
|
||
|
||
def _read_country_cart_ratio_safe(self, driver: InventoryManage, country_name: str) -> Dict[str, str]:
|
||
try:
|
||
self.log(f"开始读取国家 {country_name} 购物车比例")
|
||
return self._extract_country_cart_ratio(
|
||
driver.get_recommended_offer_cart_ratios().get("cartRatios") or [],
|
||
country_name,
|
||
)
|
||
except Exception as cart_exc:
|
||
self.log(f"读取国家 {country_name} 购物车比例失败: {str(cart_exc)}", "WARNING")
|
||
return self._empty_cart_ratio(country_name)
|
||
|
||
def _read_complete_draft_country_section_safe(
|
||
self,
|
||
driver: InventoryManage,
|
||
shop_name: str,
|
||
country_name: str,
|
||
) -> Optional[Dict[str, Any]]:
|
||
try:
|
||
self.log(f"开始读取国家 {country_name} 补全草稿")
|
||
tags = driver.get_complete_draft_quick_view_tags(shop_name, country_name)
|
||
rows = []
|
||
for item in tags:
|
||
quantity = InventoryManage._normalize_quantity_text(item.get("quantity"))
|
||
rows.append(
|
||
{
|
||
"type": "completeDraft",
|
||
"quantity": quantity,
|
||
"deleteQuantity": quantity,
|
||
}
|
||
)
|
||
self.log(f"国家 {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")
|
||
return None
|
||
|
||
def _delete_complete_draft_country_section_safe(
|
||
self,
|
||
driver: InventoryManage,
|
||
shop_name: str,
|
||
country_name: str,
|
||
delete_conditions: Optional[List[str]] = None,
|
||
) -> Dict[str, Any]:
|
||
trace_id = _new_trace_id("draft")
|
||
try:
|
||
self.log(f"trace_id={trace_id} 开始处理国家 {country_name} 补全草稿删除")
|
||
delete_result = driver.delete_complete_drafts_from_non_whitelisted_tags(
|
||
shop_name,
|
||
country_name,
|
||
trace_id=trace_id,
|
||
allowed_statuses=delete_conditions,
|
||
)
|
||
rows = []
|
||
delete_counts = delete_result.get("deleteCounts") or {}
|
||
for item in delete_result.get("draftRows") or []:
|
||
status = PatrolDeleteTask._string_result_field(item.get("tag") or item.get("status"))
|
||
canonical_status = InventoryManage._canonical_listing_status(status)
|
||
deleted_count = int(delete_counts.get(canonical_status) or 0)
|
||
rows.append(
|
||
{
|
||
"type": "completeDraft",
|
||
"status": status,
|
||
"quantity": InventoryManage._normalize_quantity_text(item.get("quantity")),
|
||
"deleteQuantity": str(deleted_count),
|
||
}
|
||
)
|
||
section = {"country": country_name, "rows": rows} if rows else None
|
||
self.log(
|
||
f"trace_id={trace_id} 国家 {country_name} 补全草稿删除处理完成: "
|
||
f"deleted={delete_result.get('totalDeleted', 0)}, rows={rows}"
|
||
)
|
||
return {
|
||
"countrySection": section,
|
||
"deletedCount": int(delete_result.get("totalDeleted") or 0),
|
||
"deleteResult": delete_result,
|
||
}
|
||
except Exception as draft_exc:
|
||
self.log(f"trace_id={trace_id} 删除国家 {country_name} 补全草稿失败: {str(draft_exc)}", "WARNING")
|
||
fallback_section = self._read_complete_draft_country_section_safe(driver, shop_name, country_name)
|
||
return {"countrySection": fallback_section, "deletedCount": 0, "error": str(draft_exc)}
|
||
|
||
def _switch_country_with_retry(self, driver: InventoryManage, country_name: str) -> None:
|
||
for retry in range(PRODUCT_TASK_MAX_RETRIES):
|
||
try:
|
||
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{PRODUCT_TASK_MAX_RETRIES} 次)")
|
||
if driver.switch_to_country(country_name):
|
||
self.log(f"成功切换到国家 {country_name}")
|
||
return
|
||
if retry < PRODUCT_TASK_MAX_RETRIES - 1:
|
||
try:
|
||
driver.tab.refresh()
|
||
except Exception:
|
||
pass
|
||
time.sleep(PRODUCT_TASK_RETRY_DELAY)
|
||
except Exception as exc:
|
||
if retry >= PRODUCT_TASK_MAX_RETRIES - 1:
|
||
raise RuntimeError(f"切换国家 {country_name} 失败: {exc}") from exc
|
||
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,
|
||
shop_name: str,
|
||
country_sections: Optional[List[Dict[str, Any]]] = None,
|
||
cart_ratios: Optional[List[Dict[str, Any]]] = None,
|
||
error: str = "",
|
||
shop_done: bool = False,
|
||
):
|
||
"""回传巡店删除结果到 Java API。"""
|
||
from config import DELETE_BRAND_API_BASE
|
||
|
||
shop_payload: Dict[str, Any] = {
|
||
"shopName": shop_name,
|
||
"shopDone": shop_done,
|
||
"submissionId": self._build_submission_id(task_id, shop_name),
|
||
"chunkIndex": 1,
|
||
"chunkTotal": 1,
|
||
}
|
||
if error:
|
||
shop_payload["error"] = error
|
||
else:
|
||
shop_payload["countrySections"] = self._normalize_country_sections_for_result(country_sections)
|
||
shop_payload["cartRatios"] = self._normalize_cart_ratios_for_result(cart_ratios)
|
||
self._validate_success_result_payload(shop_payload)
|
||
|
||
payload = {"shops": [shop_payload]}
|
||
url = f"{DELETE_BRAND_API_BASE}/api/patrol-delete/tasks/{task_id}/result"
|
||
|
||
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}")
|
||
response = requests.post(
|
||
url,
|
||
json=payload,
|
||
headers={"Content-Type": "application/json"},
|
||
timeout=30,
|
||
verify=False,
|
||
)
|
||
self.log(f"回传结果: {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)
|
||
if response.status_code == 200 and api_success:
|
||
self.log(f"巡店删除结果回传成功: {shop_name}")
|
||
return
|
||
self.log(
|
||
f"巡店删除结果回传失败,状态码: {response.status_code}, 响应: {response.text}",
|
||
"WARNING",
|
||
)
|
||
except Exception as exc:
|
||
self.log(f"调用巡店删除结果API异常: {str(exc)}", "ERROR")
|
||
|
||
if retry < max_retries - 1:
|
||
time.sleep(2)
|
||
|
||
raise RuntimeError("巡店删除结果回传最终失败")
|
||
|
||
@classmethod
|
||
def _normalize_country_sections_for_result(
|
||
cls, country_sections: Optional[List[Dict[str, Any]]]
|
||
) -> List[Dict[str, Any]]:
|
||
normalized = []
|
||
for section in country_sections or []:
|
||
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):
|
||
normalized_row = {
|
||
"status": cls._string_result_field(row.get("status")),
|
||
**normalized_row,
|
||
"processStatus": cls._string_result_field(row.get("processStatus")),
|
||
}
|
||
rows.append(normalized_row)
|
||
if country and rows:
|
||
normalized.append({"country": country, "rows": rows})
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _normalize_cart_ratios_for_result(
|
||
cart_ratios: Optional[List[Dict[str, Any]]]
|
||
) -> List[Dict[str, str]]:
|
||
normalized = []
|
||
for item in cart_ratios or []:
|
||
country = str(item.get("country") or "").strip()
|
||
if country:
|
||
normalized.append({"country": country, "ratio": PatrolDeleteTask._string_result_field(item.get("ratio"))})
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _string_result_field(value: Any) -> str:
|
||
return "" if value is None else str(value)
|
||
|
||
@staticmethod
|
||
def _is_complete_draft_result_row(row: Dict[str, Any]) -> bool:
|
||
status = InventoryManage._normalize_option_text(str(row.get("status") or ""))
|
||
tag = InventoryManage._normalize_option_text(str(row.get("tag") or ""))
|
||
row_type = str(row.get("type") or row.get("kind") or row.get("_kind") or "").strip()
|
||
return status in {"补全草稿", "商品信息草稿"} or tag == "补全草稿" or row_type == "completeDraft"
|
||
|
||
@classmethod
|
||
def _replace_complete_draft_rows(
|
||
cls,
|
||
country_section: Dict[str, Any],
|
||
complete_draft_section: Optional[Dict[str, Any]],
|
||
) -> Dict[str, Any]:
|
||
draft_rows = list((complete_draft_section or {}).get("rows") or [])
|
||
if not draft_rows:
|
||
return country_section
|
||
|
||
rows = []
|
||
replaced = False
|
||
for row in country_section.get("rows") or []:
|
||
if cls._is_complete_draft_result_row(row):
|
||
if not replaced:
|
||
rows.extend(draft_rows)
|
||
replaced = True
|
||
continue
|
||
rows.append(row)
|
||
|
||
if not replaced:
|
||
rows.extend(draft_rows)
|
||
return {**country_section, "rows": rows}
|
||
|
||
@staticmethod
|
||
def _validate_success_result_payload(shop_payload: Dict[str, Any]) -> None:
|
||
country_sections = shop_payload.get("countrySections") or []
|
||
cart_ratios = shop_payload.get("cartRatios") or []
|
||
if not country_sections:
|
||
raise RuntimeError("巡店删除成功结果缺少国家状态数据,已阻止空回传")
|
||
empty_sections = [
|
||
str(section.get("country") or "")
|
||
for section in country_sections
|
||
if not section.get("rows")
|
||
]
|
||
if empty_sections:
|
||
raise RuntimeError(f"巡店删除成功结果存在空国家状态行: {', '.join(empty_sections)}")
|
||
if not cart_ratios:
|
||
raise RuntimeError("巡店删除成功结果缺少购物车比例数据,已阻止空回传")
|
||
|
||
@staticmethod
|
||
def _resolve_country_sections(
|
||
shop_item: Dict[str, Any], country_sections: List[Dict[str, Any]]
|
||
) -> List[Dict[str, Any]]:
|
||
return deepcopy(country_sections or shop_item.get("countrySections") or [])
|
||
|
||
@staticmethod
|
||
def _resolve_cart_ratios(shop_item: Dict[str, Any], cart_ratios: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
return deepcopy(cart_ratios or shop_item.get("cartRatios") or [])
|
||
|
||
@staticmethod
|
||
def _build_submission_id(task_id: int, shop_name: str) -> str:
|
||
return f"patrol-delete:{task_id}:{shop_name}:{int(time.time() * 1000)}"
|
||
|
||
@staticmethod
|
||
def _ordered_countries(country_sections: List[Dict[str, Any]]) -> List[str]:
|
||
ordered = []
|
||
for section in country_sections:
|
||
country = str(section.get("country") or "").strip()
|
||
if country and country not in ordered:
|
||
ordered.append(country)
|
||
return ordered
|
||
|
||
@classmethod
|
||
def _build_country_section_templates(cls, country_sections: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||
templates = {}
|
||
for section in country_sections:
|
||
country = str(section.get("country") or "").strip()
|
||
if not country:
|
||
continue
|
||
rows = section.get("rows") or []
|
||
templates[country] = {
|
||
"country": country,
|
||
"rows": [
|
||
{
|
||
"status": str(row.get("status") or ""),
|
||
"quantity": str(row.get("quantity") or ""),
|
||
"deleteQuantity": str(row.get("deleteQuantity") or ""),
|
||
"processStatus": str(row.get("processStatus") or ""),
|
||
}
|
||
for row in rows
|
||
],
|
||
}
|
||
if not templates[country]["rows"]:
|
||
templates[country]["rows"] = [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]
|
||
return templates
|
||
|
||
@classmethod
|
||
def _build_cart_ratio_templates(
|
||
cls,
|
||
country_names: List[str],
|
||
cart_ratios: List[Dict[str, Any]],
|
||
) -> Dict[str, Dict[str, str]]:
|
||
ratios_by_country: Dict[str, Dict[str, str]] = {}
|
||
for item in cart_ratios:
|
||
country = str(item.get("country") or "").strip()
|
||
if not country:
|
||
continue
|
||
ratios_by_country[country] = {
|
||
"country": country,
|
||
"ratio": str(item.get("ratio") or ""),
|
||
}
|
||
for country in country_names:
|
||
ratios_by_country.setdefault(country, cls._empty_cart_ratio(country))
|
||
return ratios_by_country
|
||
|
||
@classmethod
|
||
def _build_country_section_result(
|
||
cls,
|
||
country_name: str,
|
||
status_rows: List[Dict[str, Any]],
|
||
delete_counts: Dict[str, int],
|
||
default_process_status: str = "",
|
||
) -> Dict[str, Any]:
|
||
rows = []
|
||
default_status_applied = False
|
||
for row in status_rows:
|
||
status = InventoryManage._normalize_option_text(str(row.get("status") or ""))
|
||
if not status:
|
||
continue
|
||
canonical_status = InventoryManage._canonical_listing_status(status)
|
||
delete_count = delete_counts.get(canonical_status, 0)
|
||
process_status = "已完成" if delete_count > 0 else ""
|
||
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 == "无可删商品":
|
||
delete_quantity = "0"
|
||
rows.append(
|
||
{
|
||
"status": status,
|
||
"quantity": InventoryManage._normalize_quantity_text(row.get("quantity")),
|
||
"deleteQuantity": delete_quantity,
|
||
"processStatus": process_status,
|
||
}
|
||
)
|
||
|
||
if not rows:
|
||
rows = [
|
||
{
|
||
"status": "全部",
|
||
"quantity": "",
|
||
"deleteQuantity": "0" if default_process_status == "无可删商品" else "",
|
||
"processStatus": default_process_status,
|
||
}
|
||
]
|
||
elif default_process_status and not default_status_applied and not any(row["processStatus"] for row in rows):
|
||
rows[0]["processStatus"] = default_process_status
|
||
|
||
return {"country": country_name, "rows": rows}
|
||
|
||
@classmethod
|
||
def _extract_country_cart_ratio(
|
||
cls,
|
||
cart_ratios: List[Dict[str, Any]],
|
||
country_name: str,
|
||
) -> Dict[str, str]:
|
||
for item in cart_ratios:
|
||
if str(item.get("country") or "").strip() != country_name:
|
||
continue
|
||
ratio = str(item.get("ratio2DaysAgo") or item.get("ratio") or "").strip()
|
||
return {"country": country_name, "ratio": ratio}
|
||
return cls._empty_cart_ratio(country_name)
|
||
|
||
@staticmethod
|
||
def _empty_cart_ratio(country_name: str) -> Dict[str, str]:
|
||
return {"country": country_name, "ratio": ""}
|
||
|
||
@staticmethod
|
||
def _short_error(message: str, limit: int = 80) -> str:
|
||
compact = re.sub(r"\s+", " ", message or "").strip()
|
||
if len(compact) <= limit:
|
||
return compact
|
||
return compact[: limit - 3] + "..."
|
||
|
||
@staticmethod
|
||
def _should_reopen_browser(message: str) -> bool:
|
||
return any(keyword in (message or "") for keyword in ("与页面的连接已断开", "浏览器", "target frame detached"))
|
||
|
||
@staticmethod
|
||
def _safe_close_store(driver: InventoryManage) -> None:
|
||
try:
|
||
driver.close_store()
|
||
except Exception:
|
||
pass
|