Align patrol delete module naming

Rename the patrol deletion implementation and browser helper scripts away from the generic product name so the filesystem matches the queued patrol-delete task domain. Update imports, script loading, and tests to use the patrol_delete module path.

Constraint: Existing task class remains ProductTask to avoid broad API churn beyond the requested file and script naming

Rejected: Rename ProductTask class now | would expand the change into a larger API update across tests and task dispatch

Confidence: high

Scope-risk: narrow

Directive: Keep patrol-delete script paths under app/amazon/scripts/patrol_delete when adding new helper scripts

Tested: uv run pytest tests/test_product.py

Not-tested: Remote push before this commit due previous SSH publickey failure
This commit is contained in:
koko
2026-04-27 17:58:40 +08:00
parent 13c3a04a48
commit 8defef904f
15 changed files with 17 additions and 17 deletions

View File

@@ -0,0 +1,55 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
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 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 rowOf(el, index) {
const attrs = attrsOf(el);
return {
index,
tag: el.tagName.toLowerCase(),
visible: isVisible(el),
text: textOf(el),
attrs,
outerHTML: (el.outerHTML || '').slice(0, 2500),
};
}
const candidates = allElements()
.map((el, index) => rowOf(el, index))
.filter((row) => row.visible && /补全草稿|快速查看|quick view|draft/i.test(`${row.tag} ${row.text} ${Object.values(row.attrs).join(' ')}`))
.slice(0, 120);
return candidates;

View File

@@ -0,0 +1,73 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
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 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 attrsText(attrs) {
return Object.entries(attrs).map(([key, value]) => `${key}=${value}`).join(' ');
}
const pattern = /快速查看|quick|draft|草稿|提交|submitted|missing|inventory|myinventory/i;
const elements = allElements()
.map((el, index) => {
const attrs = attrsOf(el);
const text = textOf(el);
return {
index,
tag: el.tagName.toLowerCase(),
visible: isVisible(el),
text: text.slice(0, 500),
attrs,
outerHTML: (el.outerHTML || '').slice(0, 1000),
haystack: `${el.tagName} ${text} ${attrsText(attrs)}`,
};
})
.filter((row) => pattern.test(row.haystack))
.map(({ haystack, ...row }) => row)
.slice(0, 80);
const resources = performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.filter((url) => pattern.test(url))
.slice(-120);
return {
location: window.location.href,
readyState: document.readyState,
capturedResponses: (window.__crawlerCompleteDraftResponses || []).slice(-10),
elements,
resources,
};

View File

@@ -0,0 +1,121 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
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 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 hasCountText(text) {
return /[(]\s*[\d,]+\s*[)]/.test(text);
}
function insideListingRow(el) {
return Boolean(el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]'));
}
function rowOf(el, index) {
const attrs = attrsOf(el);
const text = textOf(el);
const labelText = attrs.label || attrs.title || attrs['aria-label'] || '';
const valueText = attrs.value || attrs.count || attrs['data-count'] || attrs['data-value'] || '';
return {
index,
raw_text: text,
label_text: labelText,
quantity_text: valueText,
attrs,
visible: isVisible(el),
outerHTML: (el.outerHTML || '').slice(0, 1200),
};
}
function splitCountRow(el, index) {
const countText = textOf(el);
if (!/^\s*[\d,]+\s*$/.test(countText)) return null;
let parent = el.parentElement;
for (let depth = 0; parent && depth < 4; depth += 1, parent = parent.parentElement) {
const parentText = textOf(parent);
const labelText = parentText
.replace(new RegExp(`(^|\\s)${countText.replace(/,/g, '\\,')}(\\s|$)`), ' ')
.replace(/[(]\s*[\d,]+\s*[)]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (labelText && labelText !== parentText && !/^\s*[\d,]+\s*$/.test(labelText)) {
return {
index,
raw_text: '',
label_text: labelText,
quantity_text: countText,
attrs: attrsOf(parent),
visible: isVisible(parent),
outerHTML: (parent.outerHTML || '').slice(0, 1200),
};
}
}
return null;
}
const regionRows = allElements().filter((el) => isVisible(el) && !insideListingRow(el));
const byText = new Map();
regionRows
.forEach((el, index) => {
const row = rowOf(el, index);
const combined = `${row.raw_text} ${row.label_text} ${row.quantity_text}`;
if (row.raw_text.length > 200) return;
if (/[(]\s*[\d,]+\s*[)]/.test(combined)) {
const key = combined;
if (!byText.has(key)) byText.set(key, row);
return;
}
const splitRow = splitCountRow(el, index);
if (splitRow) {
const key = `${splitRow.label_text} ${splitRow.quantity_text}`;
if (!byText.has(key)) byText.set(key, splitRow);
}
});
return {
region: {
url: window.location.href,
title: document.title,
},
rows: Array.from(byText.values()).filter((row) => {
const combined = `${row.raw_text} ${row.label_text}`.toLowerCase();
return !/所有商品|启售商品|改善商品信息/.test(combined);
}),
candidates: Array.from(byText.values()).slice(0, 80),
};

View File

@@ -0,0 +1,124 @@
function textOf(node) {
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
}
function normalize(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function 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 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 collectStatusTargets() {
const pattern = /(全部|在搜索结果中禁止显示|配送问题|缺少报价|已停售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|不可售|在售)\s*[(]?\s*[\d,]*\s*[)]?/g;
const hits = [];
for (const el of allElements()) {
if (!isVisible(el)) continue;
const text = normalize(textOf(el));
if (!text) continue;
const match = text.match(pattern);
if (!match) continue;
const parsed = parseStatus(match[0]);
hits.push({
el,
text,
parsed,
tag: el.tagName,
clickable: Boolean(el.onclick) || el.matches('button,[role="button"],kat-option,kat-label,a,[tabindex]'),
});
}
return hits;
}
const targetStatus = normalize((__crawlerPayload || {}).status);
const candidates = collectStatusTargets()
.filter((item) => item.parsed === targetStatus || item.text.includes(targetStatus))
.map((item) => ({
...item,
score:
(item.parsed === targetStatus ? 300 : 0) +
(item.clickable ? 120 : 0) +
(/^[^ ]+\s*[(]\s*[\d,]+\s*[)]$/.test(item.text) ? 80 : 0) +
(/button|option|label/i.test(item.tag) ? 30 : 0),
}))
.sort((a, b) => b.score - a.score);
if (!candidates.length) {
return { ok: false, reason: 'visible status target not found', targetStatus };
}
for (const candidate of candidates) {
const chain = [candidate.el];
let node = candidate.el.parentElement;
for (let depth = 0; node && depth < 5; depth += 1, node = node.parentElement) {
chain.push(node);
}
for (const el of chain) {
try {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
el.click();
return {
ok: true,
targetStatus,
clicked: {
tag: el.tagName,
text: normalize(textOf(el)).slice(0, 300),
},
matched: {
tag: candidate.tag,
text: candidate.text.slice(0, 300),
parsed: candidate.parsed,
score: candidate.score,
},
};
} catch (error) {
// try parent
}
}
}
return {
ok: false,
reason: 'visible status target click failed',
targetStatus,
candidates: candidates.slice(0, 10).map((item) => ({
tag: item.tag,
text: item.text.slice(0, 300),
parsed: item.parsed,
score: item.score,
clickable: item.clickable,
})),
};

View File

@@ -0,0 +1,95 @@
const keywords = ['ListingStatus', 'listing status', '商品状态', '商品狀態', 'status', 'filter', '库存'];
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
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 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 nearestLabel(el) {
const parts = [];
let node = el;
for (let depth = 0; node && depth < 6; depth += 1, node = node.parentElement) {
const text = textOf(node);
if (text) parts.push(text.slice(0, 400));
}
const id = el.getAttribute('id');
if (id) {
document.querySelectorAll(`label[for="${CSS.escape(id)}"]`).forEach((label) => {
const text = textOf(label);
if (text) parts.unshift(text);
});
}
return [...new Set(parts)].join(' | ');
}
function hasKeyword(text) {
return keywords.some((keyword) => text.toLowerCase().includes(keyword.toLowerCase()));
}
function candidateRow(el, index) {
const attrs = attrsOf(el);
const attrText = Object.entries(attrs).map(([k, v]) => `${k}=${v}`).join(' ');
const label = nearestLabel(el);
const text = textOf(el);
const haystack = `${el.tagName} ${attrText} ${text} ${label}`;
let score = 0;
if (/商品状态|商品狀態|ListingStatus|listing status/i.test(haystack)) score += 100;
if (/status/i.test(haystack)) score += 25;
if (/filter|库存/i.test(haystack)) score += 10;
if (isVisible(el)) score += 5;
if (el.closest('[data-sku], tr, [role="row"]')) score -= 50;
return {
index,
score,
tag: el.tagName.toLowerCase(),
visible: isVisible(el),
text,
label,
attrs,
outerHTML: (el.outerHTML || '').slice(0, 2000),
};
}
const rows = allElements();
const candidates = [];
rows.forEach((el, index) => {
const tag = (el.tagName || '').toLowerCase();
const haystack = `${tag} ${textOf(el)} ${Object.values(attrsOf(el)).join(' ')} ${nearestLabel(el)}`;
if (tag === 'kat-dropdown' || tag === 'kat-option' || hasKeyword(haystack)) {
candidates.push(candidateRow(el, index));
}
});
return candidates.sort((a, b) => b.score - a.score).slice(0, 80);

View File

@@ -0,0 +1,225 @@
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);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function nearestLabel(el) {
const parts = [];
let node = el;
for (let depth = 0; node && depth < 7; depth += 1, node = node.parentElement) {
const text = textOf(node);
if (text) parts.push(text.slice(0, 500));
}
const id = el.getAttribute('id');
if (id) {
document.querySelectorAll(`label[for="${CSS.escape(id)}"]`).forEach((label) => {
const text = textOf(label);
if (text) parts.unshift(text);
});
}
return [...new Set(parts)].join(' | ');
}
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
);
return matches ? matches.length : 0;
}
function scoreDropdown(el) {
const text = textOf(el);
const label = nearestLabel(el);
const haystack = `${el.tagName} ${attrsText(el)} ${text} ${label}`;
const statusHits = statusHitCount(`${text} ${label}`);
let score = 0;
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 (statusHits >= 2) score += statusHits * 180;
if (/SKUS?|ASIN|FNSKU|UPC\/EAN|TITLE_KEYWORD|商品名称\/关键字/i.test(`${text} ${label}`)) score -= 300;
if (isVisible(el)) score += 10;
if (el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]')) score -= 80;
return { score, text, label, haystack, statusHits };
}
function candidateTriggers(dropdown) {
const selectors = [
'kat-dropdown-button',
'kat-button',
'button',
'[role="button"]',
'[aria-haspopup]',
'[part="trigger"]',
'[part="button"]',
'.header',
'.trigger',
'.button',
'span',
'div',
];
const seen = new Set();
const triggers = [];
const roots = [dropdown];
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
for (const root of roots) {
for (const selector of selectors) {
for (const el of Array.from(root.querySelectorAll(selector))) {
if (!el || seen.has(el)) continue;
seen.add(el);
const text = textOf(el);
const attrs = attrsText(el);
const score =
(isVisible(el) ? 20 : 0) +
(/全部\s*[(]\s*[\d,]+\s*[)]/.test(text) ? 200 : 0) +
(/商品状态|商品狀態|ListingStatus|listing status/i.test(`${text} ${attrs}`) ? 120 : 0) +
(/button|trigger|toggle|dropdown|expand/i.test(`${el.tagName} ${attrs}`) ? 40 : 0);
triggers.push({ el, text, attrs, score });
}
}
}
return triggers
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
}
function clickTrigger(dropdown) {
const triggers = candidateTriggers(dropdown);
for (const trigger of triggers) {
try {
trigger.el.scrollIntoView({ block: 'center', inline: 'nearest' });
trigger.el.click();
return {
ok: true,
trigger: {
tag: trigger.el.tagName,
text: trigger.text,
attrs: trigger.attrs,
score: trigger.score,
},
};
} catch (error) {
// continue
}
}
try {
dropdown.scrollIntoView({ block: 'center', inline: 'nearest' });
dropdown.click();
return {
ok: true,
trigger: {
tag: dropdown.tagName,
text: textOf(dropdown),
attrs: attrsText(dropdown),
score: -1,
},
};
} catch (error) {
return {
ok: false,
reason: String(error),
tried: triggers.slice(0, 10).map((item) => ({
tag: item.el.tagName,
text: item.text,
attrs: item.attrs,
score: item.score,
})),
};
}
}
const dropdowns = allElements().filter((el) => el.tagName.toLowerCase() === 'kat-dropdown');
const ranked = dropdowns
.map((el, index) => ({ el, index, ...scoreDropdown(el) }))
.filter((row) => row.score > 0)
.sort((a, b) => b.score - a.score);
if (!ranked.length) {
return { ok: false, reason: 'listing status kat-dropdown not found', candidates: [] };
}
const selected = ranked[0];
selected.el.setAttribute('data-crawler-listing-status-dropdown', 'true');
const clickResult = clickTrigger(selected.el);
if (!clickResult.ok) {
return {
ok: false,
reason: 'listing status dropdown trigger click failed',
selected: {
index: selected.index,
score: selected.score,
text: selected.text,
label: selected.label,
attrs: attrsText(selected.el),
outerHTML: (selected.el.outerHTML || '').slice(0, 2000),
},
clickResult,
candidates: ranked.slice(0, 10).map((row) => ({
index: row.index,
score: row.score,
text: row.text,
label: row.label,
attrs: attrsText(row.el),
outerHTML: (row.el.outerHTML || '').slice(0, 1000),
})),
};
}
return {
ok: true,
selected: {
index: selected.index,
score: selected.score,
statusHits: selected.statusHits,
text: selected.text,
label: selected.label,
attrs: attrsText(selected.el),
outerHTML: (selected.el.outerHTML || '').slice(0, 2000),
},
trigger: clickResult.trigger,
candidates: ranked.slice(0, 10).map((row) => ({
index: row.index,
score: row.score,
statusHits: row.statusHits,
text: row.text,
label: row.label,
attrs: attrsText(row.el),
outerHTML: (row.el.outerHTML || '').slice(0, 1000),
})),
};

View File

@@ -0,0 +1,78 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
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 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 insideListingRow(el) {
return Boolean(el.closest('[data-sku], tr, [role="row"], [data-testid*="row"]'));
}
function optionRow(el, index) {
const attrs = attrsOf(el);
const rawText = textOf(el) || attrs.label || attrs.text || attrs.value || '';
return {
index,
raw_text: rawText,
value: attrs.value || attrs['data-value'] || attrs.name || null,
attrs,
visible: isVisible(el),
outerHTML: (el.outerHTML || '').slice(0, 1200),
};
}
const markedDropdown = document.querySelector('[data-crawler-listing-status-dropdown="true"]');
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"]')));
}
}
const allOptions = allElements().filter((el) => {
const tag = el.tagName.toLowerCase();
const role = el.getAttribute('role');
if (tag !== 'kat-option' && role !== 'option') return false;
if (insideListingRow(el)) return false;
return isVisible(el);
});
const byNode = new Map();
scoped.concat(allOptions).forEach((el, index) => {
if (!byNode.has(el)) byNode.set(el, optionRow(el, index));
});
return Array.from(byNode.values()).filter((row) => row.raw_text);

View File

@@ -0,0 +1,91 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
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 extractStatuses(text) {
const pattern = /(全部|在搜索结果中禁止显示|配送问题|缺少报价|已停售|详情页面已删除|订单页面已删除|商品信息草稿|缺少的信息|定价问题|需要批准|不可售|在售)\s*(?:[(]\s*([\d,]+)\s*[)])?/g;
const rows = [];
let match;
while ((match = pattern.exec(text)) !== null) {
rows.push({
status: match[1],
quantity: match[2] || '',
raw_text: match[0].trim(),
});
}
return rows;
}
function parseDropdown(dropdown, index) {
const nodes = Array.from(dropdown.querySelectorAll('kat-option, kat-label, [class*="parentKatOptionStyle"]'));
const seen = new Set();
const rows = nodes
.flatMap((node) => {
const rawText = textOf(node);
return extractStatuses(rawText).map((parsed) => ({
...parsed,
value: node.getAttribute('value') || '',
}));
})
.filter((row) => {
const key = `${row.status}::${row.quantity}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const quantityCount = rows.filter((row) => row.quantity).length;
const rawText = textOf(dropdown);
const className = String(dropdown.className || '');
const isStatusDropdown = /statusDropdown/i.test(className);
const score =
rows.length * 100 +
quantityCount * 50 +
(isStatusDropdown ? 500 : 0) +
(/在搜索结果中禁止显示|需要批准|详情页面已删除/.test(rawText) ? 200 : 0);
return {
index,
tag: (dropdown.tagName || '').toLowerCase(),
raw_text: rawText,
rows,
rowCount: rows.length,
quantityCount,
score,
className,
outerHTML: (dropdown.outerHTML || '').slice(0, 1200),
};
}
const ranked = allElements()
.map((el, index) => ({ el, index }))
.filter(({ el }) => (el.tagName || '').toLowerCase() === 'kat-dropdown')
.map(({ el, index }) => parseDropdown(el, index))
.filter((row) => row.rowCount >= 3)
.sort((a, b) => b.score - a.score);
const best = ranked[0];
return {
rows: best ? best.rows : [],
selected: best || null,
candidates: ranked.slice(0, 10),
};

View File

@@ -0,0 +1,79 @@
function textOf(node) {
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
}
function isVisible(el) {
if (!el || !el.getBoundingClientRect) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function normalize(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
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 optionMeta(el, index) {
const rawText = textOf(el) || el.getAttribute('label') || el.getAttribute('text') || el.getAttribute('value') || '';
return {
index,
rawText: normalize(rawText),
parsedStatus: parseStatus(rawText),
value: el.getAttribute('value') || el.getAttribute('data-value') || '',
visible: isVisible(el),
};
}
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 };
}
const roots = [dropdown];
if (dropdown.shadowRoot) roots.push(dropdown.shadowRoot);
const options = [];
for (const root of roots) {
for (const el of Array.from(root.querySelectorAll('kat-option, [role="option"]'))) {
if (!isVisible(el)) continue;
options.push(el);
}
}
const metas = options.map((el, index) => ({ el, ...optionMeta(el, index) }));
const matched = metas.find((item) => item.parsedStatus === targetStatus || item.rawText === targetStatus);
if (!matched) {
return {
ok: false,
reason: 'status option not found',
targetStatus,
options: metas.map(({ index, rawText, parsedStatus, value, visible }) => ({
index,
rawText,
parsedStatus,
value,
visible,
})),
};
}
matched.el.scrollIntoView({ block: 'center', inline: 'nearest' });
matched.el.click();
return {
ok: true,
targetStatus,
selected: {
index: matched.index,
rawText: matched.rawText,
parsedStatus: matched.parsedStatus,
value: matched.value,
},
};

View File

@@ -0,0 +1,94 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
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 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 snapshot(el, index) {
return {
index,
tag: el.tagName.toLowerCase(),
text: textOf(el),
attrs: attrsOf(el),
outerHTML: (el.outerHTML || '').slice(0, 2000),
};
}
const KEYWORD_RE = /推荐报价|featured offer|buy box|商城|marketplace|欧洲|英国|德国|法国|西班牙|意大利|2\s*天前|30\s*天前|2\s*days?\s*ago|30\s*days?\s*ago/i;
const visible = allElements().filter((el) => isVisible(el));
const keywordMatches = visible
.map((el, index) => ({ el, index, text: textOf(el) }))
.filter((row) => KEYWORD_RE.test(`${row.el.tagName} ${row.text} ${Object.values(attrsOf(row.el)).join(' ')}`))
.slice(0, 120)
.map(({ el, index }) => snapshot(el, index));
const buyBoxCard = visible.find((el) => (el.getAttribute('data-key') || '') === 'KPI_CARD_BUYBOX')
|| visible.find((el) => (el.id || '') === 'KPI_CARD_BUYBOX');
const buyBoxNeighborhood = [];
if (buyBoxCard) {
let node = buyBoxCard;
for (let depth = 0; node && depth < 6; depth += 1, node = node.parentElement) {
buyBoxNeighborhood.push({
depth,
tag: node.tagName.toLowerCase(),
text: textOf(node),
attrs: attrsOf(node),
outerHTML: (node.outerHTML || '').slice(0, 2500),
});
}
}
const buyBoxLeafMatches = [];
if (buyBoxCard) {
const scoped = [];
walkRoots(buyBoxCard, scoped, new Set());
scoped
.filter((el) => isVisible(el))
.map((el, index) => ({ el, index, text: textOf(el) }))
.filter((row) => row.text && row.text.length <= 200)
.filter((row) => /推荐报价|商城|marketplace|欧洲|英国|德国|法国|西班牙|意大利|\d+(?:\.\d+)?\s*%|2\s*天前|30\s*天前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(row.text))
.slice(0, 120)
.forEach(({ el, index }) => buyBoxLeafMatches.push(snapshot(el, index)));
}
return {
url: window.location.href,
title: document.title,
buyBoxFound: !!buyBoxCard,
buyBoxNeighborhood,
buyBoxLeafMatches,
keywordMatches,
};

View File

@@ -0,0 +1,132 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
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 attrsText(el) {
return Object.entries(attrsOf(el)).map(([key, value]) => `${key}=${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);
node = walker.nextNode();
}
}
function allElements() {
const rows = [];
walkRoots(document, rows, new Set());
return rows;
}
function clickableFor(el) {
if (el && el.querySelector) {
const preferred = el.querySelector(
'casino-button[data-actionname="expand"], [data-actionname="expand"], casino-button[aria-label="chevron-down"], [aria-label="chevron-down"]'
);
if (preferred) return preferred;
}
let node = el;
for (let depth = 0; node && depth < 7; depth += 1, node = node.parentElement) {
if (node.querySelector) {
const preferred = node.querySelector(
'casino-button[data-actionname="expand"], [data-actionname="expand"], casino-button[aria-label="chevron-down"], [aria-label="chevron-down"]'
);
if (preferred) return preferred;
}
const tag = node.tagName.toLowerCase();
const role = node.getAttribute('role');
if (tag === 'button' || tag === 'a' || tag === 'kat-button' || role === 'button' || node.onclick) {
return node;
}
}
return el;
}
function scoreEntry(el) {
const text = textOf(el);
const haystack = `${el.tagName} ${attrsText(el)} ${text}`;
let score = 0;
if (/推荐报价百分比|推荐报价.*百分比|featured offer percentage|featured offer.*%|featured offer/i.test(haystack)) {
score += 120;
}
if (/buy box|购物车|报价/i.test(haystack)) score += 20;
if (/%/.test(haystack)) score += 15;
if (/button|link|href|click|card|metric/i.test(haystack)) score += 10;
if (isVisible(el)) score += 10;
if (text.length > 800) score -= 30;
return { score, text, haystack };
}
const ranked = allElements()
.filter((el) => isVisible(el))
.map((el, index) => ({ el, index, ...scoreEntry(el) }))
.filter((row) => row.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.text.length - b.text.length;
});
if (!ranked.length) {
return {
ok: false,
reason: 'recommended offer percentage entry not found',
url: window.location.href,
title: document.title,
candidates: allElements()
.filter((el) => isVisible(el))
.map((el, index) => ({ index, tag: el.tagName.toLowerCase(), text: textOf(el), attrs: attrsOf(el) }))
.filter((row) => /推荐报价|featured offer|buy box|百分比|percentage/i.test(`${row.tag} ${row.text} ${Object.values(row.attrs).join(' ')}`))
.slice(0, 80),
};
}
const selected = ranked[0];
const clickable = clickableFor(selected.el);
clickable.setAttribute('data-crawler-recommended-offer-entry', 'true');
clickable.scrollIntoView({ block: 'center', inline: 'nearest' });
['pointerdown', 'mousedown', 'mouseup', 'click'].forEach((eventName) => {
clickable.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, composed: true }));
});
if (typeof clickable.click === 'function') clickable.click();
return {
ok: true,
selected: {
index: selected.index,
score: selected.score,
text: selected.text,
attrs: attrsText(selected.el),
clickableTag: clickable.tagName.toLowerCase(),
clickableText: textOf(clickable),
outerHTML: (selected.el.outerHTML || '').slice(0, 2000),
},
candidates: ranked.slice(0, 12).map((row) => ({
index: row.index,
score: row.score,
text: row.text,
attrs: attrsText(row.el),
outerHTML: (row.el.outerHTML || '').slice(0, 1000),
})),
};

View File

@@ -0,0 +1,279 @@
function textOf(node) {
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
}
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 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 rowOf(el, index) {
const attrs = attrsOf(el);
const rect = el.getBoundingClientRect();
return {
index,
tag: el.tagName.toLowerCase(),
raw_text: textOf(el),
label_text: attrs.label || attrs.title || attrs['aria-label'] || '',
percentage_text: attrs.value || attrs['data-value'] || '',
attrs,
visible: isVisible(el),
rect_left: rect.left,
rect_top: rect.top,
rect_width: rect.width,
rect_height: rect.height,
outerHTML: (el.outerHTML || '').slice(0, 1200),
};
}
const COUNTRIES = [
{ country: '欧洲', aliases: ['欧洲', 'Europe'] },
{ country: '英国', aliases: ['英国', 'UK', 'United Kingdom'] },
{ country: '德国', aliases: ['德国', 'Germany'] },
{ country: '法国', aliases: ['法国', 'France'] },
{ country: '西班牙', aliases: ['西班牙', 'Spain'] },
{ country: '意大利', aliases: ['意大利', 'Italy'] },
];
function percentagesOf(text) {
return (text.match(/\d+(?:\.\d+)?\s*%/g) || []).map((item) => item.replace(/\s+/g, ''));
}
function parentOrHost(el) {
if (!el) return null;
if (el.parentElement) return el.parentElement;
const root = el.getRootNode && el.getRootNode();
return root && root.host ? root.host : null;
}
function countryOfText(text) {
const normalized = (text || '').trim().toLowerCase();
return COUNTRIES.find(({ aliases }) => aliases.some((alias) => normalized === alias.toLowerCase())) || null;
}
function countCountries(text) {
return COUNTRIES.filter(({ aliases }) => aliases.some((alias) => new RegExp(`(^|\\s)${alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, 'i').test(text))).length;
}
function bestCountryRowText(el) {
const visited = new Set();
let node = el;
for (let depth = 0; node && depth < 7; depth += 1, node = parentOrHost(node)) {
if (visited.has(node)) break;
visited.add(node);
const text = textOf(node);
if (!text || text.length > 500) continue;
const percentages = percentagesOf(text);
if (percentages.length >= 2 && countCountries(text) <= 1) return text;
}
const parent = parentOrHost(el);
if (!parent || !parent.children) return '';
const children = Array.from(parent.children).filter((child) => isVisible(child) && textOf(child));
const index = children.indexOf(el);
if (index < 0) return '';
const nearby = children.slice(Math.max(0, index - 1), Math.min(children.length, index + 5)).map((child) => textOf(child));
const joined = nearby.join(' ');
return percentagesOf(joined).length >= 2 ? joined : '';
}
function directCountryRows(elements) {
const results = new Map();
elements.forEach((el) => {
const match = countryOfText(textOf(el));
if (!match || results.has(match.country)) return;
const text = bestCountryRowText(el);
const percentages = percentagesOf(text);
if (percentages.length < 2) return;
results.set(match.country, {
country: match.country,
ratio2DaysAgo: percentages[0],
ratio30DaysAgo: percentages[1],
raw_text: text,
source: 'rendered-country-row',
});
});
return Array.from(results.values());
}
function expandedCardOf(elements) {
return elements.find((el) => el.tagName && el.tagName.toLowerCase() === 'casino-card' && /\bcasino-expanded-card\b/.test(el.className || ''));
}
function directRowsFromExpandedCard(expandedCard) {
if (!expandedCard) return [];
const scoped = [];
walkRoots(expandedCard, scoped, new Set());
const countries = [];
const percentages = [];
const seen = new Set();
scoped
.filter((el) => isVisible(el))
.forEach((el) => {
const text = textOf(el);
const title = (el.getAttribute && el.getAttribute('title')) || '';
const aria = (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('arialabel'))) || '';
const candidate = (title || aria || text || '').replace(/\s+/g, ' ').trim();
if (!candidate || candidate.length > 20) return;
const rect = el.getBoundingClientRect();
const area = rect.width * rect.height;
if (area <= 0 || area > 20000) return;
const key = `${candidate}|${Math.round(rect.left)}|${Math.round(rect.top)}|${Math.round(rect.width)}|${Math.round(rect.height)}`;
if (seen.has(key)) return;
seen.add(key);
const countryMatch = countryOfText(candidate);
if (countryMatch) {
countries.push({
country: countryMatch.country,
x: rect.left,
y: rect.top + rect.height / 2,
h: rect.height,
area,
});
return;
}
if (/^\d+(?:\.\d+)?\s*%$/.test(candidate)) {
percentages.push({
value: candidate.replace(/\s+/g, ''),
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
area,
});
}
});
const dedupedCountries = [];
countries
.sort((a, b) => a.y - b.y || a.x - b.x || a.area - b.area)
.forEach((item) => {
const exists = dedupedCountries.some((row) => row.country === item.country && Math.abs(row.y - item.y) < 8);
if (!exists) dedupedCountries.push(item);
});
const results = dedupedCountries.map((row) => {
const sameRow = percentages
.filter((item) => item.x > row.x + 20 && Math.abs(item.y - row.y) <= Math.max(14, row.h))
.sort((a, b) => a.x - b.x || a.y - b.y || a.area - b.area);
const unique = [];
sameRow.forEach((item) => {
if (!unique.some((existing) => existing.value === item.value && Math.abs(existing.x - item.x) < 8)) {
unique.push(item);
}
});
return {
country: row.country,
ratio2DaysAgo: unique[0] ? unique[0].value : '',
ratio30DaysAgo: unique[1] ? unique[1].value : '',
source: 'expanded-card-geometry',
};
});
return results.filter((item) => item.ratio2DaysAgo || item.ratio30DaysAgo);
}
function scoreRegion(el) {
const text = textOf(el);
if (!/%/.test(text)) return 0;
let score = 0;
if (/推荐报价百分比|推荐报价|featured offer percentage|featured offer|buy box/i.test(text)) score += 80;
if (/2\s*天前|30\s*天前|2\s*日前|30\s*日前|2\s*days?\s*ago|30\s*days?\s*ago/i.test(text)) score += 80;
if (/欧洲|英国|德国|法国|西班牙|意大利|Europe|UK|United Kingdom|Germany|France|Spain|Italy/i.test(text)) score += 40;
if (/%/.test(text)) score += 20;
if (isVisible(el)) score += 10;
if (text.length > 2500) score -= 40;
return score;
}
const visible = allElements().filter((el) => isVisible(el));
const expandedCard = expandedCardOf(visible);
const expandedDirectRows = directRowsFromExpandedCard(expandedCard);
const directRows = expandedDirectRows.length ? expandedDirectRows : directCountryRows(visible);
const regions = visible
.map((el, index) => {
const rect = el.getBoundingClientRect();
return { el, index, score: scoreRegion(el), text: textOf(el), area: rect.width * rect.height };
})
.filter((row) => row.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.text.length !== b.text.length) return a.text.length - b.text.length;
return a.area - b.area;
});
const sourceElements = expandedCard ? [expandedCard] : (regions.length ? [regions[0].el] : visible);
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);
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);
});
return {
directRows,
region: regions.length
? {
score: regions[0].score,
text: regions[0].text.slice(0, 2000),
outerHTML: (regions[0].el.outerHTML || '').slice(0, 3000),
}
: {
url: window.location.href,
title: document.title,
},
rows: Array.from(byText.values()),
candidates: regions.slice(0, 20).map((row) => ({
index: row.index,
score: row.score,
text: row.text.slice(0, 1000),
})),
};