Stabilize patrol delete automation against delayed Amazon UI
The patrol-delete flow now waits for country and listing-status controls before acting, uses the dedicated PatrolDeleteTask entry point, and includes the browser helper scripts required by the bulk-delete path. Tests cover delayed dropdown readiness, payload rejection/retry behavior, complete-draft normalization, filtered bulk deletion, and asset fallback lookup. Constraint: Amazon listing UI exposes status controls through dynamic KAT components and delayed option rendering Rejected: Keep row-by-row delete flow | bulk selection is the current implemented path and is covered by the added helper scripts Confidence: high Scope-risk: moderate Directive: Do not remove the patrol_delete JS helper files without checking app/amazon/patrol_delete.py script loading Tested: uv run --group dev pytest tests\\test_amazon_base.py tests\\test_patrol_delete.py Tested: uv run python -m py_compile amazon\\base.py amazon\\main.py blueprints\\main.py Not-tested: Real Amazon Seller Central browser session
This commit is contained in:
@@ -36,6 +36,8 @@ COUNTRY_INITIAL_LOAD_TIMEOUT = 120
|
||||
COUNTRY_LOOKUP_TIMEOUT = 20
|
||||
COUNTRY_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)
|
||||
|
||||
@@ -10,7 +10,7 @@ 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.patrol_delete import PatrolDeleteTask
|
||||
from amazon.detail_spider import SpiderTask
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ class TaskMonitor:
|
||||
"匹配价格" : MatchTak,
|
||||
"跟价" : PriceTask,
|
||||
"状态查询" : StatusTask,
|
||||
"巡店删除" : ProductTask,
|
||||
"巡店删除" : PatrolDeleteTask,
|
||||
"亚马逊采集" : SpiderTask,
|
||||
}
|
||||
self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
function textOf(node) {
|
||||
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function attrsText(el) {
|
||||
if (!el || !el.attributes) return '';
|
||||
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el || !el.getBoundingClientRect) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
function walkRoots(root, rows, seen) {
|
||||
if (!root || seen.has(root)) return;
|
||||
seen.add(root);
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
|
||||
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
|
||||
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
|
||||
for (const assigned of node.assignedElements({ flatten: true })) {
|
||||
walkRoots(assigned, rows, seen);
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
}
|
||||
|
||||
function allElements(root) {
|
||||
const rows = [];
|
||||
walkRoots(root || document, rows, new Set());
|
||||
return rows;
|
||||
}
|
||||
|
||||
function clickElement(el) {
|
||||
el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
if (el.focus) el.focus();
|
||||
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
|
||||
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
el.click();
|
||||
}
|
||||
|
||||
function clickTargetFor(el) {
|
||||
if (el && el.shadowRoot) {
|
||||
const button = el.shadowRoot.querySelector('button:not([disabled]), [role="button"]:not([disabled])');
|
||||
if (button) return button;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
const modal = document.querySelector('kat-modal[data-testid="action-modal"]') ||
|
||||
allElements(document).find((el) => /删除|Delete/i.test(textOf(el)) && (el.tagName || '').toLowerCase() === 'kat-modal');
|
||||
|
||||
if (!modal) return { ok: false, reason: 'delete modal not found' };
|
||||
|
||||
const buttons = allElements(modal).filter((el) => {
|
||||
const haystack = `${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`;
|
||||
return isVisible(el) && /kat-button|button/i.test(el.tagName || '') && /variant=primary|删除|确认|Delete|Confirm/i.test(haystack);
|
||||
});
|
||||
const button = buttons[0];
|
||||
|
||||
if (!button) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'confirm button not found',
|
||||
modalText: textOf(modal),
|
||||
modalHTML: (modal.outerHTML || '').slice(0, 2000),
|
||||
};
|
||||
}
|
||||
|
||||
const target = clickTargetFor(button);
|
||||
clickElement(target);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
clicked: {
|
||||
tag: button.tagName,
|
||||
text: textOf(button),
|
||||
attrs: attrsText(button),
|
||||
targetTag: target.tagName,
|
||||
},
|
||||
};
|
||||
46
app/amazon/scripts/patrol_delete/delete_success_messages.js
Normal file
46
app/amazon/scripts/patrol_delete/delete_success_messages.js
Normal file
@@ -0,0 +1,46 @@
|
||||
function textOf(node) {
|
||||
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function attrsText(el) {
|
||||
if (!el || !el.attributes) return '';
|
||||
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el || !el.getBoundingClientRect) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
function walkRoots(root, rows, seen) {
|
||||
if (!root || seen.has(root)) return;
|
||||
seen.add(root);
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
|
||||
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
|
||||
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
|
||||
for (const assigned of node.assignedElements({ flatten: true })) {
|
||||
walkRoots(assigned, rows, seen);
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
walkRoots(document, rows, new Set());
|
||||
|
||||
const seen = new Set();
|
||||
return rows
|
||||
.filter((el) => {
|
||||
const haystack = `${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`;
|
||||
return isVisible(el) && /kat-alert|alert|toast|notification|已删除|已经删除|15\s*分钟|success/i.test(haystack);
|
||||
})
|
||||
.map((el) => textOf(el) || attrsText(el))
|
||||
.filter((text) => text && !seen.has(text) && seen.add(text))
|
||||
.slice(0, 30);
|
||||
187
app/amazon/scripts/patrol_delete/inventory_delete_selected.js
Normal file
187
app/amazon/scripts/patrol_delete/inventory_delete_selected.js
Normal file
@@ -0,0 +1,187 @@
|
||||
function textOf(node) {
|
||||
return ((node && (node.innerText || node.textContent || '')) || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function attrsText(el) {
|
||||
if (!el || !el.attributes) return '';
|
||||
return Array.from(el.attributes).map((attr) => `${attr.name}=${attr.value}`).join(' ');
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el || !el.getBoundingClientRect) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
function walkRoots(root, rows, seen) {
|
||||
if (!root || seen.has(root)) return;
|
||||
seen.add(root);
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
|
||||
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
|
||||
if ((node.tagName || '').toLowerCase() === 'slot' && node.assignedElements) {
|
||||
for (const assigned of node.assignedElements({ flatten: true })) {
|
||||
walkRoots(assigned, rows, seen);
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
}
|
||||
|
||||
function allElements(root) {
|
||||
const rows = [];
|
||||
walkRoots(root || document, rows, new Set());
|
||||
return rows;
|
||||
}
|
||||
|
||||
function closestDeep(el, selector) {
|
||||
let node = el;
|
||||
while (node) {
|
||||
if (node.closest) {
|
||||
const found = node.closest(selector);
|
||||
if (found) return found;
|
||||
}
|
||||
const root = node.getRootNode && node.getRootNode();
|
||||
node = root && root.host ? root.host : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function insideInventoryRow(el) {
|
||||
return Boolean(closestDeep(el, 'div[data-sku]'));
|
||||
}
|
||||
|
||||
function isDeleteAction(el) {
|
||||
const haystack = `${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`;
|
||||
return /data-action=DeleteListing|action=DeleteListing|删除商品信息|删除商品和报价|DeleteListing|delete\s+listing/i.test(haystack);
|
||||
}
|
||||
|
||||
function clickElement(el) {
|
||||
el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
if (el.focus) el.focus();
|
||||
for (const eventName of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
|
||||
el.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
el.click();
|
||||
}
|
||||
|
||||
function hostDropdownOf(el) {
|
||||
const host = closestDeep(el, 'kat-dropdown-button');
|
||||
return host || ((el.tagName || '').toLowerCase() === 'kat-dropdown-button' ? el : null);
|
||||
}
|
||||
|
||||
function dropdownToggleOf(dropdown, fallback) {
|
||||
if (!dropdown) return fallback;
|
||||
if (fallback && textOf(fallback)) return fallback;
|
||||
return fallback || dropdown;
|
||||
}
|
||||
|
||||
function openBulkActionMenu(candidate) {
|
||||
const dropdown = hostDropdownOf(candidate);
|
||||
const toggle = dropdownToggleOf(dropdown, candidate);
|
||||
clickElement(toggle);
|
||||
return { dropdown, toggle };
|
||||
}
|
||||
|
||||
function summarize(el) {
|
||||
return {
|
||||
tag: el.tagName,
|
||||
text: textOf(el),
|
||||
attrs: attrsText(el),
|
||||
visible: isVisible(el),
|
||||
outerHTML: (el.outerHTML || '').slice(0, 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function findVisibleBulkDeleteAction() {
|
||||
return allElements(document).find((el) => isDeleteAction(el) && isVisible(el) && !insideInventoryRow(el));
|
||||
}
|
||||
|
||||
function findDeleteActionAfterMenuOpen(candidate) {
|
||||
const dropdown = hostDropdownOf(candidate) || candidate;
|
||||
const exactMenuButton = allElements(dropdown).find((child) => {
|
||||
const tag = (child.tagName || '').toLowerCase();
|
||||
const role = child.getAttribute && child.getAttribute('role');
|
||||
return tag === 'button' && role === 'menuitem' && isDeleteAction(child);
|
||||
});
|
||||
if (exactMenuButton) return { mode: 'bulk-action-menuitem-delete', el: exactMenuButton };
|
||||
|
||||
const nestedDeleteAction = allElements(dropdown).find((child) => isDeleteAction(child) && isVisible(child));
|
||||
if (nestedDeleteAction) return { mode: 'bulk-action-nested-delete', el: nestedDeleteAction };
|
||||
|
||||
const deleteAction = findVisibleBulkDeleteAction();
|
||||
if (deleteAction) return { mode: 'bulk-action-visible-delete', el: deleteAction };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBulkActionCandidate(el) {
|
||||
const tag = (el.tagName || '').toLowerCase();
|
||||
if (!/^(button|kat-button|kat-dropdown-button|kat-select|kat-popover|kat-menu-button)$/.test(tag)) return false;
|
||||
if (!isVisible(el) || insideInventoryRow(el)) return false;
|
||||
const haystack = `${tag} ${attrsText(el)} ${textOf(el)}`;
|
||||
return /DeleteListing|删除商品信息|删除|操作|更多|Action|Actions|selected|选定|已选择|批量/i.test(haystack);
|
||||
}
|
||||
|
||||
const directDelete = findVisibleBulkDeleteAction();
|
||||
if (directDelete) {
|
||||
clickElement(directDelete);
|
||||
return { ok: true, mode: 'direct-delete-button', clicked: summarize(directDelete) };
|
||||
}
|
||||
|
||||
const rows = allElements(document);
|
||||
const candidates = rows.filter(isBulkActionCandidate);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const openResult = openBulkActionMenu(candidate);
|
||||
const deleteAction = findDeleteActionAfterMenuOpen(candidate);
|
||||
if (deleteAction) {
|
||||
clickElement(deleteAction.el);
|
||||
return {
|
||||
ok: true,
|
||||
mode: deleteAction.mode,
|
||||
candidate: summarize(candidate),
|
||||
opened: {
|
||||
dropdown: openResult.dropdown ? summarize(openResult.dropdown) : null,
|
||||
toggle: openResult.toggle ? summarize(openResult.toggle) : null,
|
||||
},
|
||||
clicked: summarize(deleteAction.el),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'bulk action menu opened; delete action not visible yet',
|
||||
menuOpened: true,
|
||||
candidate: summarize(candidate),
|
||||
opened: {
|
||||
dropdown: openResult.dropdown ? summarize(openResult.dropdown) : null,
|
||||
toggle: openResult.toggle ? summarize(openResult.toggle) : null,
|
||||
},
|
||||
deleteLikeElements: allElements(document)
|
||||
.filter((el) => /DeleteListing|删除商品信息|删除商品和报价|delete\s+listing/i.test(`${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`) && !insideInventoryRow(el))
|
||||
.slice(0, 20)
|
||||
.map(summarize),
|
||||
};
|
||||
} catch (error) {
|
||||
// Try the next non-row bulk action control.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'bulk delete action not found',
|
||||
candidates: candidates.slice(0, 20).map(summarize),
|
||||
deleteLikeElements: rows
|
||||
.filter((el) => /DeleteListing|删除商品信息|删除商品和报价|delete\s+listing/i.test(`${el.tagName || ''} ${attrsText(el)} ${textOf(el)}`) && !insideInventoryRow(el))
|
||||
.slice(0, 20)
|
||||
.map(summarize),
|
||||
visibleButtons: rows
|
||||
.filter((el) => /^(button|kat-button|kat-dropdown-button)$/i.test(el.tagName || '') && isVisible(el))
|
||||
.slice(0, 20)
|
||||
.map(summarize),
|
||||
};
|
||||
71
app/amazon/scripts/patrol_delete/inventory_select_all.js
Normal file
71
app/amazon/scripts/patrol_delete/inventory_select_all.js
Normal file
@@ -0,0 +1,71 @@
|
||||
function isVisible(el) {
|
||||
if (!el || !el.getBoundingClientRect) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
function walkRoots(root, rows, seen) {
|
||||
if (!root || seen.has(root)) return;
|
||||
seen.add(root);
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.tagName) rows.push(node);
|
||||
if (node.shadowRoot) walkRoots(node.shadowRoot, rows, seen);
|
||||
node = walker.nextNode();
|
||||
}
|
||||
}
|
||||
|
||||
function allElements() {
|
||||
const rows = [];
|
||||
walkRoots(document, rows, new Set());
|
||||
return rows;
|
||||
}
|
||||
|
||||
function insideInventoryRow(el) {
|
||||
return Boolean(el && el.closest && el.closest('div[data-sku]'));
|
||||
}
|
||||
|
||||
function clickTargetFor(checkbox) {
|
||||
if (!checkbox) return null;
|
||||
if (checkbox.shadowRoot) {
|
||||
const shadowTarget = checkbox.shadowRoot.querySelector('[role="checkbox"], .checkbox');
|
||||
if (shadowTarget) return shadowTarget;
|
||||
}
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
const rowCount = document.querySelectorAll('div[data-sku]').length;
|
||||
if (rowCount <= 0) {
|
||||
return { ok: false, reason: 'no inventory rows after status filter', rowCount };
|
||||
}
|
||||
|
||||
const checkboxes = allElements()
|
||||
.filter((el) => (el.tagName || '').toLowerCase() === 'kat-checkbox' && isVisible(el))
|
||||
.sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect();
|
||||
const rectB = b.getBoundingClientRect();
|
||||
return rectA.top - rectB.top || rectA.left - rectB.left;
|
||||
});
|
||||
|
||||
const headerCheckbox = checkboxes.find((el) => !insideInventoryRow(el)) || checkboxes[0];
|
||||
if (!headerCheckbox) {
|
||||
return { ok: false, reason: 'select-all checkbox not found', rowCount, checkboxCount: checkboxes.length };
|
||||
}
|
||||
|
||||
const target = clickTargetFor(headerCheckbox);
|
||||
target.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
target.click();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rowCount,
|
||||
checkboxCount: checkboxes.length,
|
||||
clicked: {
|
||||
tag: headerCheckbox.tagName,
|
||||
insideInventoryRow: insideInventoryRow(headerCheckbox),
|
||||
outerHTML: (headerCheckbox.outerHTML || '').slice(0, 1000),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }));
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||
return true;
|
||||
@@ -2,11 +2,32 @@ function textOf(node) {
|
||||
return (node && (node.innerText || node.textContent || '') || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,13 @@ 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)
|
||||
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
|
||||
|
||||
@@ -38,9 +44,13 @@ def _resolve_asset_filename(filename):
|
||||
if len(parts) < 2:
|
||||
return safe_name
|
||||
|
||||
asset_names = []
|
||||
for asset_dir in asset_dirs:
|
||||
try:
|
||||
asset_names = os.listdir(ASSETS_DIR)
|
||||
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):
|
||||
@@ -85,10 +95,15 @@ def wb():
|
||||
@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):
|
||||
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(repo_brand_path, 'rb') as f:
|
||||
with open(candidate_path, 'rb') as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
from html_crypto import decrypt
|
||||
@@ -99,7 +114,7 @@ def brand_page():
|
||||
content = raw.decode('utf-8', errors='replace')
|
||||
return render_template_string(content, user_id=session.get('user_id'))
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
return _render_html('brand.html', user_id=session.get('user_id'))
|
||||
|
||||
|
||||
@@ -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)
|
||||
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) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
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>')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": ""},
|
||||
],
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{"status": "商品信息草稿", "quantity": "1", "deleteQuantity": "", "processStatus": ""},
|
||||
{"status": "全部", "quantity": "8", "deleteQuantity": "", "processStatus": ""},
|
||||
],
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""},
|
||||
],
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""},
|
||||
],
|
||||
}
|
||||
],
|
||||
]
|
||||
state = {"index": 0, "selected": [], "deleted": 0}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user