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:
koko
2026-04-28 20:46:50 +08:00
parent 8be85fd332
commit 325687d532
14 changed files with 1062 additions and 170 deletions

View File

@@ -36,6 +36,8 @@ COUNTRY_INITIAL_LOAD_TIMEOUT = 120
COUNTRY_LOOKUP_TIMEOUT = 20
COUNTRY_CLICK_TIMEOUT = 10
COUNTRY_DROPDOWN_DELAY = 1
COUNTRY_DROPDOWN_READY_TIMEOUT = 20
COUNTRY_DROPDOWN_RETRY_INTERVAL = 0.5
IP_CHECK_TIMEOUT = 60
LOGIN_ATTEMPTS = 4
@@ -585,18 +587,36 @@ class AmamzonBase(ZiniaoDriver):
if not dropdown_header:
logger.warning("找不到国家切换下拉框")
return False
dropdown_header.click()
time.sleep(COUNTRY_DROPDOWN_DELAY)
logger.info("正在展开国家列表")
first_item = self.tab.ele(COUNTRY_LIST_ITEM_XPATH, timeout=COUNTRY_CLICK_TIMEOUT)
if not first_item:
first_item = self._wait_country_list_item(dropdown_header)
if first_item is None:
logger.warning("找不到国家列表项")
return False
first_item.click()
time.sleep(COUNTRY_DROPDOWN_DELAY)
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:
logger.info("正在切换到国家:{}", country_name)
target_country = self.tab.ele(self._country_option_xpath(country_name), timeout=COUNTRY_CLICK_TIMEOUT)

View File

@@ -4,17 +4,17 @@ import requests
from datetime import datetime
from typing import Dict, Any, List
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 amazon.del_brand import AmazoneDriver, kill_process
from amazon.approve import ApproveTask
from amazon.match_action import MatchTak
from amazon.price_match import PriceTask
from amazon.asin_status import StatusTask
from amazon.patrol_delete import ProductTask
from amazon.detail_spider import SpiderTask
from amazon.tool import get_shop_info,show_notification
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.approve import ApproveTask
from amazon.match_action import MatchTak
from amazon.price_match import PriceTask
from amazon.asin_status import StatusTask
from amazon.patrol_delete import PatrolDeleteTask
from amazon.detail_spider import SpiderTask
from amazon.tool import get_shop_info,show_notification
class TaskMonitor:
@@ -55,15 +55,15 @@ class TaskMonitor:
self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
futures = [] # 保存所有提交的任务Future对象
task_type_info = {
"product-risk-resolve-run" : "产品风险审批",
"shop-match-run" : "匹配价格",
"price-track-run" : "跟价",
"query-asin-run" : "状态查询",
"patrol-delete-run" : "巡店删除",
"appearance-patent-run" : "亚马逊采集",
}
try:
task_type_info = {
"product-risk-resolve-run" : "产品风险审批",
"shop-match-run" : "匹配价格",
"price-track-run" : "跟价",
"query-asin-run" : "状态查询",
"patrol-delete-run" : "巡店删除",
"appearance-patent-run" : "亚马逊采集",
}
try:
while self.running:
try:
# 使用较长超时时间等待任务,减少空等待异常
@@ -125,14 +125,14 @@ class TaskMonitor:
task_data: 任务数据
"""
try:
TASK_INFO = {
"产品风险审批" : ApproveTask,
"匹配价格" : MatchTak,
"跟价" : PriceTask,
"状态查询" : StatusTask,
"巡店删除" : ProductTask,
"亚马逊采集" : SpiderTask,
}
TASK_INFO = {
"产品风险审批" : ApproveTask,
"匹配价格" : MatchTak,
"跟价" : PriceTask,
"状态查询" : StatusTask,
"巡店删除" : PatrolDeleteTask,
"亚马逊采集" : SpiderTask,
}
self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...")
# 创建ApproveTask实例并处理任务
TASK_CLS = TASK_INFO[TASK_TYPE] # 根据任务类型选择处理类默认为ApproveTask

View File

@@ -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,
},
};

View 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);

View 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),
};

View 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),
},
};

View File

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

View File

@@ -2,11 +2,32 @@ function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
function normalize(text) {
return String(text || '').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 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) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
@@ -50,9 +71,25 @@ function nearestLabel(el) {
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) {
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;
}
@@ -60,18 +97,24 @@ function statusHitCount(text) {
function scoreDropdown(el) {
const text = textOf(el);
const label = nearestLabel(el);
const haystack = `${el.tagName} ${attrsText(el)} ${text} ${label}`;
const statusHits = statusHitCount(`${text} ${label}`);
const attrs = attrsText(el);
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;
if (isSearchFieldDropdown(haystack)) score -= 1000;
if (isStatusDropdown) score += 500;
if (/商品状态|商品狀態|ListingStatus|listing status/i.test(haystack)) score += 120;
if (/\bstatus\b|状态/i.test(haystack)) score += 25;
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 (/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 (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) {
@@ -174,6 +217,32 @@ if (!ranked.length) {
}
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');
const clickResult = clickTrigger(selected.el);
@@ -217,6 +286,7 @@ return {
index: row.index,
score: row.score,
statusHits: row.statusHits,
disabled: row.disabled,
text: row.text,
label: row.label,
attrs: attrsText(row.el),

View File

@@ -35,10 +35,48 @@ function allElements() {
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 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) {
const attrs = attrsOf(el);
const rawText = textOf(el) || attrs.label || attrs.text || attrs.value || '';
@@ -46,33 +84,46 @@ function optionRow(el, index) {
index,
raw_text: rawText,
value: attrs.value || attrs['data-value'] || attrs.name || null,
tag: (el.tagName || '').toLowerCase(),
attrs,
visible: isVisible(el),
outerHTML: (el.outerHTML || '').slice(0, 1200),
};
}
const markedDropdown = document.querySelector('[data-crawler-listing-status-dropdown="true"]');
const markedDropdown = findMarkedDropdown();
let scoped = [];
if (markedDropdown) {
const roots = [markedDropdown];
if (markedDropdown.shadowRoot) roots.push(markedDropdown.shadowRoot);
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 tag = el.tagName.toLowerCase();
const role = el.getAttribute('role');
if (tag !== 'kat-option' && role !== 'option') return false;
if (!isOptionLike(el)) return false;
if (insideListingRow(el)) return false;
return isVisible(el);
});
const byNode = new Map();
const byStatus = new Map();
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);

View File

@@ -1,3 +1,5 @@
const __crawlerPayload = arguments[0] || {};
function textOf(node) {
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';
}
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) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
@@ -31,24 +86,36 @@ function optionMeta(el, index) {
};
}
const targetStatus = normalize((__crawlerPayload || {}).status);
const dropdown = document.querySelector('[data-crawler-listing-status-dropdown="true"]');
if (!dropdown) {
return { ok: false, reason: 'marked dropdown missing', targetStatus };
function clickTargetFor(el) {
if (!el) return el;
return el.closest('[class*="parentKatOptionStyle"], kat-option, [role="option"]') || el;
}
const roots = [dropdown];
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
const targetStatus = normalize((__crawlerPayload || {}).status);
const dropdown = findMarkedDropdown();
const options = [];
for (const root of roots) {
for (const el of Array.from(root.querySelectorAll('kat-option, [role="option"]'))) {
if (!isVisible(el)) continue;
if (dropdown) {
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);
}
}
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);
if (!matched) {
return {
@@ -65,8 +132,9 @@ if (!matched) {
};
}
matched.el.scrollIntoView({ block: 'center', inline: 'nearest' });
matched.el.click();
const clickTarget = clickTargetFor(matched.el);
clickTarget.scrollIntoView({ block: 'center', inline: 'nearest' });
clickTarget.click();
return {
ok: true,
targetStatus,
@@ -75,5 +143,6 @@ return {
rawText: matched.rawText,
parsedStatus: matched.parsedStatus,
value: matched.value,
clickedTag: clickTarget.tagName,
},
};

View File

@@ -224,6 +224,64 @@ function scoreRegion(el) {
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 expandedCard = expandedCardOf(visible);
const expandedDirectRows = directRowsFromExpandedCard(expandedCard);
@@ -245,15 +303,10 @@ const scoped = [];
sourceElements.forEach((root) => walkRoots(root, scoped, new Set()));
const byText = new Map();
scoped
.filter((el) => isVisible(el))
.forEach((el, index) => {
const row = rowOf(el, index);
collectRelevantRows(sourceElements)
.concat(collectRowsAcrossScroll(sourceElements, visible))
.forEach((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;
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)}`;
if (!byText.has(key)) byText.set(key, row);
});

View File

@@ -26,9 +26,15 @@ main_bp = Blueprint('main', __name__)
def _resolve_asset_filename(filename):
"""Resolve hashed Vite assets without maintaining hardcoded alias tables."""
safe_name = os.path.basename(filename)
exact_path = os.path.join(ASSETS_DIR, safe_name)
if os.path.isfile(exact_path):
return safe_name
asset_dirs = [
ASSETS_DIR,
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)
if not base_name or not ext:
@@ -38,9 +44,13 @@ def _resolve_asset_filename(filename):
if len(parts) < 2:
return safe_name
try:
asset_names = os.listdir(ASSETS_DIR)
except OSError:
asset_names = []
for asset_dir in asset_dirs:
try:
asset_names.extend(os.listdir(asset_dir))
except OSError:
continue
if not asset_names:
return safe_name
for prefix_length in range(len(parts) - 1, 0, -1):
@@ -82,25 +92,30 @@ def wb():
return _render_html('index.html')
@main_bp.route('/brand')
@login_required
def brand_page():
repo_brand_path = os.path.abspath(os.path.join(BASE_DIR, '..', 'web_source', 'brand.html'))
if os.path.isfile(repo_brand_path):
try:
with open(repo_brand_path, 'rb') as f:
raw = f.read()
try:
from html_crypto import decrypt
content = decrypt(raw).decode('utf-8')
except Exception:
if raw[:7] == b'gAAAAAB':
raise
content = raw.decode('utf-8', errors='replace')
return render_template_string(content, user_id=session.get('user_id'))
except Exception:
pass
return _render_html('brand.html', user_id=session.get('user_id'))
@main_bp.route('/brand')
@login_required
def brand_page():
candidate_paths = [
os.path.join(BASE_DIR, 'web_source', 'brand.html'),
os.path.abspath(os.path.join(BASE_DIR, '..', 'web_source', 'brand.html')),
]
for candidate_path in candidate_paths:
if not os.path.isfile(candidate_path):
continue
try:
with open(candidate_path, 'rb') as f:
raw = f.read()
try:
from html_crypto import decrypt
content = decrypt(raw).decode('utf-8')
except Exception:
if raw[:7] == b'gAAAAAB':
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')
@@ -137,12 +152,19 @@ def serve_static(filename):
def serve_assets(filename):
"""提供 static 目录及子目录下的静态文件访问。"""
filename = _resolve_asset_filename(filename)
filepath = os.path.normpath(os.path.join(ASSETS_DIR, filename))
static_abs = os.path.abspath(ASSETS_DIR)
file_abs = os.path.abspath(filepath)
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
return '', 404
return send_file(file_abs, as_attachment=False)
candidate_dirs = [
os.path.abspath(ASSETS_DIR),
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 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>')

View File

@@ -302,6 +302,23 @@ def test_switching_countries_clicks_and_verifies_target(monkeypatch):
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):
monkeypatch.setattr(base.time, "sleep", lambda seconds: None)
password_input = FakeElement()

View File

@@ -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):
@@ -8,6 +8,9 @@ def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch
status_code = 200
text = '{"success":true}'
def json(self):
return {"success": True}
def fake_post(url, **kwargs):
captured["url"] = url
captured["kwargs"] = kwargs
@@ -24,7 +27,7 @@ def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch
]
cart_ratios = [{"country": "德国", "ratio": "25%"}]
ProductTask().post_result(
PatrolDeleteTask().post_result(
task_id=3089,
shop_name="郭亚芳",
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:郭亚芳:")
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):
from config import runing_shop, runing_task
@@ -104,16 +231,16 @@ def test_patrol_delete_task_process_task_aggregates_country_results(monkeypatch)
"reopenRequired": True,
}
monkeypatch.setattr(ProductTask, "open_shop", fake_open_shop)
monkeypatch.setattr(ProductTask, "process_country", fake_process_country)
monkeypatch.setattr(ProductTask, "post_result", fake_post_result)
monkeypatch.setattr(PatrolDeleteTask, "open_shop", fake_open_shop)
monkeypatch.setattr(PatrolDeleteTask, "process_country", fake_process_country)
monkeypatch.setattr(PatrolDeleteTask, "post_result", fake_post_result)
country_sections = [
{"country": "德国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]},
{"country": "英国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]},
]
cart_ratios = [{"country": "德国", "ratio": ""}, {"country": "英国", "ratio": ""}]
ProductTask().process_task(
PatrolDeleteTask().process_task(
{
"type": "patrol-delete-run",
"data": {
@@ -173,10 +300,10 @@ def test_patrol_delete_task_process_task_skips_missing_required_data(monkeypatch
def fake_post_result(self, **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": "郭亚芳"}]}})
ProductTask().process_task({"type": "patrol-delete-run", "data": {"taskId": 1, "items": []}})
PatrolDeleteTask().process_task({"type": "patrol-delete-run", "data": {"items": [{"shopName": "郭亚芳"}]}})
PatrolDeleteTask().process_task({"type": "patrol-delete-run", "data": {"taskId": 1, "items": []}})
assert calls == []
@@ -194,10 +321,10 @@ def test_patrol_delete_task_reports_shop_error(monkeypatch):
def fake_open_shop(self, **kwargs):
return None
monkeypatch.setattr(ProductTask, "open_shop", fake_open_shop)
monkeypatch.setattr(ProductTask, "post_result", fake_post_result)
monkeypatch.setattr(PatrolDeleteTask, "open_shop", fake_open_shop)
monkeypatch.setattr(PatrolDeleteTask, "post_result", fake_post_result)
ProductTask().process_task(
PatrolDeleteTask().process_task(
{
"type": "patrol-delete-run",
"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}]
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({})
sections = [
status_rows = [
[
{
"country": "德国",
"rows": [
{"status": "商品信息草稿", "quantity": "2", "deleteQuantity": "", "processStatus": ""},
{"status": "全部", "quantity": "9", "deleteQuantity": "", "processStatus": ""},
],
}
{"status": "商品信息草稿", "quantity": "2", "deleteQuantity": "", "processStatus": ""},
{"status": "全部", "quantity": "9", "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": ""},
],
}
{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""},
],
]
state = {"index": 0, "selected": [], "deleted": 0}
def fake_get_listing_status_country_sections(shop_name, country):
current = sections[state["index"]]
def fake_read_listing_status_rows_for_delete(shop_name, country):
current = status_rows[min(state["index"], len(status_rows) - 1)]
state["index"] += 1
return current
def fake_select_listing_status(status):
state["selected"].append(status)
def fake_get_first_inventory_row(status):
return {"sku": f"sku-{state['deleted'] + 1}"}
def fake_delete_selected_filtered_inventory_rows(status):
state["deleted"] += 2
return {
"deletedCount": 2,
"target": {"sku": "bulk-selected", "rawText": "bulk-selected"},
"successMessage": f"{status} 删除成功",
}
def fake_summarize_inventory_row(row):
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, "_read_listing_status_rows_for_delete", fake_read_listing_status_rows_for_delete)
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, "_summarize_inventory_row", fake_summarize_inventory_row)
monkeypatch.setattr(driver, "_delete_inventory_row", fake_delete_inventory_row)
monkeypatch.setattr(driver, "_delete_selected_filtered_inventory_rows", fake_delete_selected_filtered_inventory_rows)
monkeypatch.setattr(driver, "_wait_inventory_loader", lambda timeout=5: None)
result = driver.delete_all_listings_from_non_whitelisted_statuses("郭亚芳", "德国")
assert state["selected"] == ["商品信息草稿", "商品信息草稿"]
assert state["selected"] == ["商品信息草稿"]
assert state["deleted"] == 2
assert result["deleteCounts"] == {"商品信息草稿": 2}
assert result["totalDeleted"] == 2
assert result["stopReason"] == "no-candidates"
assert result["stopReason"] == "processed-candidates"
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():
ratio = ProductTask._extract_country_cart_ratio(
ratio = PatrolDeleteTask._extract_country_cart_ratio(
[
{"country": "德国", "ratio2DaysAgo": "25%", "ratio30DaysAgo": "19%"},
{"country": "英国", "ratio30DaysAgo": "40%"},
],
"德国",
)
missing_ratio2 = ProductTask._extract_country_cart_ratio(
missing_ratio2 = PatrolDeleteTask._extract_country_cart_ratio(
[{"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():
rows = [
{"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():
assert InventoryManage._is_delete_success_message("1 个商品已经删除。所作更改需要15分钟才会显示在商品详情页面上")
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():
assert InventoryManage._parse_label_quantity_text("未提交的草稿 (0)") == ("未提交的草稿", 0)