Stabilize patrol delete automation against delayed Amazon UI
The patrol-delete flow now waits for country and listing-status controls before acting, uses the dedicated PatrolDeleteTask entry point, and includes the browser helper scripts required by the bulk-delete path. Tests cover delayed dropdown readiness, payload rejection/retry behavior, complete-draft normalization, filtered bulk deletion, and asset fallback lookup. Constraint: Amazon listing UI exposes status controls through dynamic KAT components and delayed option rendering Rejected: Keep row-by-row delete flow | bulk selection is the current implemented path and is covered by the added helper scripts Confidence: high Scope-risk: moderate Directive: Do not remove the patrol_delete JS helper files without checking app/amazon/patrol_delete.py script loading Tested: uv run --group dev pytest tests\\test_amazon_base.py tests\\test_patrol_delete.py Tested: uv run python -m py_compile amazon\\base.py amazon\\main.py blueprints\\main.py Not-tested: Real Amazon Seller Central browser session
This commit is contained in:
@@ -36,6 +36,8 @@ COUNTRY_INITIAL_LOAD_TIMEOUT = 120
|
|||||||
COUNTRY_LOOKUP_TIMEOUT = 20
|
COUNTRY_LOOKUP_TIMEOUT = 20
|
||||||
COUNTRY_CLICK_TIMEOUT = 10
|
COUNTRY_CLICK_TIMEOUT = 10
|
||||||
COUNTRY_DROPDOWN_DELAY = 1
|
COUNTRY_DROPDOWN_DELAY = 1
|
||||||
|
COUNTRY_DROPDOWN_READY_TIMEOUT = 20
|
||||||
|
COUNTRY_DROPDOWN_RETRY_INTERVAL = 0.5
|
||||||
IP_CHECK_TIMEOUT = 60
|
IP_CHECK_TIMEOUT = 60
|
||||||
|
|
||||||
LOGIN_ATTEMPTS = 4
|
LOGIN_ATTEMPTS = 4
|
||||||
@@ -585,18 +587,36 @@ class AmamzonBase(ZiniaoDriver):
|
|||||||
if not dropdown_header:
|
if not dropdown_header:
|
||||||
logger.warning("找不到国家切换下拉框")
|
logger.warning("找不到国家切换下拉框")
|
||||||
return False
|
return False
|
||||||
dropdown_header.click()
|
|
||||||
time.sleep(COUNTRY_DROPDOWN_DELAY)
|
|
||||||
|
|
||||||
logger.info("正在展开国家列表")
|
logger.info("正在展开国家列表")
|
||||||
first_item = self.tab.ele(COUNTRY_LIST_ITEM_XPATH, timeout=COUNTRY_CLICK_TIMEOUT)
|
first_item = self._wait_country_list_item(dropdown_header)
|
||||||
if not first_item:
|
if first_item is None:
|
||||||
logger.warning("找不到国家列表项")
|
logger.warning("找不到国家列表项")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
first_item.click()
|
first_item.click()
|
||||||
time.sleep(COUNTRY_DROPDOWN_DELAY)
|
time.sleep(COUNTRY_DROPDOWN_DELAY)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _wait_country_list_item(self, dropdown_header):
|
||||||
|
deadline = time.time() + COUNTRY_DROPDOWN_READY_TIMEOUT
|
||||||
|
attempt = 0
|
||||||
|
while time.time() < deadline:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
dropdown_header.click()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("点击国家切换下拉框失败, attempt={}, error={}", attempt, exc)
|
||||||
|
|
||||||
|
time.sleep(COUNTRY_DROPDOWN_DELAY)
|
||||||
|
first_item = self.tab.ele(COUNTRY_LIST_ITEM_XPATH, timeout=COUNTRY_DROPDOWN_RETRY_INTERVAL)
|
||||||
|
if first_item:
|
||||||
|
return first_item
|
||||||
|
|
||||||
|
self.tab.wait.doc_loaded(timeout=COUNTRY_CLICK_TIMEOUT, raise_err=False)
|
||||||
|
time.sleep(COUNTRY_DROPDOWN_RETRY_INTERVAL)
|
||||||
|
return None
|
||||||
|
|
||||||
def _select_country(self, country_name: str) -> bool:
|
def _select_country(self, country_name: str) -> bool:
|
||||||
logger.info("正在切换到国家:{}", country_name)
|
logger.info("正在切换到国家:{}", country_name)
|
||||||
target_country = self.tab.ele(self._country_option_xpath(country_name), timeout=COUNTRY_CLICK_TIMEOUT)
|
target_country = self.tab.ele(self._country_option_xpath(country_name), timeout=COUNTRY_CLICK_TIMEOUT)
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ import requests
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from config import JSON_TASK_QUEUE, runing_task, runing_shop, DELETE_BRAND_API_BASE, ZN_COMPANY, ZN_USERNAME, ZN_PASSWORD
|
from config import JSON_TASK_QUEUE, runing_task, runing_shop, DELETE_BRAND_API_BASE, ZN_COMPANY, ZN_USERNAME, ZN_PASSWORD
|
||||||
from amazon.del_brand import AmazoneDriver, kill_process
|
from amazon.del_brand import AmazoneDriver, kill_process
|
||||||
from amazon.approve import ApproveTask
|
from amazon.approve import ApproveTask
|
||||||
from amazon.match_action import MatchTak
|
from amazon.match_action import MatchTak
|
||||||
from amazon.price_match import PriceTask
|
from amazon.price_match import PriceTask
|
||||||
from amazon.asin_status import StatusTask
|
from amazon.asin_status import StatusTask
|
||||||
from amazon.patrol_delete import ProductTask
|
from amazon.patrol_delete import PatrolDeleteTask
|
||||||
from amazon.detail_spider import SpiderTask
|
from amazon.detail_spider import SpiderTask
|
||||||
|
|
||||||
|
|
||||||
from amazon.tool import get_shop_info,show_notification
|
from amazon.tool import get_shop_info,show_notification
|
||||||
|
|
||||||
|
|
||||||
class TaskMonitor:
|
class TaskMonitor:
|
||||||
@@ -55,15 +55,15 @@ class TaskMonitor:
|
|||||||
self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
|
self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
|
||||||
futures = [] # 保存所有提交的任务Future对象
|
futures = [] # 保存所有提交的任务Future对象
|
||||||
|
|
||||||
task_type_info = {
|
task_type_info = {
|
||||||
"product-risk-resolve-run" : "产品风险审批",
|
"product-risk-resolve-run" : "产品风险审批",
|
||||||
"shop-match-run" : "匹配价格",
|
"shop-match-run" : "匹配价格",
|
||||||
"price-track-run" : "跟价",
|
"price-track-run" : "跟价",
|
||||||
"query-asin-run" : "状态查询",
|
"query-asin-run" : "状态查询",
|
||||||
"patrol-delete-run" : "巡店删除",
|
"patrol-delete-run" : "巡店删除",
|
||||||
"appearance-patent-run" : "亚马逊采集",
|
"appearance-patent-run" : "亚马逊采集",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
# 使用较长超时时间等待任务,减少空等待异常
|
# 使用较长超时时间等待任务,减少空等待异常
|
||||||
@@ -125,14 +125,14 @@ class TaskMonitor:
|
|||||||
task_data: 任务数据
|
task_data: 任务数据
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
TASK_INFO = {
|
TASK_INFO = {
|
||||||
"产品风险审批" : ApproveTask,
|
"产品风险审批" : ApproveTask,
|
||||||
"匹配价格" : MatchTak,
|
"匹配价格" : MatchTak,
|
||||||
"跟价" : PriceTask,
|
"跟价" : PriceTask,
|
||||||
"状态查询" : StatusTask,
|
"状态查询" : StatusTask,
|
||||||
"巡店删除" : ProductTask,
|
"巡店删除" : PatrolDeleteTask,
|
||||||
"亚马逊采集" : SpiderTask,
|
"亚马逊采集" : SpiderTask,
|
||||||
}
|
}
|
||||||
self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...")
|
self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...")
|
||||||
# 创建ApproveTask实例并处理任务
|
# 创建ApproveTask实例并处理任务
|
||||||
TASK_CLS = TASK_INFO[TASK_TYPE] # 根据任务类型选择处理类,默认为ApproveTask
|
TASK_CLS = TASK_INFO[TASK_TYPE] # 根据任务类型选择处理类,默认为ApproveTask
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
function textOf(node) {
|
||||||
|
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrsText(el) {
|
||||||
|
if (!el || !el.attributes) return '';
|
||||||
|
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 walkRoots(root, rows, seen) {
|
||||||
|
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) rows.push(node);
|
||||||
|
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
|
||||||
|
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
|
||||||
|
for (const assigned of node.assignedElements({ flatten: true })) {
|
||||||
|
walkRoots(assigned, rows, seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allElements(root) {
|
||||||
|
const rows = [];
|
||||||
|
walkRoots(root || document, rows, new Set());
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 clickTargetFor(el) {
|
||||||
|
if (el && el.shadowRoot) {
|
||||||
|
const button = el.shadowRoot.querySelector('button:not([disabled]), [role="button"]:not([disabled])');
|
||||||
|
if (button) return button;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.querySelector('kat-modal[data-testid="action-modal"]') ||
|
||||||
|
allElements(document).find((el) => /删除|Delete/i.test(textOf(el)) && (el.tagName || '').toLowerCase() === 'kat-modal');
|
||||||
|
|
||||||
|
if (!modal) return { ok: false, reason: 'delete modal not found' };
|
||||||
|
|
||||||
|
const buttons = allElements(modal).filter((el) => {
|
||||||
|
const haystack = `${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`;
|
||||||
|
return isVisible(el) && /kat-button|button/i.test(el.tagName || '') && /variant=primary|删除|确认|Delete|Confirm/i.test(haystack);
|
||||||
|
});
|
||||||
|
const button = buttons[0];
|
||||||
|
|
||||||
|
if (!button) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'confirm button not found',
|
||||||
|
modalText: textOf(modal),
|
||||||
|
modalHTML: (modal.outerHTML || '').slice(0, 2000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = clickTargetFor(button);
|
||||||
|
clickElement(target);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
clicked: {
|
||||||
|
tag: button.tagName,
|
||||||
|
text: textOf(button),
|
||||||
|
attrs: attrsText(button),
|
||||||
|
targetTag: target.tagName,
|
||||||
|
},
|
||||||
|
};
|
||||||
46
app/amazon/scripts/patrol_delete/delete_success_messages.js
Normal file
46
app/amazon/scripts/patrol_delete/delete_success_messages.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
function textOf(node) {
|
||||||
|
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrsText(el) {
|
||||||
|
if (!el || !el.attributes) return '';
|
||||||
|
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 walkRoots(root, rows, seen) {
|
||||||
|
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) rows.push(node);
|
||||||
|
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
|
||||||
|
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
|
||||||
|
for (const assigned of node.assignedElements({ flatten: true })) {
|
||||||
|
walkRoots(assigned, rows, seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
walkRoots(document, rows, new Set());
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
return rows
|
||||||
|
.filter((el) => {
|
||||||
|
const haystack = `${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`;
|
||||||
|
return isVisible(el) && /kat-alert|alert|toast|notification|已删除|已经删除|15\s*分钟|success/i.test(haystack);
|
||||||
|
})
|
||||||
|
.map((el) => textOf(el) || attrsText(el))
|
||||||
|
.filter((text) => text && !seen.has(text) && seen.add(text))
|
||||||
|
.slice(0, 30);
|
||||||
187
app/amazon/scripts/patrol_delete/inventory_delete_selected.js
Normal file
187
app/amazon/scripts/patrol_delete/inventory_delete_selected.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
function textOf(node) {
|
||||||
|
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrsText(el) {
|
||||||
|
if (!el || !el.attributes) return '';
|
||||||
|
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 walkRoots(root, rows, seen) {
|
||||||
|
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) rows.push(node);
|
||||||
|
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
|
||||||
|
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
|
||||||
|
for (const assigned of node.assignedElements({ flatten: true })) {
|
||||||
|
walkRoots(assigned, rows, seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allElements(root) {
|
||||||
|
const rows = [];
|
||||||
|
walkRoots(root || document, rows, new Set());
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestDeep(el, selector) {
|
||||||
|
let node = el;
|
||||||
|
while (node) {
|
||||||
|
if (node.closest) {
|
||||||
|
const found = node.closest(selector);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
const root = node.getRootNode && node.getRootNode();
|
||||||
|
node = root && root.host ? root.host : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insideInventoryRow(el) {
|
||||||
|
return Boolean(closestDeep(el, 'div[data-sku]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeleteAction(el) {
|
||||||
|
const haystack = `${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`;
|
||||||
|
return /data-action=DeleteListing|action=DeleteListing|删除商品信息|删除商品和报价|DeleteListing|delete\s+listing/i.test(haystack);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 hostDropdownOf(el) {
|
||||||
|
const host = closestDeep(el, 'kat-dropdown-button');
|
||||||
|
return host || ((el.tagName || '').toLowerCase() === 'kat-dropdown-button' ? el : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropdownToggleOf(dropdown, fallback) {
|
||||||
|
if (!dropdown) return fallback;
|
||||||
|
if (fallback && textOf(fallback)) return fallback;
|
||||||
|
return fallback || dropdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBulkActionMenu(candidate) {
|
||||||
|
const dropdown = hostDropdownOf(candidate);
|
||||||
|
const toggle = dropdownToggleOf(dropdown, candidate);
|
||||||
|
clickElement(toggle);
|
||||||
|
return { dropdown, toggle };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(el) {
|
||||||
|
return {
|
||||||
|
tag: el.tagName,
|
||||||
|
text: textOf(el),
|
||||||
|
attrs: attrsText(el),
|
||||||
|
visible: isVisible(el),
|
||||||
|
outerHTML: (el.outerHTML || '').slice(0, 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVisibleBulkDeleteAction() {
|
||||||
|
return allElements(document).find((el) => isDeleteAction(el) && isVisible(el) && !insideInventoryRow(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDeleteActionAfterMenuOpen(candidate) {
|
||||||
|
const dropdown = hostDropdownOf(candidate) || candidate;
|
||||||
|
const exactMenuButton = allElements(dropdown).find((child) => {
|
||||||
|
const tag = (child.tagName || '').toLowerCase();
|
||||||
|
const role = child.getAttribute && child.getAttribute('role');
|
||||||
|
return tag === 'button' && role === 'menuitem' && isDeleteAction(child);
|
||||||
|
});
|
||||||
|
if (exactMenuButton) return { mode: 'bulk-action-menuitem-delete', el: exactMenuButton };
|
||||||
|
|
||||||
|
const nestedDeleteAction = allElements(dropdown).find((child) => isDeleteAction(child) && isVisible(child));
|
||||||
|
if (nestedDeleteAction) return { mode: 'bulk-action-nested-delete', el: nestedDeleteAction };
|
||||||
|
|
||||||
|
const deleteAction = findVisibleBulkDeleteAction();
|
||||||
|
if (deleteAction) return { mode: 'bulk-action-visible-delete', el: deleteAction };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBulkActionCandidate(el) {
|
||||||
|
const tag = (el.tagName || '').toLowerCase();
|
||||||
|
if (!/^(button|kat-button|kat-dropdown-button|kat-select|kat-popover|kat-menu-button)$/.test(tag)) return false;
|
||||||
|
if (!isVisible(el) || insideInventoryRow(el)) return false;
|
||||||
|
const haystack = `${tag} ${attrsText(el)} ${textOf(el)}`;
|
||||||
|
return /DeleteListing|删除商品信息|删除|操作|更多|Action|Actions|selected|选定|已选择|批量/i.test(haystack);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directDelete = findVisibleBulkDeleteAction();
|
||||||
|
if (directDelete) {
|
||||||
|
clickElement(directDelete);
|
||||||
|
return { ok: true, mode: 'direct-delete-button', clicked: summarize(directDelete) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = allElements(document);
|
||||||
|
const candidates = rows.filter(isBulkActionCandidate);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const openResult = openBulkActionMenu(candidate);
|
||||||
|
const deleteAction = findDeleteActionAfterMenuOpen(candidate);
|
||||||
|
if (deleteAction) {
|
||||||
|
clickElement(deleteAction.el);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mode: deleteAction.mode,
|
||||||
|
candidate: summarize(candidate),
|
||||||
|
opened: {
|
||||||
|
dropdown: openResult.dropdown ? summarize(openResult.dropdown) : null,
|
||||||
|
toggle: openResult.toggle ? summarize(openResult.toggle) : null,
|
||||||
|
},
|
||||||
|
clicked: summarize(deleteAction.el),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'bulk action menu opened; delete action not visible yet',
|
||||||
|
menuOpened: true,
|
||||||
|
candidate: summarize(candidate),
|
||||||
|
opened: {
|
||||||
|
dropdown: openResult.dropdown ? summarize(openResult.dropdown) : null,
|
||||||
|
toggle: openResult.toggle ? summarize(openResult.toggle) : null,
|
||||||
|
},
|
||||||
|
deleteLikeElements: allElements(document)
|
||||||
|
.filter((el) => /DeleteListing|删除商品信息|删除商品和报价|delete\s+listing/i.test(`${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`) && !insideInventoryRow(el))
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(summarize),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Try the next non-row bulk action control.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'bulk delete action not found',
|
||||||
|
candidates: candidates.slice(0, 20).map(summarize),
|
||||||
|
deleteLikeElements: rows
|
||||||
|
.filter((el) => /DeleteListing|删除商品信息|删除商品和报价|delete\s+listing/i.test(`${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`) && !insideInventoryRow(el))
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(summarize),
|
||||||
|
visibleButtons: rows
|
||||||
|
.filter((el) => /^(button|kat-button|kat-dropdown-button)$/i.test(el.tagName || '') && isVisible(el))
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(summarize),
|
||||||
|
};
|
||||||
71
app/amazon/scripts/patrol_delete/inventory_select_all.js
Normal file
71
app/amazon/scripts/patrol_delete/inventory_select_all.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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 walkRoots(root, rows, seen) {
|
||||||
|
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) rows.push(node);
|
||||||
|
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allElements() {
|
||||||
|
const rows = [];
|
||||||
|
walkRoots(document, rows, new Set());
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insideInventoryRow(el) {
|
||||||
|
return Boolean(el && el.closest && el.closest('div[data-sku]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickTargetFor(checkbox) {
|
||||||
|
if (!checkbox) return null;
|
||||||
|
if (checkbox.shadowRoot) {
|
||||||
|
const shadowTarget = checkbox.shadowRoot.querySelector('[role="checkbox"], .checkbox');
|
||||||
|
if (shadowTarget) return shadowTarget;
|
||||||
|
}
|
||||||
|
return checkbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowCount = document.querySelectorAll('div[data-sku]').length;
|
||||||
|
if (rowCount <= 0) {
|
||||||
|
return { ok: false, reason: 'no inventory rows after status filter', rowCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkboxes = allElements()
|
||||||
|
.filter((el) => (el.tagName || '').toLowerCase() === 'kat-checkbox' && isVisible(el))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const rectA = a.getBoundingClientRect();
|
||||||
|
const rectB = b.getBoundingClientRect();
|
||||||
|
return rectA.top - rectB.top || rectA.left - rectB.left;
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerCheckbox = checkboxes.find((el) => !insideInventoryRow(el)) || checkboxes[0];
|
||||||
|
if (!headerCheckbox) {
|
||||||
|
return { ok: false, reason: 'select-all checkbox not found', rowCount, checkboxCount: checkboxes.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = clickTargetFor(headerCheckbox);
|
||||||
|
target.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||||
|
target.click();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
rowCount,
|
||||||
|
checkboxCount: checkboxes.length,
|
||||||
|
clicked: {
|
||||||
|
tag: headerCheckbox.tagName,
|
||||||
|
insideInventoryRow: insideInventoryRow(headerCheckbox),
|
||||||
|
outerHTML: (headerCheckbox.outerHTML || '').slice(0, 1000),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }));
|
||||||
|
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||||
|
return true;
|
||||||
@@ -2,11 +2,32 @@ function textOf(node) {
|
|||||||
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
|
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalize(text) {
|
||||||
|
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
function attrsText(el) {
|
function attrsText(el) {
|
||||||
if (!el || !el.attributes) return '';
|
if (!el || !el.attributes) return '';
|
||||||
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attrsOf(el) {
|
||||||
|
const attrs = {};
|
||||||
|
if (!el || !el.attributes) return attrs;
|
||||||
|
for (const attr of el.attributes) attrs[attr.name] = attr.value;
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDisabled(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
return (
|
||||||
|
el.hasAttribute('disabled') ||
|
||||||
|
el.disabled === true ||
|
||||||
|
el.getAttribute('aria-disabled') === 'true' ||
|
||||||
|
/\bdisabled\b/i.test(el.getAttribute('class') || '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isVisible(el) {
|
function isVisible(el) {
|
||||||
if (!el || !el.getBoundingClientRect) return false;
|
if (!el || !el.getBoundingClientRect) return false;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
@@ -50,9 +71,25 @@ function nearestLabel(el) {
|
|||||||
return [...new Set(parts)].join(' | ');
|
return [...new Set(parts)].join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionTextOf(dropdown) {
|
||||||
|
const roots = [dropdown];
|
||||||
|
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
|
||||||
|
const texts = [];
|
||||||
|
for (const root of roots) {
|
||||||
|
for (const option of Array.from(root.querySelectorAll('kat-option, kat-label, [role="option"], [class*="parentKatOptionStyle"]'))) {
|
||||||
|
texts.push(`${textOf(option)} ${attrsText(option)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalize(texts.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSearchFieldDropdown(text) {
|
||||||
|
return /(^|\s)(SKUS?|ASIN|FNSKU|UPC\/EAN|TITLE_KEYWORD|GTIN)(\s|$)|商品名称\/关键字|data-value=(SKU|ASIN|FNSKU|TITLE_KEYWORD|GTIN)|value=(SKU|ASIN|FNSKU|TITLE_KEYWORD|GTIN)/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
function statusHitCount(text) {
|
function statusHitCount(text) {
|
||||||
const matches = normalize(text).match(
|
const matches = normalize(text).match(
|
||||||
/(全部\s*[((]\s*[\d,]+\s*[))]|在售\s*[((]\s*[\d,]+\s*[))]|不可售\s*[((]\s*[\d,]+\s*[))]|详情页面已删除\s*[((]\s*[\d,]+\s*[))]|订单页面已删除\s*[((]\s*[\d,]+\s*[))]|定价问题\s*[((]\s*[\d,]+\s*[))]|需要批准\s*[((]\s*[\d,]+\s*[))]|在搜索结果中禁止显示\s*[((]\s*[\d,]+\s*[))])/g
|
/(全部|在售|不可售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|配送问题|缺少报价|已停售|在搜索结果中禁止显示)\s*(?:[((]\s*[\d,]+\s*[))])?/g
|
||||||
);
|
);
|
||||||
return matches ? matches.length : 0;
|
return matches ? matches.length : 0;
|
||||||
}
|
}
|
||||||
@@ -60,18 +97,24 @@ function statusHitCount(text) {
|
|||||||
function scoreDropdown(el) {
|
function scoreDropdown(el) {
|
||||||
const text = textOf(el);
|
const text = textOf(el);
|
||||||
const label = nearestLabel(el);
|
const label = nearestLabel(el);
|
||||||
const haystack = `${el.tagName} ${attrsText(el)} ${text} ${label}`;
|
const attrs = attrsText(el);
|
||||||
const statusHits = statusHitCount(`${text} ${label}`);
|
const optionText = optionTextOf(el);
|
||||||
|
const haystack = `${el.tagName} ${attrs} ${text} ${label} ${optionText}`;
|
||||||
|
const statusHits = statusHitCount(`${text} ${label} ${optionText}`);
|
||||||
|
const isStatusDropdown = /statusDropdown|商品状态|商品狀態|ListingStatus|listing status/i.test(haystack);
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
if (isSearchFieldDropdown(haystack)) score -= 1000;
|
||||||
|
if (isStatusDropdown) score += 500;
|
||||||
if (/商品状态|商品狀態|ListingStatus|listing status/i.test(haystack)) score += 120;
|
if (/商品状态|商品狀態|ListingStatus|listing status/i.test(haystack)) score += 120;
|
||||||
if (/\bstatus\b|状态/i.test(haystack)) score += 25;
|
if (/\bstatus\b|状态/i.test(haystack)) score += 25;
|
||||||
if (/filter|筛选|筛選|库存/i.test(haystack)) score += 10;
|
if (/filter|筛选|筛選|库存/i.test(haystack)) score += 10;
|
||||||
if (/全部\s*[((]\s*[\d,]+\s*[))]/.test(`${text} ${label}`)) score += 300;
|
if (/全部\s*[((]\s*[\d,]+\s*[))]/.test(`${text} ${label} ${optionText}`)) score += 300;
|
||||||
if (statusHits >= 2) score += statusHits * 180;
|
if (statusHits >= 2) score += statusHits * 180;
|
||||||
if (/SKUS?|ASIN|FNSKU|UPC\/EAN|TITLE_KEYWORD|商品名称\/关键字/i.test(`${text} ${label}`)) score -= 300;
|
if (!isStatusDropdown && statusHits === 0) score -= 200;
|
||||||
if (isVisible(el)) score += 10;
|
if (isVisible(el)) score += 10;
|
||||||
if (el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]')) score -= 80;
|
if (el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]')) score -= 80;
|
||||||
return { score, text, label, haystack, statusHits };
|
if (isDisabled(el)) score -= 20;
|
||||||
|
return { score, text, label, haystack, statusHits, disabled: isDisabled(el) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateTriggers(dropdown) {
|
function candidateTriggers(dropdown) {
|
||||||
@@ -174,6 +217,32 @@ if (!ranked.length) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selected = ranked[0];
|
const selected = ranked[0];
|
||||||
|
if (selected.disabled) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'listing status dropdown disabled/loading',
|
||||||
|
selected: {
|
||||||
|
index: selected.index,
|
||||||
|
score: selected.score,
|
||||||
|
statusHits: selected.statusHits,
|
||||||
|
text: selected.text,
|
||||||
|
label: selected.label,
|
||||||
|
attrs: attrsOf(selected.el),
|
||||||
|
outerHTML: (selected.el.outerHTML || '').slice(0, 2000),
|
||||||
|
},
|
||||||
|
candidates: ranked.slice(0, 10).map((row) => ({
|
||||||
|
index: row.index,
|
||||||
|
score: row.score,
|
||||||
|
statusHits: row.statusHits,
|
||||||
|
disabled: row.disabled,
|
||||||
|
text: row.text,
|
||||||
|
label: row.label,
|
||||||
|
attrs: attrsText(row.el),
|
||||||
|
outerHTML: (row.el.outerHTML || '').slice(0, 1000),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
selected.el.setAttribute('data-crawler-listing-status-dropdown', 'true');
|
selected.el.setAttribute('data-crawler-listing-status-dropdown', 'true');
|
||||||
const clickResult = clickTrigger(selected.el);
|
const clickResult = clickTrigger(selected.el);
|
||||||
|
|
||||||
@@ -217,6 +286,7 @@ return {
|
|||||||
index: row.index,
|
index: row.index,
|
||||||
score: row.score,
|
score: row.score,
|
||||||
statusHits: row.statusHits,
|
statusHits: row.statusHits,
|
||||||
|
disabled: row.disabled,
|
||||||
text: row.text,
|
text: row.text,
|
||||||
label: row.label,
|
label: row.label,
|
||||||
attrs: attrsText(row.el),
|
attrs: attrsText(row.el),
|
||||||
|
|||||||
@@ -35,10 +35,48 @@ function allElements() {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findMarkedDropdown() {
|
||||||
|
return allElements().find((el) => el.getAttribute('data-crawler-listing-status-dropdown') === 'true') || null;
|
||||||
|
}
|
||||||
|
|
||||||
function insideListingRow(el) {
|
function insideListingRow(el) {
|
||||||
return Boolean(el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]'));
|
return Boolean(el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalize(text) {
|
||||||
|
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptionLike(el) {
|
||||||
|
if (!el || !el.tagName) return false;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const role = el.getAttribute('role');
|
||||||
|
const className = el.getAttribute('class') || '';
|
||||||
|
return (
|
||||||
|
tag === 'kat-option' ||
|
||||||
|
tag === 'kat-label' ||
|
||||||
|
role === 'option' ||
|
||||||
|
/parentKatOptionStyle/i.test(className)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatus(rawText) {
|
||||||
|
const text = normalize(rawText);
|
||||||
|
if (!text) return '';
|
||||||
|
const match = text.match(/^(.*?)\s*[((]\s*[\d,]+\s*[))]\s*$/);
|
||||||
|
return normalize(match ? match[1] : text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isListingStatusOption(row) {
|
||||||
|
const text = normalize(`${row.raw_text} ${row.value || ''}`);
|
||||||
|
const parsed = parseStatus(row.raw_text);
|
||||||
|
const haystack = normalize(`${text} ${parsed}`);
|
||||||
|
if (/^(所有|SKUS?|ASIN|FNSKU|UPC\/EAN|商品名称\/关键字)$|^(ALL|SKU|ASIN|FNSKU|TITLE_KEYWORD|GTIN)$/i.test(haystack)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /^(全部|在售|不可售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|配送问题|缺少报价|已停售|在搜索结果中禁止显示)$/.test(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function optionRow(el, index) {
|
function optionRow(el, index) {
|
||||||
const attrs = attrsOf(el);
|
const attrs = attrsOf(el);
|
||||||
const rawText = textOf(el) || attrs.label || attrs.text || attrs.value || '';
|
const rawText = textOf(el) || attrs.label || attrs.text || attrs.value || '';
|
||||||
@@ -46,33 +84,46 @@ function optionRow(el, index) {
|
|||||||
index,
|
index,
|
||||||
raw_text: rawText,
|
raw_text: rawText,
|
||||||
value: attrs.value || attrs['data-value'] || attrs.name || null,
|
value: attrs.value || attrs['data-value'] || attrs.name || null,
|
||||||
|
tag: (el.tagName || '').toLowerCase(),
|
||||||
attrs,
|
attrs,
|
||||||
visible: isVisible(el),
|
visible: isVisible(el),
|
||||||
outerHTML: (el.outerHTML || '').slice(0, 1200),
|
outerHTML: (el.outerHTML || '').slice(0, 1200),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const markedDropdown = document.querySelector('[data-crawler-listing-status-dropdown="true"]');
|
const markedDropdown = findMarkedDropdown();
|
||||||
let scoped = [];
|
let scoped = [];
|
||||||
if (markedDropdown) {
|
if (markedDropdown) {
|
||||||
const roots = [markedDropdown];
|
const roots = [markedDropdown];
|
||||||
if (markedDropdown.shadowRoot) roots.push(markedDropdown.shadowRoot);
|
if (markedDropdown.shadowRoot) roots.push(markedDropdown.shadowRoot);
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
scoped = scoped.concat(Array.from(root.querySelectorAll('kat-option, [role="option"]')));
|
scoped = scoped.concat(Array.from(root.querySelectorAll('kat-option, kat-label, [role="option"], [class*="parentKatOptionStyle"]')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allOptions = allElements().filter((el) => {
|
const allOptions = allElements().filter((el) => {
|
||||||
const tag = el.tagName.toLowerCase();
|
if (!isOptionLike(el)) return false;
|
||||||
const role = el.getAttribute('role');
|
|
||||||
if (tag !== 'kat-option' && role !== 'option') return false;
|
|
||||||
if (insideListingRow(el)) return false;
|
if (insideListingRow(el)) return false;
|
||||||
return isVisible(el);
|
return isVisible(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
const byNode = new Map();
|
const byNode = new Map();
|
||||||
|
const byStatus = new Map();
|
||||||
scoped.concat(allOptions).forEach((el, index) => {
|
scoped.concat(allOptions).forEach((el, index) => {
|
||||||
if (!byNode.has(el)) byNode.set(el, optionRow(el, index));
|
if (!byNode.has(el)) {
|
||||||
|
const row = optionRow(el, index);
|
||||||
|
if (isListingStatusOption(row)) {
|
||||||
|
byNode.set(el, row);
|
||||||
|
const parsed = parseStatus(row.raw_text);
|
||||||
|
const hasValue = Boolean(row.value);
|
||||||
|
const hasQuantity = /[((]\s*[\d,]+\s*[))]/.test(row.raw_text);
|
||||||
|
const quality = (hasValue ? 2 : 0) + (hasQuantity ? 1 : 0);
|
||||||
|
const existing = byStatus.get(parsed);
|
||||||
|
if (!existing || quality > existing.quality) {
|
||||||
|
byStatus.set(parsed, { row, quality });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(byNode.values()).filter((row) => row.raw_text);
|
return Array.from(byStatus.values()).map((item) => item.row).filter((row) => row.raw_text);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const __crawlerPayload = arguments[0] || {};
|
||||||
|
|
||||||
function textOf(node) {
|
function textOf(node) {
|
||||||
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
@@ -9,6 +11,59 @@ function isVisible(el) {
|
|||||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function walkRoots(root, rows, seen) {
|
||||||
|
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) rows.push(node);
|
||||||
|
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allElements() {
|
||||||
|
const rows = [];
|
||||||
|
walkRoots(document, rows, new Set());
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMarkedDropdown() {
|
||||||
|
return allElements().find((el) => el.getAttribute('data-crawler-listing-status-dropdown') === 'true') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insideListingRow(el) {
|
||||||
|
return Boolean(el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptionLike(el) {
|
||||||
|
if (!el || !el.tagName) return false;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const role = el.getAttribute('role');
|
||||||
|
const className = el.getAttribute('class') || '';
|
||||||
|
return (
|
||||||
|
tag === 'kat-option' ||
|
||||||
|
tag === 'kat-label' ||
|
||||||
|
role === 'option' ||
|
||||||
|
/parentKatOptionStyle/i.test(className)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSearchFieldOption(meta) {
|
||||||
|
return /^(所有|SKUS?|ASIN|FNSKU|UPC\/EAN|商品名称\/关键字)$|^(ALL|SKU|ASIN|FNSKU|TITLE_KEYWORD|GTIN)$/i.test(
|
||||||
|
`${meta.rawText} ${meta.parsedStatus} ${meta.value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isListingStatusOption(meta) {
|
||||||
|
if (isSearchFieldOption(meta)) return false;
|
||||||
|
return /^(全部|在售|不可售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|配送问题|缺少报价|已停售|在搜索结果中禁止显示)$/.test(
|
||||||
|
meta.parsedStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalize(text) {
|
function normalize(text) {
|
||||||
return String(text || '').replace(/\s+/g, ' ').trim();
|
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
@@ -31,24 +86,36 @@ function optionMeta(el, index) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetStatus = normalize((__crawlerPayload || {}).status);
|
function clickTargetFor(el) {
|
||||||
const dropdown = document.querySelector('[data-crawler-listing-status-dropdown="true"]');
|
if (!el) return el;
|
||||||
if (!dropdown) {
|
return el.closest('[class*="parentKatOptionStyle"], kat-option, [role="option"]') || el;
|
||||||
return { ok: false, reason: 'marked dropdown missing', targetStatus };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const roots = [dropdown];
|
const targetStatus = normalize((__crawlerPayload || {}).status);
|
||||||
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
|
const dropdown = findMarkedDropdown();
|
||||||
|
|
||||||
const options = [];
|
const options = [];
|
||||||
for (const root of roots) {
|
|
||||||
for (const el of Array.from(root.querySelectorAll('kat-option, [role="option"]'))) {
|
if (dropdown) {
|
||||||
if (!isVisible(el)) continue;
|
const roots = [dropdown];
|
||||||
|
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
|
||||||
|
for (const root of roots) {
|
||||||
|
for (const el of Array.from(root.querySelectorAll('kat-option, kat-label, [role="option"], [class*="parentKatOptionStyle"]'))) {
|
||||||
|
options.push(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.length) {
|
||||||
|
for (const el of allElements()) {
|
||||||
|
if (!isOptionLike(el)) continue;
|
||||||
|
if (!isVisible(el) || insideListingRow(el)) continue;
|
||||||
options.push(el);
|
options.push(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const metas = options.map((el, index) => ({ el, ...optionMeta(el, index) }));
|
const metas = options
|
||||||
|
.map((el, index) => ({ el, ...optionMeta(el, index) }))
|
||||||
|
.filter((item) => isListingStatusOption(item));
|
||||||
const matched = metas.find((item) => item.parsedStatus === targetStatus || item.rawText === targetStatus);
|
const matched = metas.find((item) => item.parsedStatus === targetStatus || item.rawText === targetStatus);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
return {
|
return {
|
||||||
@@ -65,8 +132,9 @@ if (!matched) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
matched.el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
const clickTarget = clickTargetFor(matched.el);
|
||||||
matched.el.click();
|
clickTarget.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||||
|
clickTarget.click();
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
targetStatus,
|
targetStatus,
|
||||||
@@ -75,5 +143,6 @@ return {
|
|||||||
rawText: matched.rawText,
|
rawText: matched.rawText,
|
||||||
parsedStatus: matched.parsedStatus,
|
parsedStatus: matched.parsedStatus,
|
||||||
value: matched.value,
|
value: matched.value,
|
||||||
|
clickedTag: clickTarget.tagName,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -224,6 +224,64 @@ function scoreRegion(el) {
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRelevantRatioRow(row) {
|
||||||
|
const combined = `${row.raw_text} ${row.label_text} ${row.percentage_text}`;
|
||||||
|
const hasDayLabels = /2\s*天前|30\s*天前|2\s*日前|30\s*日前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(combined);
|
||||||
|
const hasCountries = /欧洲|英国|德国|法国|西班牙|意大利|Europe|UK|United Kingdom|Germany|France|Spain|Italy/i.test(combined);
|
||||||
|
if (!/%|2\s*天前|30\s*天前|2\s*日前|30\s*日前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(combined) && !hasCountries) return false;
|
||||||
|
if (row.raw_text.length > 800 && !hasDayLabels && !hasCountries) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRelevantRows(sourceElements) {
|
||||||
|
const byText = new Map();
|
||||||
|
const scoped = [];
|
||||||
|
sourceElements.forEach((root) => walkRoots(root, scoped, new Set()));
|
||||||
|
scoped
|
||||||
|
.filter((el) => isVisible(el))
|
||||||
|
.forEach((el, index) => {
|
||||||
|
const row = rowOf(el, index);
|
||||||
|
if (!isRelevantRatioRow(row)) return;
|
||||||
|
const combined = `${row.raw_text} ${row.label_text} ${row.percentage_text}`;
|
||||||
|
const key = `${combined}|${Math.round(row.rect_left)}|${Math.round(row.rect_top)}|${Math.round(row.rect_width)}|${Math.round(row.rect_height)}`;
|
||||||
|
if (!byText.has(key)) byText.set(key, row);
|
||||||
|
});
|
||||||
|
return Array.from(byText.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollableElements(elements) {
|
||||||
|
return elements.filter((el) => {
|
||||||
|
if (!el || !el.getBoundingClientRect || !isVisible(el)) return false;
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
if (!/(auto|scroll)/i.test(`${style.overflowY} ${style.overflow}`)) return false;
|
||||||
|
return el.scrollHeight > el.clientHeight + 20;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRowsAcrossScroll(sourceElements, visibleElements) {
|
||||||
|
const targets = scrollableElements(visibleElements);
|
||||||
|
if (document.scrollingElement) targets.push(document.scrollingElement);
|
||||||
|
|
||||||
|
const byKey = new Map();
|
||||||
|
const originals = targets.map((target) => ({ target, top: target.scrollTop }));
|
||||||
|
for (const target of targets) {
|
||||||
|
const maxTop = Math.max(0, target.scrollHeight - target.clientHeight);
|
||||||
|
const positions = [...new Set([0, Math.floor(maxTop / 2), maxTop])];
|
||||||
|
for (const position of positions) {
|
||||||
|
target.scrollTop = position;
|
||||||
|
collectRelevantRows(sourceElements).forEach((row) => {
|
||||||
|
const combined = `${row.raw_text} ${row.label_text} ${row.percentage_text}`;
|
||||||
|
const key = `${combined}|${Math.round(row.rect_left)}|${Math.round(row.rect_top)}|${Math.round(row.rect_width)}|${Math.round(row.rect_height)}`;
|
||||||
|
if (!byKey.has(key)) byKey.set(key, row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
originals.forEach(({ target, top }) => {
|
||||||
|
target.scrollTop = top;
|
||||||
|
});
|
||||||
|
return Array.from(byKey.values());
|
||||||
|
}
|
||||||
|
|
||||||
const visible = allElements().filter((el) => isVisible(el));
|
const visible = allElements().filter((el) => isVisible(el));
|
||||||
const expandedCard = expandedCardOf(visible);
|
const expandedCard = expandedCardOf(visible);
|
||||||
const expandedDirectRows = directRowsFromExpandedCard(expandedCard);
|
const expandedDirectRows = directRowsFromExpandedCard(expandedCard);
|
||||||
@@ -245,15 +303,10 @@ const scoped = [];
|
|||||||
sourceElements.forEach((root) => walkRoots(root, scoped, new Set()));
|
sourceElements.forEach((root) => walkRoots(root, scoped, new Set()));
|
||||||
|
|
||||||
const byText = new Map();
|
const byText = new Map();
|
||||||
scoped
|
collectRelevantRows(sourceElements)
|
||||||
.filter((el) => isVisible(el))
|
.concat(collectRowsAcrossScroll(sourceElements, visible))
|
||||||
.forEach((el, index) => {
|
.forEach((row) => {
|
||||||
const row = rowOf(el, index);
|
|
||||||
const combined = `${row.raw_text} ${row.label_text} ${row.percentage_text}`;
|
const combined = `${row.raw_text} ${row.label_text} ${row.percentage_text}`;
|
||||||
const hasDayLabels = /2\s*天前|30\s*天前|2\s*日前|30\s*日前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(combined);
|
|
||||||
const hasCountries = /欧洲|英国|德国|法国|西班牙|意大利|Europe|UK|United Kingdom|Germany|France|Spain|Italy/i.test(combined);
|
|
||||||
if (!/%|2\s*天前|30\s*天前|2\s*日前|30\s*日前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(combined) && !hasCountries) return;
|
|
||||||
if (row.raw_text.length > 800 && !hasDayLabels && !hasCountries) return;
|
|
||||||
const key = `${combined}|${Math.round(row.rect_left)}|${Math.round(row.rect_top)}|${Math.round(row.rect_width)}|${Math.round(row.rect_height)}`;
|
const key = `${combined}|${Math.round(row.rect_left)}|${Math.round(row.rect_top)}|${Math.round(row.rect_width)}|${Math.round(row.rect_height)}`;
|
||||||
if (!byText.has(key)) byText.set(key, row);
|
if (!byText.has(key)) byText.set(key, row);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ main_bp = Blueprint('main', __name__)
|
|||||||
def _resolve_asset_filename(filename):
|
def _resolve_asset_filename(filename):
|
||||||
"""Resolve hashed Vite assets without maintaining hardcoded alias tables."""
|
"""Resolve hashed Vite assets without maintaining hardcoded alias tables."""
|
||||||
safe_name = os.path.basename(filename)
|
safe_name = os.path.basename(filename)
|
||||||
exact_path = os.path.join(ASSETS_DIR, safe_name)
|
asset_dirs = [
|
||||||
if os.path.isfile(exact_path):
|
ASSETS_DIR,
|
||||||
return safe_name
|
os.path.abspath(os.path.join(BASE_DIR, 'new_web_source', 'assets')),
|
||||||
|
os.path.abspath(os.path.join(BASE_DIR, '..', 'new_web_source', 'assets')),
|
||||||
|
]
|
||||||
|
for asset_dir in asset_dirs:
|
||||||
|
exact_path = os.path.join(asset_dir, safe_name)
|
||||||
|
if os.path.isfile(exact_path):
|
||||||
|
return safe_name
|
||||||
|
|
||||||
base_name, ext = os.path.splitext(safe_name)
|
base_name, ext = os.path.splitext(safe_name)
|
||||||
if not base_name or not ext:
|
if not base_name or not ext:
|
||||||
@@ -38,9 +44,13 @@ def _resolve_asset_filename(filename):
|
|||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
return safe_name
|
return safe_name
|
||||||
|
|
||||||
try:
|
asset_names = []
|
||||||
asset_names = os.listdir(ASSETS_DIR)
|
for asset_dir in asset_dirs:
|
||||||
except OSError:
|
try:
|
||||||
|
asset_names.extend(os.listdir(asset_dir))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not asset_names:
|
||||||
return safe_name
|
return safe_name
|
||||||
|
|
||||||
for prefix_length in range(len(parts) - 1, 0, -1):
|
for prefix_length in range(len(parts) - 1, 0, -1):
|
||||||
@@ -82,25 +92,30 @@ def wb():
|
|||||||
return _render_html('index.html')
|
return _render_html('index.html')
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/brand')
|
@main_bp.route('/brand')
|
||||||
@login_required
|
@login_required
|
||||||
def brand_page():
|
def brand_page():
|
||||||
repo_brand_path = os.path.abspath(os.path.join(BASE_DIR, '..', 'web_source', 'brand.html'))
|
candidate_paths = [
|
||||||
if os.path.isfile(repo_brand_path):
|
os.path.join(BASE_DIR, 'web_source', 'brand.html'),
|
||||||
try:
|
os.path.abspath(os.path.join(BASE_DIR, '..', 'web_source', 'brand.html')),
|
||||||
with open(repo_brand_path, 'rb') as f:
|
]
|
||||||
raw = f.read()
|
for candidate_path in candidate_paths:
|
||||||
try:
|
if not os.path.isfile(candidate_path):
|
||||||
from html_crypto import decrypt
|
continue
|
||||||
content = decrypt(raw).decode('utf-8')
|
try:
|
||||||
except Exception:
|
with open(candidate_path, 'rb') as f:
|
||||||
if raw[:7] == b'gAAAAAB':
|
raw = f.read()
|
||||||
raise
|
try:
|
||||||
content = raw.decode('utf-8', errors='replace')
|
from html_crypto import decrypt
|
||||||
return render_template_string(content, user_id=session.get('user_id'))
|
content = decrypt(raw).decode('utf-8')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
if raw[:7] == b'gAAAAAB':
|
||||||
return _render_html('brand.html', user_id=session.get('user_id'))
|
raise
|
||||||
|
content = raw.decode('utf-8', errors='replace')
|
||||||
|
return render_template_string(content, user_id=session.get('user_id'))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return _render_html('brand.html', user_id=session.get('user_id'))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/brand/legacy')
|
@main_bp.route('/brand/legacy')
|
||||||
@@ -137,12 +152,19 @@ def serve_static(filename):
|
|||||||
def serve_assets(filename):
|
def serve_assets(filename):
|
||||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||||
filename = _resolve_asset_filename(filename)
|
filename = _resolve_asset_filename(filename)
|
||||||
filepath = os.path.normpath(os.path.join(ASSETS_DIR, filename))
|
candidate_dirs = [
|
||||||
static_abs = os.path.abspath(ASSETS_DIR)
|
os.path.abspath(ASSETS_DIR),
|
||||||
file_abs = os.path.abspath(filepath)
|
os.path.abspath(os.path.join(BASE_DIR, 'new_web_source', 'assets')),
|
||||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
os.path.abspath(os.path.join(BASE_DIR, '..', 'new_web_source', 'assets')),
|
||||||
return '', 404
|
]
|
||||||
return send_file(file_abs, as_attachment=False)
|
for static_abs in candidate_dirs:
|
||||||
|
filepath = os.path.normpath(os.path.join(static_abs, filename))
|
||||||
|
file_abs = os.path.abspath(filepath)
|
||||||
|
if not file_abs.startswith(static_abs):
|
||||||
|
continue
|
||||||
|
if os.path.isfile(file_abs):
|
||||||
|
return send_file(file_abs, as_attachment=False)
|
||||||
|
return '', 404
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/new_web_source/<path:filename>')
|
@main_bp.route('/new_web_source/<path:filename>')
|
||||||
|
|||||||
@@ -302,6 +302,23 @@ def test_switching_countries_clicks_and_verifies_target(monkeypatch):
|
|||||||
assert target.clicks == 1
|
assert target.clicks == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_country_dropdown_waits_for_delayed_country_list(monkeypatch):
|
||||||
|
monkeypatch.setattr(base.time, "sleep", lambda seconds: None)
|
||||||
|
dropdown = FakeElement()
|
||||||
|
first_item = FakeElement()
|
||||||
|
driver = base.AmamzonBase({})
|
||||||
|
driver.tab = FakeTab(
|
||||||
|
ele_returns={
|
||||||
|
'xpath://div[@class="dropdown-account-switcher-header-label"]': [dropdown],
|
||||||
|
'xpath://div[@class="dropdown-account-switcher-list-item"]': [None, first_item],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert driver._open_country_dropdown() is True
|
||||||
|
assert dropdown.clicks == 2
|
||||||
|
assert first_item.clicks == 1
|
||||||
|
|
||||||
|
|
||||||
def test_login_returns_true_when_otp_submit_succeeds(monkeypatch):
|
def test_login_returns_true_when_otp_submit_succeeds(monkeypatch):
|
||||||
monkeypatch.setattr(base.time, "sleep", lambda seconds: None)
|
monkeypatch.setattr(base.time, "sleep", lambda seconds: None)
|
||||||
password_input = FakeElement()
|
password_input = FakeElement()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from amazon.patrol_delete import InventoryManage, ProductTask
|
from amazon.patrol_delete import InventoryManage, PatrolDeleteTask
|
||||||
|
|
||||||
|
|
||||||
def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch):
|
def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch):
|
||||||
@@ -8,6 +8,9 @@ def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch
|
|||||||
status_code = 200
|
status_code = 200
|
||||||
text = '{"success":true}'
|
text = '{"success":true}'
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
def fake_post(url, **kwargs):
|
def fake_post(url, **kwargs):
|
||||||
captured["url"] = url
|
captured["url"] = url
|
||||||
captured["kwargs"] = kwargs
|
captured["kwargs"] = kwargs
|
||||||
@@ -24,7 +27,7 @@ def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch
|
|||||||
]
|
]
|
||||||
cart_ratios = [{"country": "德国", "ratio": "25%"}]
|
cart_ratios = [{"country": "德国", "ratio": "25%"}]
|
||||||
|
|
||||||
ProductTask().post_result(
|
PatrolDeleteTask().post_result(
|
||||||
task_id=3089,
|
task_id=3089,
|
||||||
shop_name="郭亚芳",
|
shop_name="郭亚芳",
|
||||||
country_sections=country_sections,
|
country_sections=country_sections,
|
||||||
@@ -50,6 +53,130 @@ def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch
|
|||||||
assert captured["kwargs"]["json"]["shops"][0]["submissionId"].startswith("patrol-delete:3089:郭亚芳:")
|
assert captured["kwargs"]["json"]["shops"][0]["submissionId"].startswith("patrol-delete:3089:郭亚芳:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_patrol_delete_task_post_result_rejects_empty_success_payload(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_post(url, **kwargs):
|
||||||
|
calls.append((url, kwargs))
|
||||||
|
raise AssertionError("empty success payload must not be posted")
|
||||||
|
|
||||||
|
monkeypatch.setattr("amazon.patrol_delete.requests.post", fake_post)
|
||||||
|
|
||||||
|
try:
|
||||||
|
PatrolDeleteTask().post_result(task_id=3089, shop_name="郭亚芳", country_sections=[], cart_ratios=[])
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert "缺少国家状态数据" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected empty success payload to fail")
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_patrol_delete_task_post_result_retries_business_failure(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
status_code = 200
|
||||||
|
text = '{"success":false,"message":"bad payload"}'
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"success": False, "message": "bad payload"}
|
||||||
|
|
||||||
|
def fake_post(url, **kwargs):
|
||||||
|
calls.append((url, kwargs))
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
monkeypatch.setattr("config.DELETE_BRAND_API_BASE", "http://java.example")
|
||||||
|
monkeypatch.setattr("amazon.patrol_delete.requests.post", fake_post)
|
||||||
|
monkeypatch.setattr("amazon.patrol_delete.time.sleep", lambda seconds: None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
PatrolDeleteTask().post_result(
|
||||||
|
task_id=3089,
|
||||||
|
shop_name="郭亚芳",
|
||||||
|
country_sections=[{"country": "德国", "rows": [{"status": "全部", "quantity": "1"}]}],
|
||||||
|
cart_ratios=[{"country": "德国", "ratio": ""}],
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert "最终失败" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected Java business failure to fail")
|
||||||
|
|
||||||
|
assert len(calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_country_sections_omits_status_fields_for_complete_draft_rows():
|
||||||
|
rows = PatrolDeleteTask._normalize_country_sections_for_result(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"country": "意大利",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"status": "商品信息草稿",
|
||||||
|
"quantity": "12",
|
||||||
|
"deleteQuantity": "3",
|
||||||
|
"processStatus": "已完成",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "缺少的信息",
|
||||||
|
"quantity": "1",
|
||||||
|
"deleteQuantity": "1",
|
||||||
|
"processStatus": "已完成",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rows == [
|
||||||
|
{
|
||||||
|
"country": "意大利",
|
||||||
|
"rows": [
|
||||||
|
{"quantity": "12", "deleteQuantity": "3"},
|
||||||
|
{"status": "缺少的信息", "quantity": "1", "deleteQuantity": "1", "processStatus": "已完成"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_complete_draft_rows_uses_quick_view_rows():
|
||||||
|
country_section = {
|
||||||
|
"country": "西班牙",
|
||||||
|
"rows": [
|
||||||
|
{"status": "全部", "quantity": "6230", "deleteQuantity": "0", "processStatus": "无可删商品"},
|
||||||
|
{"status": "商品信息草稿", "quantity": "", "deleteQuantity": "0", "processStatus": ""},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
complete_draft_section = {
|
||||||
|
"country": "西班牙",
|
||||||
|
"rows": [
|
||||||
|
{"type": "completeDraft", "quantity": "1", "deleteQuantity": "1"},
|
||||||
|
{"type": "completeDraft", "quantity": "0", "deleteQuantity": "0"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = PatrolDeleteTask._replace_complete_draft_rows(country_section, complete_draft_section)
|
||||||
|
|
||||||
|
assert merged == {
|
||||||
|
"country": "西班牙",
|
||||||
|
"rows": [
|
||||||
|
{"status": "全部", "quantity": "6230", "deleteQuantity": "0", "processStatus": "无可删商品"},
|
||||||
|
{"type": "completeDraft", "quantity": "1", "deleteQuantity": "1"},
|
||||||
|
{"type": "completeDraft", "quantity": "0", "deleteQuantity": "0"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert PatrolDeleteTask._normalize_country_sections_for_result([merged]) == [
|
||||||
|
{
|
||||||
|
"country": "西班牙",
|
||||||
|
"rows": [
|
||||||
|
{"status": "全部", "quantity": "6230", "deleteQuantity": "0", "processStatus": "无可删商品"},
|
||||||
|
{"quantity": "1", "deleteQuantity": "1"},
|
||||||
|
{"quantity": "0", "deleteQuantity": "0"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_patrol_delete_task_process_task_aggregates_country_results(monkeypatch):
|
def test_patrol_delete_task_process_task_aggregates_country_results(monkeypatch):
|
||||||
from config import runing_shop, runing_task
|
from config import runing_shop, runing_task
|
||||||
|
|
||||||
@@ -104,16 +231,16 @@ def test_patrol_delete_task_process_task_aggregates_country_results(monkeypatch)
|
|||||||
"reopenRequired": True,
|
"reopenRequired": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(ProductTask, "open_shop", fake_open_shop)
|
monkeypatch.setattr(PatrolDeleteTask, "open_shop", fake_open_shop)
|
||||||
monkeypatch.setattr(ProductTask, "process_country", fake_process_country)
|
monkeypatch.setattr(PatrolDeleteTask, "process_country", fake_process_country)
|
||||||
monkeypatch.setattr(ProductTask, "post_result", fake_post_result)
|
monkeypatch.setattr(PatrolDeleteTask, "post_result", fake_post_result)
|
||||||
|
|
||||||
country_sections = [
|
country_sections = [
|
||||||
{"country": "德国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]},
|
{"country": "德国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]},
|
||||||
{"country": "英国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]},
|
{"country": "英国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]},
|
||||||
]
|
]
|
||||||
cart_ratios = [{"country": "德国", "ratio": ""}, {"country": "英国", "ratio": ""}]
|
cart_ratios = [{"country": "德国", "ratio": ""}, {"country": "英国", "ratio": ""}]
|
||||||
ProductTask().process_task(
|
PatrolDeleteTask().process_task(
|
||||||
{
|
{
|
||||||
"type": "patrol-delete-run",
|
"type": "patrol-delete-run",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -173,10 +300,10 @@ def test_patrol_delete_task_process_task_skips_missing_required_data(monkeypatch
|
|||||||
def fake_post_result(self, **kwargs):
|
def fake_post_result(self, **kwargs):
|
||||||
calls.append(kwargs)
|
calls.append(kwargs)
|
||||||
|
|
||||||
monkeypatch.setattr(ProductTask, "post_result", fake_post_result)
|
monkeypatch.setattr(PatrolDeleteTask, "post_result", fake_post_result)
|
||||||
|
|
||||||
ProductTask().process_task({"type": "patrol-delete-run", "data": {"items": [{"shopName": "郭亚芳"}]}})
|
PatrolDeleteTask().process_task({"type": "patrol-delete-run", "data": {"items": [{"shopName": "郭亚芳"}]}})
|
||||||
ProductTask().process_task({"type": "patrol-delete-run", "data": {"taskId": 1, "items": []}})
|
PatrolDeleteTask().process_task({"type": "patrol-delete-run", "data": {"taskId": 1, "items": []}})
|
||||||
|
|
||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
@@ -194,10 +321,10 @@ def test_patrol_delete_task_reports_shop_error(monkeypatch):
|
|||||||
def fake_open_shop(self, **kwargs):
|
def fake_open_shop(self, **kwargs):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(ProductTask, "open_shop", fake_open_shop)
|
monkeypatch.setattr(PatrolDeleteTask, "open_shop", fake_open_shop)
|
||||||
monkeypatch.setattr(ProductTask, "post_result", fake_post_result)
|
monkeypatch.setattr(PatrolDeleteTask, "post_result", fake_post_result)
|
||||||
|
|
||||||
ProductTask().process_task(
|
PatrolDeleteTask().process_task(
|
||||||
{
|
{
|
||||||
"type": "patrol-delete-run",
|
"type": "patrol-delete-run",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -216,91 +343,121 @@ def test_patrol_delete_task_reports_shop_error(monkeypatch):
|
|||||||
assert calls == [{"task_id": 2, "shop_name": "郭亚芳", "error": "店铺 郭亚芳 打开失败", "shop_done": True}]
|
assert calls == [{"task_id": 2, "shop_name": "郭亚芳", "error": "店铺 郭亚芳 打开失败", "shop_done": True}]
|
||||||
|
|
||||||
|
|
||||||
def test_delete_all_listings_from_non_whitelisted_statuses_loops_until_no_candidates(monkeypatch):
|
def test_delete_all_listings_from_non_whitelisted_statuses_deletes_each_row_in_status(monkeypatch):
|
||||||
driver = InventoryManage({})
|
driver = InventoryManage({})
|
||||||
sections = [
|
status_rows = [
|
||||||
[
|
[
|
||||||
{
|
{"status": "商品信息草稿", "quantity": "2", "deleteQuantity": "", "processStatus": ""},
|
||||||
"country": "德国",
|
{"status": "全部", "quantity": "9", "deleteQuantity": "", "processStatus": ""},
|
||||||
"rows": [
|
|
||||||
{"status": "商品信息草稿", "quantity": "2", "deleteQuantity": "", "processStatus": ""},
|
|
||||||
{"status": "全部", "quantity": "9", "deleteQuantity": "", "processStatus": ""},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""},
|
||||||
"country": "德国",
|
|
||||||
"rows": [
|
|
||||||
{"status": "商品信息草稿", "quantity": "1", "deleteQuantity": "", "processStatus": ""},
|
|
||||||
{"status": "全部", "quantity": "8", "deleteQuantity": "", "processStatus": ""},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"country": "德国",
|
|
||||||
"rows": [
|
|
||||||
{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"country": "德国",
|
|
||||||
"rows": [
|
|
||||||
{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
state = {"index": 0, "selected": [], "deleted": 0}
|
state = {"index": 0, "selected": [], "deleted": 0}
|
||||||
|
|
||||||
def fake_get_listing_status_country_sections(shop_name, country):
|
def fake_read_listing_status_rows_for_delete(shop_name, country):
|
||||||
current = sections[state["index"]]
|
current = status_rows[min(state["index"], len(status_rows) - 1)]
|
||||||
state["index"] += 1
|
state["index"] += 1
|
||||||
return current
|
return current
|
||||||
|
|
||||||
def fake_select_listing_status(status):
|
def fake_select_listing_status(status):
|
||||||
state["selected"].append(status)
|
state["selected"].append(status)
|
||||||
|
|
||||||
def fake_get_first_inventory_row(status):
|
def fake_delete_selected_filtered_inventory_rows(status):
|
||||||
return {"sku": f"sku-{state['deleted'] + 1}"}
|
state["deleted"] += 2
|
||||||
|
return {
|
||||||
|
"deletedCount": 2,
|
||||||
|
"target": {"sku": "bulk-selected", "rawText": "bulk-selected"},
|
||||||
|
"successMessage": f"{status} 删除成功",
|
||||||
|
}
|
||||||
|
|
||||||
def fake_summarize_inventory_row(row):
|
monkeypatch.setattr(driver, "_read_listing_status_rows_for_delete", fake_read_listing_status_rows_for_delete)
|
||||||
return {"sku": row["sku"], "rawText": row["sku"]}
|
|
||||||
|
|
||||||
def fake_delete_inventory_row(row, status):
|
|
||||||
state["deleted"] += 1
|
|
||||||
return f"{status} 删除成功"
|
|
||||||
|
|
||||||
monkeypatch.setattr(driver, "get_listing_status_country_sections", fake_get_listing_status_country_sections)
|
|
||||||
monkeypatch.setattr(driver, "select_listing_status", fake_select_listing_status)
|
monkeypatch.setattr(driver, "select_listing_status", fake_select_listing_status)
|
||||||
monkeypatch.setattr(driver, "_get_first_inventory_row", fake_get_first_inventory_row)
|
monkeypatch.setattr(driver, "_delete_selected_filtered_inventory_rows", fake_delete_selected_filtered_inventory_rows)
|
||||||
monkeypatch.setattr(driver, "_summarize_inventory_row", fake_summarize_inventory_row)
|
|
||||||
monkeypatch.setattr(driver, "_delete_inventory_row", fake_delete_inventory_row)
|
|
||||||
monkeypatch.setattr(driver, "_wait_inventory_loader", lambda timeout=5: None)
|
monkeypatch.setattr(driver, "_wait_inventory_loader", lambda timeout=5: None)
|
||||||
|
|
||||||
result = driver.delete_all_listings_from_non_whitelisted_statuses("郭亚芳", "德国")
|
result = driver.delete_all_listings_from_non_whitelisted_statuses("郭亚芳", "德国")
|
||||||
|
|
||||||
assert state["selected"] == ["商品信息草稿", "商品信息草稿"]
|
assert state["selected"] == ["商品信息草稿"]
|
||||||
assert state["deleted"] == 2
|
assert state["deleted"] == 2
|
||||||
assert result["deleteCounts"] == {"商品信息草稿": 2}
|
assert result["deleteCounts"] == {"商品信息草稿": 2}
|
||||||
assert result["totalDeleted"] == 2
|
assert result["totalDeleted"] == 2
|
||||||
assert result["stopReason"] == "no-candidates"
|
assert result["stopReason"] == "processed-candidates"
|
||||||
assert result["statusRows"] == [{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""}]
|
assert result["statusRows"] == [{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""}]
|
||||||
assert [item["target"]["sku"] for item in result["results"]] == ["sku-1", "sku-2"]
|
assert [item["target"]["sku"] for item in result["results"]] == ["bulk-selected"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_listing_status_uses_dropdown_option_not_visible_status(monkeypatch):
|
||||||
|
driver = InventoryManage({})
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(driver, "open_listing_status_dropdown", lambda: calls.append("open") or {"ok": True})
|
||||||
|
monkeypatch.setattr(driver, "_wait_inventory_loader", lambda timeout=5: None)
|
||||||
|
monkeypatch.setattr(driver, "_run_js", lambda script, *args: calls.append((script, args)) or {"ok": True})
|
||||||
|
monkeypatch.setattr("amazon.patrol_delete.time.sleep", lambda seconds: None)
|
||||||
|
|
||||||
|
result = driver.select_listing_status("商品信息草稿")
|
||||||
|
|
||||||
|
assert result["mode"] == "dropdown-option"
|
||||||
|
assert calls[0] == "open"
|
||||||
|
assert calls[-1][1] == ({"status": "商品信息草稿"},)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_listing_status_dropdown_options_closes_dropdown_after_read(monkeypatch):
|
||||||
|
driver = InventoryManage({})
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(driver, "open_listing_status_dropdown", lambda: calls.append("open") or {"ok": True})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
driver,
|
||||||
|
"_wait_listing_status_option_rows",
|
||||||
|
lambda: calls.append("read") or [{"raw_text": "详情页面已删除 (1)", "value": "DetailPageRemoved"}],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(driver, "_close_listing_status_dropdown", lambda: calls.append("close"))
|
||||||
|
|
||||||
|
rows = driver.get_listing_status_dropdown_options("郭亚芳", "德国")
|
||||||
|
|
||||||
|
assert calls == ["open", "read", "close"]
|
||||||
|
assert rows[0]["status"] == "详情页面已删除"
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_listing_status_dropdown_waits_until_component_enabled(monkeypatch):
|
||||||
|
driver = InventoryManage({})
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Wait:
|
||||||
|
def doc_loaded(self, raise_err=False):
|
||||||
|
calls.append("doc_loaded")
|
||||||
|
|
||||||
|
class Tab:
|
||||||
|
wait = Wait()
|
||||||
|
|
||||||
|
responses = [
|
||||||
|
{"ok": False, "reason": "listing status dropdown disabled/loading"},
|
||||||
|
{"ok": True, "selected": {"text": "全部在售不可售商品信息草稿"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
driver.tab = Tab()
|
||||||
|
monkeypatch.setattr(driver, "_wait_inventory_loader", lambda timeout=5: calls.append(("loader", timeout)))
|
||||||
|
monkeypatch.setattr(driver, "_run_js", lambda script: calls.append("js") or responses.pop(0))
|
||||||
|
monkeypatch.setattr("amazon.patrol_delete.time.sleep", lambda seconds: calls.append(("sleep", seconds)))
|
||||||
|
|
||||||
|
result = driver.open_listing_status_dropdown()
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert calls.count("js") == 2
|
||||||
|
|
||||||
|
|
||||||
def test_extract_country_cart_ratio_prefers_ratio2daysago_only():
|
def test_extract_country_cart_ratio_prefers_ratio2daysago_only():
|
||||||
ratio = ProductTask._extract_country_cart_ratio(
|
ratio = PatrolDeleteTask._extract_country_cart_ratio(
|
||||||
[
|
[
|
||||||
{"country": "德国", "ratio2DaysAgo": "25%", "ratio30DaysAgo": "19%"},
|
{"country": "德国", "ratio2DaysAgo": "25%", "ratio30DaysAgo": "19%"},
|
||||||
{"country": "英国", "ratio30DaysAgo": "40%"},
|
{"country": "英国", "ratio30DaysAgo": "40%"},
|
||||||
],
|
],
|
||||||
"德国",
|
"德国",
|
||||||
)
|
)
|
||||||
missing_ratio2 = ProductTask._extract_country_cart_ratio(
|
missing_ratio2 = PatrolDeleteTask._extract_country_cart_ratio(
|
||||||
[{"country": "英国", "ratio30DaysAgo": "40%"}],
|
[{"country": "英国", "ratio30DaysAgo": "40%"}],
|
||||||
"英国",
|
"英国",
|
||||||
)
|
)
|
||||||
@@ -455,6 +612,20 @@ def test_build_deletable_status_rows_keeps_non_whitelisted_statuses():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_deletable_status_rows_skips_group_labels_without_quantity():
|
||||||
|
rows = [
|
||||||
|
{"status": "商品信息草稿", "quantity": ""},
|
||||||
|
{"status": "详情页面已删除", "quantity": None},
|
||||||
|
{"status": "自定义异常状态", "quantity": "0"},
|
||||||
|
{"status": "缺少的信息", "quantity": "1"},
|
||||||
|
{"status": "全部", "quantity": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert InventoryManage._build_deletable_status_rows(rows) == [
|
||||||
|
{"status": "缺少的信息", "canonicalStatus": "缺少的信息", "quantity": 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_build_deletable_status_rows_compatibility_map_prevents_whitelist_misdelete():
|
def test_build_deletable_status_rows_compatibility_map_prevents_whitelist_misdelete():
|
||||||
rows = [
|
rows = [
|
||||||
{"status": "搜尋結果中禁止顯示", "quantity": "4"},
|
{"status": "搜尋結果中禁止顯示", "quantity": "4"},
|
||||||
@@ -480,20 +651,43 @@ def test_build_deletable_status_rows_keeps_legacy_non_whitelist_statuses_deletab
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_limit_delete_candidates_enforces_single_delete_cap():
|
|
||||||
candidates = [
|
|
||||||
{"status": "商品信息草稿", "canonicalStatus": "商品信息草稿", "quantity": 2},
|
|
||||||
{"status": "自定义异常状态", "canonicalStatus": "自定义异常状态", "quantity": 5},
|
|
||||||
]
|
|
||||||
|
|
||||||
assert InventoryManage._limit_delete_candidates(candidates, max_delete_count=1) == [candidates[0]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_delete_success_message_accepts_both_copy_variants():
|
def test_is_delete_success_message_accepts_both_copy_variants():
|
||||||
assert InventoryManage._is_delete_success_message("1 个商品已经删除。所作更改需要15分钟才会显示在商品详情页面上")
|
assert InventoryManage._is_delete_success_message("1 个商品已经删除。所作更改需要15分钟才会显示在商品详情页面上")
|
||||||
assert InventoryManage._is_delete_success_message("所做更改需要 15 分钟才会显示在商品详情页面上。 1 个商品已删除。")
|
assert InventoryManage._is_delete_success_message("所做更改需要 15 分钟才会显示在商品详情页面上。 1 个商品已删除。")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_delete_success_count_accepts_success_message():
|
||||||
|
assert InventoryManage._extract_delete_success_count("所做更改需要 15 分钟才会显示在商品详情页面上。 3 个商品已删除。") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_country_section_result_sets_zero_delete_quantity_when_no_deletable_products():
|
||||||
|
section = PatrolDeleteTask._build_country_section_result(
|
||||||
|
country_name="德国",
|
||||||
|
status_rows=[{"status": "全部", "quantity": "12"}],
|
||||||
|
delete_counts={},
|
||||||
|
default_process_status="无可删商品",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert section == {
|
||||||
|
"country": "德国",
|
||||||
|
"rows": [{"status": "全部", "quantity": "12", "deleteQuantity": "0", "processStatus": "无可删商品"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_country_section_result_sets_zero_delete_quantity_for_empty_no_deletable_result():
|
||||||
|
section = PatrolDeleteTask._build_country_section_result(
|
||||||
|
country_name="德国",
|
||||||
|
status_rows=[],
|
||||||
|
delete_counts={},
|
||||||
|
default_process_status="无可删商品",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert section == {
|
||||||
|
"country": "德国",
|
||||||
|
"rows": [{"status": "全部", "quantity": "", "deleteQuantity": "0", "processStatus": "无可删商品"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_parse_label_quantity_text_accepts_complete_draft_sample():
|
def test_parse_label_quantity_text_accepts_complete_draft_sample():
|
||||||
assert InventoryManage._parse_label_quantity_text("未提交的草稿 (0)") == ("未提交的草稿", 0)
|
assert InventoryManage._parse_label_quantity_text("未提交的草稿 (0)") == ("未提交的草稿", 0)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user