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
188 lines
6.4 KiB
JavaScript
188 lines
6.4 KiB
JavaScript
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),
|
|
};
|