Files
crawler-plugin/app/amazon/product.py
2026-04-27 00:23:44 +08:00

1679 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
import requests
from loguru import logger
from amazon.base import AmamzonBase, UserInfo, kill_process
from amazon.tool import get_shop_info, show_notification
DROPDOWN_OPEN_DELAY = 0.8
DROPDOWN_OPTIONS_TIMEOUT = 6
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") / "product"
def _load_product_script(name: str) -> str:
return (SCRIPT_DIR / name).read_text(encoding="utf-8")
LISTING_STATUS_DROPDOWN_DEBUG_SCRIPT = _load_product_script("listing_status_dropdown_debug.js")
LISTING_STATUS_DROPDOWN_OPEN_SCRIPT = _load_product_script("listing_status_dropdown_open.js")
LISTING_STATUS_DROPDOWN_OPTIONS_SCRIPT = _load_product_script("listing_status_dropdown_options.js")
LISTING_STATUS_CLICK_VISIBLE_STATUS_SCRIPT = _load_product_script("listing_status_click_visible_status.js")
LISTING_STATUS_SELECT_OPTION_SCRIPT = _load_product_script("listing_status_select_option.js")
LISTING_STATUS_SECTION_SCRIPT = _load_product_script("listing_status_section.js")
COMPLETE_DRAFT_QUICK_VIEW_DEBUG_SCRIPT = _load_product_script("complete_draft_quick_view_debug.js")
COMPLETE_DRAFT_QUICK_VIEW_TAGS_SCRIPT = _load_product_script("complete_draft_quick_view_tags.js")
COMPLETE_DRAFT_QUICK_VIEW_DIAGNOSTIC_SCRIPT = _load_product_script("complete_draft_quick_view_diagnostic.js")
RECOMMENDED_OFFER_PERCENTAGE_OPEN_SCRIPT = _load_product_script("recommended_offer_percentage_open.js")
RECOMMENDED_OFFER_PERCENTAGE_ROWS_SCRIPT = _load_product_script("recommended_offer_percentage_rows.js")
RECOMMENDED_OFFER_PERCENTAGE_DIAGNOSTIC_SCRIPT = _load_product_script("recommended_offer_percentage_diagnostic.js")
LISTING_STATUS_SELECT_DELAY = 0.8
INVENTORY_ROW_TIMEOUT = 10
INVENTORY_LOADER_TIMEOUT = 5
DELETE_SUCCESS_TIMEOUT = 8
PRODUCT_TASK_MAX_RETRIES = 3
PRODUCT_TASK_RETRY_DELAY = 2
PRODUCT_TASK_OPEN_SHOP_DELAY = 3
PRODUCT_TASK_BROWSER_RESET_DELAY = 2
PRODUCT_TASK_MAX_UNCHANGED_CANDIDATE_ROUNDS = 3
DELETE_SUCCESS_MESSAGE_PARTS = (
re.compile(r"\d+\s*个商品(?:已删除|已经删除)"),
re.compile(r"所[作做]更改需要\s*15\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"] 出现
"""
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)
def debug_dump_listing_status_dropdown(self) -> List[Dict[str, Any]]:
"""打印商品状态下拉框候选 DOM供现场确认真实页面结构。"""
self.tab.wait.doc_loaded(raise_err=False)
candidates = self._run_js(LISTING_STATUS_DROPDOWN_DEBUG_SCRIPT) or []
logger.info("商品状态下拉框候选 DOM:\n{}", json.dumps(candidates, ensure_ascii=False, indent=2))
return candidates
def open_listing_status_dropdown(self) -> Dict[str, Any]:
"""只打开实测识别出的商品状态下拉框,不点击任何选项。"""
self.tab.wait.doc_loaded(raise_err=False)
result = self._run_js(LISTING_STATUS_DROPDOWN_OPEN_SCRIPT) or {}
if not result.get("ok"):
raise RuntimeError(f"未找到商品状态下拉框: {result}")
time.sleep(DROPDOWN_OPEN_DELAY)
return 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)
if not parsed_rows:
raise RuntimeError(f"商品状态下拉框已打开但未读取到选项: {open_result}")
return parsed_rows
def print_listing_status_json(self, shop_name: str, country: str) -> List[Dict[str, Any]]:
data = {"countrySections": self.get_listing_status_country_sections(shop_name, country)}
logger.info("商品状态 JSON:\n{}", json.dumps(data, ensure_ascii=False, indent=2))
return data["countrySections"]
def debug_dump_complete_draft_quick_view(self) -> List[Dict[str, Any]]:
"""打印补全草稿快速查看候选 DOM供现场确认真实页面结构。"""
self.tab.wait.doc_loaded(raise_err=False)
candidates = self._run_js(COMPLETE_DRAFT_QUICK_VIEW_DEBUG_SCRIPT) or []
logger.info("补全草稿快速查看候选 DOM:\n{}", json.dumps(candidates, ensure_ascii=False, indent=2))
return candidates
def debug_dump_complete_draft_diagnostics(self) -> Dict[str, Any]:
"""打印补全草稿相关 DOM 和资源 URL避免输出整页商品列表。"""
self.tab.wait.doc_loaded(raise_err=False)
diagnostics = self._run_js(COMPLETE_DRAFT_QUICK_VIEW_DIAGNOSTIC_SCRIPT) or {}
logger.info("补全草稿快速查看诊断信息:\n{}", json.dumps(diagnostics, ensure_ascii=False, indent=2))
return diagnostics
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 print_complete_draft_quick_view_json(self, shop_name: str, country: str) -> List[Dict[str, Any]]:
data = self.get_complete_draft_quick_view_tags(shop_name, country)
logger.info("补全草稿快速查看标签 JSON:\n{}", json.dumps(data, ensure_ascii=False, indent=2))
return data
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 print_recommended_offer_cart_ratios_json(self) -> Dict[str, List[Dict[str, str]]]:
data = self.get_recommended_offer_cart_ratios()
logger.info("推荐报价百分比明细 JSON:\n{}", json.dumps(data, ensure_ascii=False, indent=2))
return data
def debug_dump_recommended_offer_percentage_diagnostics(self) -> Dict[str, Any]:
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 diagnostics
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_one_listing_from_non_whitelisted_statuses(
self, shop_name: str, country: str, max_delete_count: int = 1
) -> Dict[str, Any]:
country_sections = self.get_listing_status_country_sections(shop_name, country)
status_rows = country_sections[0].get("rows") if country_sections else []
delete_candidates = self._build_deletable_status_rows(status_rows)
if not delete_candidates:
raise RuntimeError(f"商品状态中未找到可删除分类: {json.dumps(status_rows, ensure_ascii=False)}")
delete_plan = self._limit_delete_candidates(delete_candidates, max_delete_count=max_delete_count)
if not delete_plan:
raise RuntimeError(f"删除计划为空: max_delete_count={max_delete_count}")
results = []
for candidate in delete_plan:
self.select_listing_status(candidate["status"])
row = self._get_first_inventory_row(candidate["status"])
target = self._summarize_inventory_row(row)
success_message = self._delete_inventory_row(row, candidate["status"])
results.append(
{
"status": candidate["status"],
"canonicalStatus": candidate["canonicalStatus"],
"statusQuantity": candidate["quantity"],
"target": target,
"successMessage": success_message,
}
)
return {
"shopName": shop_name,
"country": country,
"statusRows": status_rows,
"deleteCandidates": delete_candidates,
"deletePlan": delete_plan,
"results": results,
}
def delete_all_listings_from_non_whitelisted_statuses(
self,
shop_name: str,
country: str,
max_delete_operations: Optional[int] = None,
) -> Dict[str, Any]:
"""循环删除非白名单分类商品,直到当前国家没有可删项。"""
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]] = []
previous_signature: Optional[tuple] = None
unchanged_candidate_rounds = 0
total_deleted = 0
stop_reason = "no-candidates"
while True:
country_sections = self.get_listing_status_country_sections(shop_name, country)
latest_status_rows = deepcopy(country_sections[0].get("rows") if country_sections else [])
latest_delete_candidates = self._build_deletable_status_rows(latest_status_rows)
if not latest_delete_candidates:
stop_reason = "no-candidates"
break
candidate_signature = tuple(
(item["canonicalStatus"], item["quantity"]) for item in latest_delete_candidates
)
if candidate_signature == previous_signature:
unchanged_candidate_rounds += 1
if unchanged_candidate_rounds >= PRODUCT_TASK_MAX_UNCHANGED_CANDIDATE_ROUNDS:
raise RuntimeError(
f"国家[{country}]删除后商品状态统计未变化,停止重试: "
f"{json.dumps(latest_delete_candidates, ensure_ascii=False)}"
)
else:
unchanged_candidate_rounds = 0
previous_signature = candidate_signature
candidate = latest_delete_candidates[0]
self.select_listing_status(candidate["status"])
row = self._get_first_inventory_row(candidate["status"])
target = self._summarize_inventory_row(row)
success_message = self._delete_inventory_row(row, candidate["status"])
result = {
"status": candidate["status"],
"canonicalStatus": candidate["canonicalStatus"],
"statusQuantity": candidate["quantity"],
"target": target,
"successMessage": success_message,
}
all_results.append(result)
delete_counts[candidate["canonicalStatus"]] = delete_counts.get(candidate["canonicalStatus"], 0) + 1
total_deleted += 1
if max_delete_operations is not None and total_deleted >= max_delete_operations:
stop_reason = "max-delete-operations"
break
self._wait_inventory_loader()
final_country_sections = self.get_listing_status_country_sections(shop_name, country)
final_status_rows = deepcopy(final_country_sections[0].get("rows") if final_country_sections else 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,
}
def select_listing_status(self, status: str) -> Dict[str, Any]:
normalized_status = self._normalize_option_text(status)
if not normalized_status:
raise RuntimeError("商品状态为空,无法执行筛选")
direct_result = self._click_visible_listing_status(normalized_status)
if direct_result.get("ok"):
time.sleep(LISTING_STATUS_SELECT_DELAY)
self._wait_inventory_loader()
return {"mode": "visible-status", **direct_result}
self.open_listing_status_dropdown()
script = f"const __crawlerPayload = {json.dumps({'status': normalized_status}, ensure_ascii=False)};\n{LISTING_STATUS_SELECT_OPTION_SCRIPT}"
result = self._run_js(script) or {}
if not result.get("ok"):
raise RuntimeError(f"未找到商品状态选项[{normalized_status}]: {result}")
time.sleep(LISTING_STATUS_SELECT_DELAY)
self._wait_inventory_loader()
return {"mode": "dropdown-option", **result}
def _click_visible_listing_status(self, status: str) -> Dict[str, Any]:
script = (
f"const __crawlerPayload = {json.dumps({'status': status}, ensure_ascii=False)};\n"
f"{LISTING_STATUS_CLICK_VISIBLE_STATUS_SCRIPT}"
)
return self._run_js(script) or {}
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}]筛选后未找到商品行")
@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 _delete_inventory_row(self, row, status: str) -> str:
try:
dropdown = row.ele('xpath:.//kat-dropdown-button[@variant="secondary"]', timeout=5)
except Exception:
dropdown = None
if not dropdown:
raise RuntimeError(f"状态[{status}]商品行缺少三点菜单")
dropdown.click()
time.sleep(1)
try:
delete_button = dropdown.sr("xpath:.//button[@role='menuitem' and @data-action='DeleteListing']")
except Exception:
delete_button = None
if not delete_button:
raise RuntimeError(f"状态[{status}]三点菜单中未找到“删除商品信息”按钮")
delete_button.wait.displayed(raise_err=False)
delete_button.click()
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()
return self._wait_delete_success_message(status)
def _wait_delete_success_message(self, status: str) -> str:
deadline = time.time() + DELETE_SUCCESS_TIMEOUT
seen_messages = []
while time.time() < deadline:
alerts = self.tab.eles("xpath://kat-alert[@variant='success' and not(@dismissed)]", timeout=2)
for alert in alerts:
text = self._extract_alert_text(alert)
if not text:
continue
if text not in seen_messages:
seen_messages.append(text)
if self._is_delete_success_message(text):
return text
time.sleep(0.3)
raise RuntimeError(f"状态[{status}]删除后未读取到成功提示: {seen_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)
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):
if self.tab is None:
raise RuntimeError("浏览器页面不存在,请先打开店铺并进入管理所有库存页面")
if hasattr(self.tab, "run_js"):
return self.tab.run_js(script)
if hasattr(self.tab, "run_js_loaded"):
return self.tab.run_js_loaded(script)
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]]) -> List[Dict[str, Any]]:
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 quantity <= 0 or canonical_status in LISTING_STATUS_DELETE_WHITELIST:
continue
candidates.append(
{
"status": status,
"canonicalStatus": canonical_status,
"quantity": quantity,
}
)
return candidates
@staticmethod
def _limit_delete_candidates(candidates: Iterable[Dict[str, Any]], max_delete_count: int = 1) -> List[Dict[str, Any]]:
if max_delete_count <= 0:
return []
return list(candidates)[:max_delete_count]
@classmethod
def _canonical_listing_status(cls, status: str) -> str:
normalized = cls._normalize_option_text(status)
return LISTING_STATUS_COMPATIBILITY_MAP.get(normalized, normalized)
@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 ProductTask:
"""巡店删除任务处理类。"""
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", [])
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)} 个店铺")
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)
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]],
):
"""处理单个店铺。"""
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)
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,
)
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"]
if driver is not None:
self._safe_close_store(driver)
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
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,
) -> 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)
driver.switch_to_manage_inventory()
delete_result = driver.delete_all_listings_from_non_whitelisted_statuses(shop_name, country_name)
latest_status_rows = delete_result.get("statusRows") or []
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 "",
)
cart_ratio = self._empty_cart_ratio(country_name)
try:
cart_ratio = 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 {
"success": True,
"countrySection": country_section,
"cartRatio": cart_ratio,
"deletedCount": delete_result.get("totalDeleted", 0),
"reopenRequired": False,
}
except Exception as exc:
self.log(f"处理国家 {country_name} 失败: {str(exc)}", "ERROR")
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": self._empty_cart_ratio(country_name),
"deletedCount": 0,
"reopenRequired": self._should_reopen_browser(str(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):
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} 失败")
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"] = country_sections or []
shop_payload["cartRatios"] = cart_ratios or []
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}")
if response.status_code == 200:
self.log(f"巡店删除结果回传成功: {shop_name}")
return
self.log(f"巡店删除结果回传失败,状态码: {response.status_code}", "WARNING")
except Exception as exc:
self.log(f"调用巡店删除结果API异常: {str(exc)}", "ERROR")
if retry < max_retries - 1:
time.sleep(2)
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
rows.append(
{
"status": status,
"quantity": InventoryManage._normalize_quantity_text(row.get("quantity")),
"deleteQuantity": str(delete_count) if delete_count > 0 else "",
"processStatus": process_status,
}
)
if not rows:
rows = [
{
"status": "全部",
"quantity": "",
"deleteQuantity": "",
"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
if __name__ == "__main__":
user_info: UserInfo = {"company": "rongchuang123", "username": "自动化_Robot", "password": "#20zsg25"}
driver = InventoryManage(user_info)
shop_name = "郭亚芳"
country = "德国"
driver.open_shop(shop_name)
driver.switch_to_country(country)
driver.print_recommended_offer_cart_ratios_json()