Fix patrol delete condition handling
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
@@ -20,7 +21,7 @@ except ImportError:
|
||||
STATUS_OK = "0"
|
||||
STATUS_LOGIN_FAILED = "-10003"
|
||||
|
||||
DEFAULT_SOCKET_PORT = 19890
|
||||
DEFAULT_SOCKET_PORT = 20000
|
||||
CLIENT_API_TIMEOUT = 120
|
||||
PORT_CHECK_TIMEOUT = 2
|
||||
UPDATE_CORE_RETRY_DELAY = 2
|
||||
@@ -30,6 +31,7 @@ CLIENT_READY_TIMEOUT = 10
|
||||
CLIENT_READY_INTERVAL = 0.5
|
||||
CLIENT_POST_START_DELAY = 5
|
||||
PROCESS_KILL_DELAY = 3
|
||||
ZINIAO_WEBDRIVER_LOG_TAIL_LINES = 80
|
||||
|
||||
DOC_LOAD_TIMEOUT = 30
|
||||
COUNTRY_INITIAL_LOAD_TIMEOUT = 120
|
||||
@@ -152,6 +154,49 @@ class ZiniaoDriver:
|
||||
time.sleep(CLIENT_READY_INTERVAL)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _redact_ziniao_log_line(line: str) -> str:
|
||||
return re.sub(r'("password"\s*:\s*)"[^"]*"', r'\1"***"', line)
|
||||
|
||||
@staticmethod
|
||||
def _ziniao_webdriver_log_path() -> Optional[str]:
|
||||
appdata = os.getenv("APPDATA")
|
||||
if not appdata:
|
||||
return None
|
||||
filename = f"webdriver.{time.strftime('%Y%m%d')}.log"
|
||||
return os.path.join(
|
||||
appdata,
|
||||
"ziniaobrowser",
|
||||
"instances",
|
||||
"userdata1",
|
||||
"logs",
|
||||
"client",
|
||||
filename,
|
||||
)
|
||||
|
||||
def _log_ziniao_webdriver_tail(self) -> None:
|
||||
log_path = self._ziniao_webdriver_log_path()
|
||||
if not log_path:
|
||||
logger.warning("无法读取紫鸟 webdriver 日志: APPDATA 环境变量为空")
|
||||
return
|
||||
if not os.path.exists(log_path):
|
||||
logger.warning("紫鸟 webdriver 日志不存在:{}", log_path)
|
||||
return
|
||||
|
||||
try:
|
||||
with open(log_path, "r", encoding="utf-8", errors="replace") as log_file:
|
||||
lines = log_file.readlines()
|
||||
except OSError as exc:
|
||||
logger.warning("读取紫鸟 webdriver 日志失败:{} | {}", log_path, exc)
|
||||
return
|
||||
|
||||
tail_lines = lines[-ZINIAO_WEBDRIVER_LOG_TAIL_LINES:]
|
||||
tail_text = "".join(self._redact_ziniao_log_line(line) for line in tail_lines).strip()
|
||||
if tail_text:
|
||||
logger.error("紫鸟 webdriver 日志尾部({}):\n{}", log_path, tail_text)
|
||||
else:
|
||||
logger.warning("紫鸟 webdriver 日志为空:{}", log_path)
|
||||
|
||||
def get_zinaio_exe(self, protocol_name: str = "superbrowser"):
|
||||
"""从 Windows 注册表读取紫鸟客户端可执行文件路径.
|
||||
|
||||
@@ -402,7 +447,7 @@ class ZiniaoDriver:
|
||||
|
||||
for retry_count in range(CLIENT_START_RETRIES):
|
||||
logger.info("第 {}/{} 次尝试启动紫鸟客户端", retry_count + 1, CLIENT_START_RETRIES)
|
||||
subprocess.Popen(cmd)
|
||||
process = subprocess.Popen(cmd)
|
||||
|
||||
if self._wait_until_client_ready(CLIENT_READY_TIMEOUT):
|
||||
logger.info("紫鸟客户端启动成功,第 {} 次尝试", retry_count + 1)
|
||||
@@ -411,6 +456,9 @@ class ZiniaoDriver:
|
||||
return
|
||||
|
||||
logger.warning("第 {} 次尝试启动失败,10秒内未检测到客户端启动", retry_count + 1)
|
||||
if process.poll() is not None:
|
||||
logger.warning("紫鸟客户端进程已退出,returncode={}", process.returncode)
|
||||
self._log_ziniao_webdriver_tail()
|
||||
|
||||
logger.error("紫鸟客户端启动失败,已重试 {} 次", CLIENT_START_RETRIES)
|
||||
raise RuntimeError(f"客户端启动失败:重试 {CLIENT_START_RETRIES} 次后仍未成功启动")
|
||||
|
||||
@@ -4,7 +4,7 @@ import time
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
@@ -38,6 +38,19 @@ 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")
|
||||
@@ -49,6 +62,9 @@ DELETE_CONFIRM_MODAL_CLICK_SCRIPT = _load_patrol_delete_script("delete_confirm_m
|
||||
DELETE_SUCCESS_MESSAGES_SCRIPT = _load_patrol_delete_script("delete_success_messages.js")
|
||||
COMPLETE_DRAFT_QUICK_VIEW_TAGS_SCRIPT = _load_patrol_delete_script("complete_draft_quick_view_tags.js")
|
||||
COMPLETE_DRAFT_QUICK_VIEW_DIAGNOSTIC_SCRIPT = _load_patrol_delete_script("complete_draft_quick_view_diagnostic.js")
|
||||
COMPLETE_DRAFT_SELECT_TAG_SCRIPT = _load_patrol_delete_script("complete_draft_select_tag.js")
|
||||
COMPLETE_DRAFT_SELECT_ALL_SCRIPT = _load_patrol_delete_script("complete_draft_select_all.js")
|
||||
COMPLETE_DRAFT_DELETE_SELECTED_SCRIPT = _load_patrol_delete_script("complete_draft_delete_selected.js")
|
||||
RECOMMENDED_OFFER_PERCENTAGE_OPEN_SCRIPT = _load_patrol_delete_script("recommended_offer_percentage_open.js")
|
||||
RECOMMENDED_OFFER_PERCENTAGE_ROWS_SCRIPT = _load_patrol_delete_script("recommended_offer_percentage_rows.js")
|
||||
RECOMMENDED_OFFER_PERCENTAGE_DIAGNOSTIC_SCRIPT = _load_patrol_delete_script("recommended_offer_percentage_diagnostic.js")
|
||||
@@ -65,6 +81,10 @@ 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 = {
|
||||
"全部",
|
||||
"定价问题",
|
||||
@@ -153,6 +173,229 @@ class InventoryManage(AmamzonBase):
|
||||
raise RuntimeError(f"补全草稿快速查看已打开但未读取到标签: {open_result}")
|
||||
return parsed_rows
|
||||
|
||||
def delete_complete_drafts_from_non_whitelisted_tags(
|
||||
self,
|
||||
shop_name: str,
|
||||
country: str,
|
||||
max_delete_operations: Optional[int] = None,
|
||||
trace_id: str = "",
|
||||
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:
|
||||
@@ -206,13 +449,15 @@ class InventoryManage(AmamzonBase):
|
||||
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={}",
|
||||
"开始批量删除非白名单商品: 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] = {}
|
||||
@@ -222,7 +467,7 @@ class InventoryManage(AmamzonBase):
|
||||
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)
|
||||
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
|
||||
@@ -456,7 +701,7 @@ class InventoryManage(AmamzonBase):
|
||||
text = ""
|
||||
return {"sku": sku, "rawText": text[:400]}
|
||||
|
||||
def _confirm_delete_modal(self, status: str) -> None:
|
||||
def _confirm_delete_modal(self, status: str, trace_id: str = "") -> None:
|
||||
js_result = self._click_confirm_delete_modal_with_js()
|
||||
if js_result.get("ok"):
|
||||
logger.info("已通过 JS 确认删除弹窗: status={}, result={}", status, js_result)
|
||||
@@ -544,6 +789,11 @@ class InventoryManage(AmamzonBase):
|
||||
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 "")
|
||||
@@ -733,7 +983,12 @@ class InventoryManage(AmamzonBase):
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _build_deletable_status_rows(cls, rows: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
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 "")
|
||||
@@ -743,6 +998,8 @@ class InventoryManage(AmamzonBase):
|
||||
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
|
||||
|
||||
@@ -755,6 +1012,37 @@ class InventoryManage(AmamzonBase):
|
||||
)
|
||||
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 = []
|
||||
@@ -769,6 +1057,21 @@ class InventoryManage(AmamzonBase):
|
||||
normalized = cls._normalize_option_text(status)
|
||||
return LISTING_STATUS_COMPATIBILITY_MAP.get(normalized, normalized)
|
||||
|
||||
@classmethod
|
||||
def _normalize_allowed_status_set(cls, statuses: Optional[Iterable[str]]) -> Optional[Set[str]]:
|
||||
if statuses is None:
|
||||
return None
|
||||
allowed = set()
|
||||
for status in statuses:
|
||||
text = cls._normalize_option_text(str(status or ""))
|
||||
if not text:
|
||||
continue
|
||||
for part in re.split(r"[,,;;、\r\n]+", text):
|
||||
normalized = cls._normalize_option_text(part)
|
||||
if normalized:
|
||||
allowed.add(cls._canonical_listing_status(normalized))
|
||||
return allowed
|
||||
|
||||
@staticmethod
|
||||
def _normalize_option_text(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
@@ -1249,6 +1552,7 @@ class PatrolDeleteTask:
|
||||
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")
|
||||
@@ -1274,7 +1578,10 @@ class PatrolDeleteTask:
|
||||
"stop_requested": False,
|
||||
}
|
||||
|
||||
self.log(f"开始处理巡店删除任务 {task_id},共 {len(items)} 个店铺,{total_countries} 个国家")
|
||||
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")
|
||||
@@ -1283,7 +1590,7 @@ class PatrolDeleteTask:
|
||||
|
||||
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)
|
||||
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):
|
||||
@@ -1309,6 +1616,7 @@ class PatrolDeleteTask:
|
||||
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
|
||||
@@ -1334,7 +1642,10 @@ class PatrolDeleteTask:
|
||||
country_names = self._ordered_countries(payload_country_sections)
|
||||
aggregated_sections = self._build_country_section_templates(payload_country_sections)
|
||||
aggregated_cart_ratios = self._build_cart_ratio_templates(country_names, payload_cart_ratios)
|
||||
self.log(f"店铺 {shop_name} 待处理国家: {', '.join(country_names)}")
|
||||
self.log(
|
||||
f"店铺 {shop_name} 待处理国家: {', '.join(country_names)},"
|
||||
f"删除条件={delete_conditions if delete_conditions is not None else '未指定'}"
|
||||
)
|
||||
|
||||
driver: Optional[InventoryManage] = None
|
||||
requires_browser_reset = False
|
||||
@@ -1362,6 +1673,7 @@ class PatrolDeleteTask:
|
||||
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"]
|
||||
@@ -1472,6 +1784,7 @@ class PatrolDeleteTask:
|
||||
task_id: int,
|
||||
shop_name: str,
|
||||
country_name: str,
|
||||
delete_conditions: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
from config import runing_task
|
||||
|
||||
@@ -1487,7 +1800,11 @@ class PatrolDeleteTask:
|
||||
driver.switch_to_manage_inventory()
|
||||
self.log(f"国家 {country_name} 已进入管理所有库存页面,开始删除非白名单商品")
|
||||
|
||||
delete_result = driver.delete_all_listings_from_non_whitelisted_statuses(shop_name, country_name)
|
||||
delete_result = driver.delete_all_listings_from_non_whitelisted_statuses(
|
||||
shop_name,
|
||||
country_name,
|
||||
allowed_statuses=delete_conditions,
|
||||
)
|
||||
latest_status_rows = delete_result.get("statusRows") or []
|
||||
self.log(
|
||||
f"国家 {country_name} 删除步骤完成,deleted={delete_result.get('totalDeleted', 0)},"
|
||||
@@ -1499,17 +1816,26 @@ class PatrolDeleteTask:
|
||||
delete_counts=delete_result.get("deleteCounts") or {},
|
||||
default_process_status="无可删商品" if delete_result.get("totalDeleted", 0) == 0 else "",
|
||||
)
|
||||
complete_draft_section = self._read_complete_draft_country_section_safe(driver, shop_name, country_name)
|
||||
complete_draft_delete_result = self._delete_complete_draft_country_section_safe(
|
||||
driver,
|
||||
shop_name,
|
||||
country_name,
|
||||
delete_conditions,
|
||||
)
|
||||
complete_draft_section = complete_draft_delete_result.get("countrySection")
|
||||
country_section = self._replace_complete_draft_rows(country_section, complete_draft_section)
|
||||
|
||||
cart_ratio = self._read_country_cart_ratio_safe(driver, country_name)
|
||||
self.log(f"国家 {country_name} 购物车比例读取完成: {cart_ratio.get('ratio', '')}")
|
||||
|
||||
total_deleted = int(delete_result.get("totalDeleted") or 0) + int(
|
||||
complete_draft_delete_result.get("deletedCount") or 0
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"countrySection": country_section,
|
||||
"cartRatio": cart_ratio,
|
||||
"deletedCount": delete_result.get("totalDeleted", 0),
|
||||
"deletedCount": total_deleted,
|
||||
"reopenRequired": False,
|
||||
}
|
||||
except Exception as exc:
|
||||
@@ -1566,6 +1892,51 @@ class PatrolDeleteTask:
|
||||
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:
|
||||
@@ -1585,6 +1956,41 @@ class PatrolDeleteTask:
|
||||
time.sleep(PRODUCT_TASK_RETRY_DELAY)
|
||||
raise RuntimeError(f"切换国家 {country_name} 失败")
|
||||
|
||||
@classmethod
|
||||
def _resolve_delete_condition_texts(cls, data: Dict[str, Any]) -> Optional[List[str]]:
|
||||
if "delete_conditions" in data:
|
||||
raw_conditions = data.get("delete_conditions")
|
||||
elif "deleteConditions" in data:
|
||||
raw_conditions = data.get("deleteConditions")
|
||||
else:
|
||||
return []
|
||||
return cls._parse_delete_condition_texts(raw_conditions)
|
||||
|
||||
@classmethod
|
||||
def _parse_delete_condition_texts(cls, raw_conditions: Any) -> List[str]:
|
||||
if raw_conditions is None:
|
||||
return []
|
||||
if not isinstance(raw_conditions, list):
|
||||
raw_conditions = [raw_conditions]
|
||||
|
||||
texts: List[str] = []
|
||||
for item in raw_conditions:
|
||||
text = ""
|
||||
if isinstance(item, dict):
|
||||
for key in ("conditionText", "condition_text", "status", "text", "label", "name"):
|
||||
value = item.get(key)
|
||||
if value:
|
||||
text = str(value)
|
||||
break
|
||||
elif item is not None:
|
||||
text = str(item)
|
||||
|
||||
for part in re.split(r"[,,;;、\r\n]+", text):
|
||||
normalized = InventoryManage._normalize_option_text(part)
|
||||
if normalized and normalized not in texts:
|
||||
texts.append(normalized)
|
||||
return texts
|
||||
|
||||
def post_result(
|
||||
self,
|
||||
task_id: int,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
function textOf(node) {
|
||||
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el || !el.getBoundingClientRect) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
function clickElement(el) {
|
||||
el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
if (el.focus) el.focus();
|
||||
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
|
||||
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
el.click();
|
||||
}
|
||||
|
||||
function summarize(el) {
|
||||
return {
|
||||
tag: el.tagName,
|
||||
text: textOf(el).slice(0, 500),
|
||||
attrs: Array.from(el.attributes || []).reduce((acc, attr) => {
|
||||
acc[attr.name] = attr.value;
|
||||
return acc;
|
||||
}, {}),
|
||||
outerHTML: (el.outerHTML || '').slice(0, 1000),
|
||||
};
|
||||
}
|
||||
|
||||
const view = document.querySelector('[data-testid="complete-drafts-view"]');
|
||||
if (!view) return { ok: false, reason: 'complete drafts view not found' };
|
||||
|
||||
const selectedRows = Array.from(view.querySelectorAll('.ag-center-cols-container [role="row"]'))
|
||||
.filter((row) => isVisible(row) && (row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')));
|
||||
if (!selectedRows.length) return { ok: false, reason: 'no selected complete draft rows' };
|
||||
|
||||
const bulkDropdown = view.querySelector('#bulk-dropdown');
|
||||
if (!bulkDropdown) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'bulk dropdown not found',
|
||||
selectedCount: selectedRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
const toggle = bulkDropdown.shadowRoot && bulkDropdown.shadowRoot.querySelector('button[part="dropdown-button-toggle-button"], button');
|
||||
clickElement(toggle || bulkDropdown);
|
||||
|
||||
function findBulkDeleteAction() {
|
||||
const all = [];
|
||||
const seen = new Set();
|
||||
function walk(root) {
|
||||
if (!root || seen.has(root)) return;
|
||||
seen.add(root);
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) all.push(node);
|
||||
if (node.shadowRoot) walk(node.shadowRoot);
|
||||
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
|
||||
for (const assigned of node.assignedElements({ flatten: true })) walk(assigned);
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
}
|
||||
walk(document);
|
||||
const exactAction = all.find((el) => {
|
||||
const action = String(el.getAttribute && el.getAttribute('data-action') || '');
|
||||
const text = textOf(el);
|
||||
return isVisible(el) && action === 'DELETE_DRAFTS' && /删除商品信息草稿/.test(text);
|
||||
});
|
||||
if (exactAction) return exactAction;
|
||||
|
||||
return all.find((el) => {
|
||||
const text = textOf(el);
|
||||
const attrs = Array.from(el.attributes || []).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
||||
const haystack = `${text} ${attrs}`;
|
||||
if (!isVisible(el) || !/删除商品信息草稿/.test(haystack)) return false;
|
||||
if (/^(HTML|BODY|KAT-DATA-GRID)$/i.test(el.tagName || '') && text.length > 80) return false;
|
||||
if (/^DIV$/i.test(el.tagName || '') && text !== '删除商品信息草稿') return false;
|
||||
if ((el.tagName || '').toLowerCase() === 'kat-icon') return false;
|
||||
return /^(BUTTON|KAT-BUTTON|KAT-OPTION|KAT-DROPDOWN-OPTION|LI|A|SPAN|DIV)$/i.test(el.tagName || '');
|
||||
});
|
||||
}
|
||||
|
||||
const deleteAction = findBulkDeleteAction();
|
||||
|
||||
if (!deleteAction) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
function walkCandidates(root) {
|
||||
if (!root || seen.has(root)) return;
|
||||
seen.add(root);
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.tagName && isVisible(node)) {
|
||||
const text = textOf(node);
|
||||
const attrs = Array.from(node.attributes || []).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
||||
if (/删除|草稿|选择组操作|menu|option/i.test(`${text} ${attrs}`)) candidates.push(summarize(node));
|
||||
}
|
||||
if (node.shadowRoot) walkCandidates(node.shadowRoot);
|
||||
node = walker.nextNode();
|
||||
}
|
||||
}
|
||||
walkCandidates(document);
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'bulk delete draft action not found',
|
||||
menuOpened: true,
|
||||
selectedCount: selectedRows.length,
|
||||
bulkDropdown: summarize(bulkDropdown),
|
||||
candidates: candidates.slice(0, 50),
|
||||
};
|
||||
}
|
||||
|
||||
clickElement(deleteAction);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
selectedCount: selectedRows.length,
|
||||
bulkDropdown: summarize(bulkDropdown),
|
||||
clicked: summarize(deleteAction),
|
||||
rows: selectedRows.slice(0, 10).map(summarize),
|
||||
};
|
||||
100
app/amazon/scripts/patrol_delete/complete_draft_select_all.js
Normal file
100
app/amazon/scripts/patrol_delete/complete_draft_select_all.js
Normal file
@@ -0,0 +1,100 @@
|
||||
function textOf(node) {
|
||||
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el || !el.getBoundingClientRect) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
function clickElement(el) {
|
||||
el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
if (el.focus) el.focus();
|
||||
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
|
||||
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
el.click();
|
||||
}
|
||||
|
||||
function summarizeRow(row) {
|
||||
return {
|
||||
rowId: row.getAttribute('row-id') || '',
|
||||
text: textOf(row).slice(0, 500),
|
||||
selected: row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected'),
|
||||
};
|
||||
}
|
||||
|
||||
const payload = arguments[0] || {};
|
||||
const view = document.querySelector('[data-testid="complete-drafts-view"]');
|
||||
if (!view) return { ok: false, reason: 'complete drafts view not found' };
|
||||
|
||||
const rows = Array.from(view.querySelectorAll('.ag-center-cols-container [role="row"]'))
|
||||
.filter((row) => isVisible(row));
|
||||
const rowCount = rows.length;
|
||||
const selectedBefore = rows.filter((row) => row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')).length;
|
||||
const maxSelectCountRaw = Number(payload.maxSelectCount || 0);
|
||||
const maxSelectCount = Number.isFinite(maxSelectCountRaw) && maxSelectCountRaw > 0 ? Math.floor(maxSelectCountRaw) : 0;
|
||||
|
||||
if (payload.probeOnly) {
|
||||
return {
|
||||
ok: true,
|
||||
probeOnly: true,
|
||||
rowCount,
|
||||
selectedCount: selectedBefore,
|
||||
rows: rows.slice(0, 10).map(summarizeRow),
|
||||
};
|
||||
}
|
||||
|
||||
if (rowCount <= 0) {
|
||||
return { ok: false, reason: 'no complete draft rows', rowCount, selectedCount: selectedBefore };
|
||||
}
|
||||
|
||||
const headerCheckbox = view.querySelector('.ag-header-select-all input[type="checkbox"]');
|
||||
if (!headerCheckbox) {
|
||||
return { ok: false, reason: 'complete draft select-all checkbox not found', rowCount, selectedCount: selectedBefore };
|
||||
}
|
||||
|
||||
if (maxSelectCount > 0 && maxSelectCount < rowCount) {
|
||||
if (headerCheckbox.checked) {
|
||||
clickElement(headerCheckbox);
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')) {
|
||||
const checkbox = row.querySelector('input[type="checkbox"]');
|
||||
if (checkbox && checkbox.checked) clickElement(checkbox);
|
||||
}
|
||||
}
|
||||
for (const row of rows.slice(0, maxSelectCount)) {
|
||||
const checkbox = row.querySelector('input[type="checkbox"]');
|
||||
if (checkbox && !checkbox.checked) {
|
||||
clickElement(checkbox);
|
||||
} else if (!checkbox) {
|
||||
clickElement(row);
|
||||
}
|
||||
}
|
||||
} else if (!headerCheckbox.checked || selectedBefore < rowCount) {
|
||||
clickElement(headerCheckbox);
|
||||
}
|
||||
|
||||
let selectedRows = Array.from(view.querySelectorAll('.ag-center-cols-container [role="row"]'))
|
||||
.filter((row) => isVisible(row) && (row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')));
|
||||
|
||||
if (selectedRows.length <= 0) {
|
||||
const firstRowCheckbox = rows[0] && rows[0].querySelector('input[type="checkbox"]');
|
||||
if (firstRowCheckbox && !firstRowCheckbox.checked) {
|
||||
clickElement(firstRowCheckbox);
|
||||
}
|
||||
}
|
||||
|
||||
selectedRows = Array.from(view.querySelectorAll('.ag-center-cols-container [role="row"]'))
|
||||
.filter((row) => isVisible(row) && (row.getAttribute('aria-selected') === 'true' || row.classList.contains('ag-row-selected')));
|
||||
|
||||
return {
|
||||
ok: selectedRows.length > 0,
|
||||
rowCount,
|
||||
selectedCount: selectedRows.length,
|
||||
headerChecked: headerCheckbox.checked,
|
||||
rows: selectedRows.slice(0, 10).map(summarizeRow),
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
function textOf(node) {
|
||||
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el || !el.getBoundingClientRect) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
function clickElement(el) {
|
||||
el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
if (el.focus) el.focus();
|
||||
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
|
||||
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
el.click();
|
||||
}
|
||||
|
||||
function summarize(el) {
|
||||
return {
|
||||
tag: el.tagName,
|
||||
text: textOf(el),
|
||||
className: el.className || '',
|
||||
outerHTML: (el.outerHTML || '').slice(0, 1000),
|
||||
};
|
||||
}
|
||||
|
||||
const targetTag = String((arguments[0] && arguments[0].tag) || '').replace(/\s+/g, ' ').trim();
|
||||
if (!targetTag) return { ok: false, reason: 'target tag empty' };
|
||||
|
||||
const view = document.querySelector('[data-testid="complete-drafts-view"]');
|
||||
if (!view) return { ok: false, reason: 'complete drafts view not found' };
|
||||
|
||||
const chips = Array.from(view.querySelectorAll('.JanusChip-module__janusChipContainer--gRjdp'));
|
||||
const chip = chips.find((el) => {
|
||||
const text = textOf(el);
|
||||
return text === targetTag || text.startsWith(`${targetTag} (`) || text.startsWith(`${targetTag}(`);
|
||||
});
|
||||
|
||||
if (!chip) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'complete draft chip not found',
|
||||
tag: targetTag,
|
||||
chips: chips.map(summarize),
|
||||
};
|
||||
}
|
||||
|
||||
if (!/active/.test(String(chip.className || ''))) {
|
||||
clickElement(chip);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tag: targetTag,
|
||||
clicked: summarize(chip),
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>格式转换 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-C66x_2Dh.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-BtdhG28Q.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-Lp7YWffz.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css">
|
||||
</head>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据去重 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-C66x_2Dh.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-BtdhG28Q.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-Lp7YWffz.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css">
|
||||
</head>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>删除品牌 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-C66x_2Dh.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-BtdhG28Q.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-Lp7YWffz.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BP3XWKAC.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BfMLVSQU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据拆分 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-C66x_2Dh.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BZije8D7.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-BtdhG28Q.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-Lp7YWffz.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Dp5dN8OO.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css">
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user