diff --git a/app/amazon/base.py b/app/amazon/base.py index f5be539..107f2cf 100644 --- a/app/amazon/base.py +++ b/app/amazon/base.py @@ -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) diff --git a/app/amazon/main.py b/app/amazon/main.py index 238e771..a5e7111 100644 --- a/app/amazon/main.py +++ b/app/amazon/main.py @@ -4,17 +4,17 @@ import requests from datetime import datetime from typing import Dict, Any, List from concurrent.futures import ThreadPoolExecutor, as_completed -from config import JSON_TASK_QUEUE, runing_task, runing_shop, DELETE_BRAND_API_BASE, ZN_COMPANY, ZN_USERNAME, ZN_PASSWORD -from amazon.del_brand import AmazoneDriver, kill_process -from amazon.approve import ApproveTask -from amazon.match_action import MatchTak -from amazon.price_match import PriceTask -from amazon.asin_status import StatusTask -from amazon.patrol_delete import ProductTask -from amazon.detail_spider import SpiderTask - - -from amazon.tool import get_shop_info,show_notification +from config import JSON_TASK_QUEUE, runing_task, runing_shop, DELETE_BRAND_API_BASE, ZN_COMPANY, ZN_USERNAME, ZN_PASSWORD +from amazon.del_brand import AmazoneDriver, kill_process +from amazon.approve import ApproveTask +from amazon.match_action import MatchTak +from amazon.price_match import PriceTask +from amazon.asin_status import StatusTask +from amazon.patrol_delete import PatrolDeleteTask +from amazon.detail_spider import SpiderTask + + +from amazon.tool import get_shop_info,show_notification class TaskMonitor: @@ -55,15 +55,15 @@ class TaskMonitor: self.executor = ThreadPoolExecutor(max_workers=self.max_workers) futures = [] # 保存所有提交的任务Future对象 - task_type_info = { - "product-risk-resolve-run" : "产品风险审批", - "shop-match-run" : "匹配价格", - "price-track-run" : "跟价", - "query-asin-run" : "状态查询", - "patrol-delete-run" : "巡店删除", - "appearance-patent-run" : "亚马逊采集", - } - try: + task_type_info = { + "product-risk-resolve-run" : "产品风险审批", + "shop-match-run" : "匹配价格", + "price-track-run" : "跟价", + "query-asin-run" : "状态查询", + "patrol-delete-run" : "巡店删除", + "appearance-patent-run" : "亚马逊采集", + } + try: while self.running: try: # 使用较长超时时间等待任务,减少空等待异常 @@ -125,14 +125,14 @@ class TaskMonitor: task_data: 任务数据 """ try: - TASK_INFO = { - "产品风险审批" : ApproveTask, - "匹配价格" : MatchTak, - "跟价" : PriceTask, - "状态查询" : StatusTask, - "巡店删除" : ProductTask, - "亚马逊采集" : SpiderTask, - } + TASK_INFO = { + "产品风险审批" : ApproveTask, + "匹配价格" : MatchTak, + "跟价" : PriceTask, + "状态查询" : StatusTask, + "巡店删除" : PatrolDeleteTask, + "亚马逊采集" : SpiderTask, + } self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...") # 创建ApproveTask实例并处理任务 TASK_CLS = TASK_INFO[TASK_TYPE] # 根据任务类型选择处理类,默认为ApproveTask diff --git a/app/amazon/scripts/patrol_delete/delete_confirm_modal_click.js b/app/amazon/scripts/patrol_delete/delete_confirm_modal_click.js new file mode 100644 index 0000000..601f3da --- /dev/null +++ b/app/amazon/scripts/patrol_delete/delete_confirm_modal_click.js @@ -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, + }, +}; diff --git a/app/amazon/scripts/patrol_delete/delete_success_messages.js b/app/amazon/scripts/patrol_delete/delete_success_messages.js new file mode 100644 index 0000000..5ee52fe --- /dev/null +++ b/app/amazon/scripts/patrol_delete/delete_success_messages.js @@ -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); diff --git a/app/amazon/scripts/patrol_delete/inventory_delete_selected.js b/app/amazon/scripts/patrol_delete/inventory_delete_selected.js new file mode 100644 index 0000000..6a48275 --- /dev/null +++ b/app/amazon/scripts/patrol_delete/inventory_delete_selected.js @@ -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), +}; diff --git a/app/amazon/scripts/patrol_delete/inventory_select_all.js b/app/amazon/scripts/patrol_delete/inventory_select_all.js new file mode 100644 index 0000000..151c4cd --- /dev/null +++ b/app/amazon/scripts/patrol_delete/inventory_select_all.js @@ -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), + }, +}; diff --git a/app/amazon/scripts/patrol_delete/listing_status_dropdown_close.js b/app/amazon/scripts/patrol_delete/listing_status_dropdown_close.js new file mode 100644 index 0000000..cb9be2c --- /dev/null +++ b/app/amazon/scripts/patrol_delete/listing_status_dropdown_close.js @@ -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; diff --git a/app/amazon/scripts/patrol_delete/listing_status_dropdown_open.js b/app/amazon/scripts/patrol_delete/listing_status_dropdown_open.js index f1e466c..ba21550 100644 --- a/app/amazon/scripts/patrol_delete/listing_status_dropdown_open.js +++ b/app/amazon/scripts/patrol_delete/listing_status_dropdown_open.js @@ -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), diff --git a/app/amazon/scripts/patrol_delete/listing_status_dropdown_options.js b/app/amazon/scripts/patrol_delete/listing_status_dropdown_options.js index 0f9c250..9c71294 100644 --- a/app/amazon/scripts/patrol_delete/listing_status_dropdown_options.js +++ b/app/amazon/scripts/patrol_delete/listing_status_dropdown_options.js @@ -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); diff --git a/app/amazon/scripts/patrol_delete/listing_status_select_option.js b/app/amazon/scripts/patrol_delete/listing_status_select_option.js index ad8ea14..fe01096 100644 --- a/app/amazon/scripts/patrol_delete/listing_status_select_option.js +++ b/app/amazon/scripts/patrol_delete/listing_status_select_option.js @@ -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, }, }; diff --git a/app/amazon/scripts/patrol_delete/recommended_offer_percentage_rows.js b/app/amazon/scripts/patrol_delete/recommended_offer_percentage_rows.js index e273a0f..32ec558 100644 --- a/app/amazon/scripts/patrol_delete/recommended_offer_percentage_rows.js +++ b/app/amazon/scripts/patrol_delete/recommended_offer_percentage_rows.js @@ -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); }); diff --git a/app/blueprints/main.py b/app/blueprints/main.py index 8b3d19e..85cfd05 100644 --- a/app/blueprints/main.py +++ b/app/blueprints/main.py @@ -26,9 +26,15 @@ main_bp = Blueprint('main', __name__) def _resolve_asset_filename(filename): """Resolve hashed Vite assets without maintaining hardcoded alias tables.""" safe_name = os.path.basename(filename) - exact_path = os.path.join(ASSETS_DIR, safe_name) - if os.path.isfile(exact_path): - return safe_name + asset_dirs = [ + ASSETS_DIR, + os.path.abspath(os.path.join(BASE_DIR, 'new_web_source', 'assets')), + os.path.abspath(os.path.join(BASE_DIR, '..', 'new_web_source', 'assets')), + ] + for asset_dir in asset_dirs: + exact_path = os.path.join(asset_dir, safe_name) + if os.path.isfile(exact_path): + return safe_name base_name, ext = os.path.splitext(safe_name) if not base_name or not ext: @@ -38,9 +44,13 @@ def _resolve_asset_filename(filename): if len(parts) < 2: return safe_name - try: - asset_names = os.listdir(ASSETS_DIR) - except OSError: + asset_names = [] + for asset_dir in asset_dirs: + try: + asset_names.extend(os.listdir(asset_dir)) + except OSError: + continue + if not asset_names: return safe_name for prefix_length in range(len(parts) - 1, 0, -1): @@ -82,25 +92,30 @@ def wb(): return _render_html('index.html') -@main_bp.route('/brand') -@login_required -def brand_page(): - repo_brand_path = os.path.abspath(os.path.join(BASE_DIR, '..', 'web_source', 'brand.html')) - if os.path.isfile(repo_brand_path): - try: - with open(repo_brand_path, 'rb') as f: - raw = f.read() - try: - from html_crypto import decrypt - content = decrypt(raw).decode('utf-8') - except Exception: - if raw[:7] == b'gAAAAAB': - raise - content = raw.decode('utf-8', errors='replace') - return render_template_string(content, user_id=session.get('user_id')) - except Exception: - pass - return _render_html('brand.html', user_id=session.get('user_id')) +@main_bp.route('/brand') +@login_required +def brand_page(): + candidate_paths = [ + os.path.join(BASE_DIR, 'web_source', 'brand.html'), + os.path.abspath(os.path.join(BASE_DIR, '..', 'web_source', 'brand.html')), + ] + for candidate_path in candidate_paths: + if not os.path.isfile(candidate_path): + continue + try: + with open(candidate_path, 'rb') as f: + raw = f.read() + try: + from html_crypto import decrypt + content = decrypt(raw).decode('utf-8') + except Exception: + if raw[:7] == b'gAAAAAB': + raise + content = raw.decode('utf-8', errors='replace') + return render_template_string(content, user_id=session.get('user_id')) + except Exception: + continue + return _render_html('brand.html', user_id=session.get('user_id')) @main_bp.route('/brand/legacy') @@ -137,12 +152,19 @@ def serve_static(filename): def serve_assets(filename): """提供 static 目录及子目录下的静态文件访问。""" filename = _resolve_asset_filename(filename) - filepath = os.path.normpath(os.path.join(ASSETS_DIR, filename)) - static_abs = os.path.abspath(ASSETS_DIR) - file_abs = os.path.abspath(filepath) - if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): - return '', 404 - return send_file(file_abs, as_attachment=False) + candidate_dirs = [ + os.path.abspath(ASSETS_DIR), + os.path.abspath(os.path.join(BASE_DIR, 'new_web_source', 'assets')), + os.path.abspath(os.path.join(BASE_DIR, '..', 'new_web_source', 'assets')), + ] + for static_abs in candidate_dirs: + filepath = os.path.normpath(os.path.join(static_abs, filename)) + file_abs = os.path.abspath(filepath) + if not file_abs.startswith(static_abs): + continue + if os.path.isfile(file_abs): + return send_file(file_abs, as_attachment=False) + return '', 404 @main_bp.route('/new_web_source/') diff --git a/app/tests/test_amazon_base.py b/app/tests/test_amazon_base.py index 92f47a1..9c2a754 100644 --- a/app/tests/test_amazon_base.py +++ b/app/tests/test_amazon_base.py @@ -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() diff --git a/app/tests/test_patrol_delete.py b/app/tests/test_patrol_delete.py index 4afd51a..b59b617 100644 --- a/app/tests/test_patrol_delete.py +++ b/app/tests/test_patrol_delete.py @@ -1,4 +1,4 @@ -from amazon.patrol_delete import InventoryManage, ProductTask +from amazon.patrol_delete import InventoryManage, PatrolDeleteTask def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch): @@ -8,6 +8,9 @@ def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch status_code = 200 text = '{"success":true}' + def json(self): + return {"success": True} + def fake_post(url, **kwargs): captured["url"] = url captured["kwargs"] = kwargs @@ -24,7 +27,7 @@ def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch ] cart_ratios = [{"country": "德国", "ratio": "25%"}] - ProductTask().post_result( + PatrolDeleteTask().post_result( task_id=3089, shop_name="郭亚芳", country_sections=country_sections, @@ -50,6 +53,130 @@ def test_patrol_delete_task_post_result_builds_patrol_delete_payload(monkeypatch assert captured["kwargs"]["json"]["shops"][0]["submissionId"].startswith("patrol-delete:3089:郭亚芳:") +def test_patrol_delete_task_post_result_rejects_empty_success_payload(monkeypatch): + calls = [] + + def fake_post(url, **kwargs): + calls.append((url, kwargs)) + raise AssertionError("empty success payload must not be posted") + + monkeypatch.setattr("amazon.patrol_delete.requests.post", fake_post) + + try: + PatrolDeleteTask().post_result(task_id=3089, shop_name="郭亚芳", country_sections=[], cart_ratios=[]) + except RuntimeError as exc: + assert "缺少国家状态数据" in str(exc) + else: + raise AssertionError("expected empty success payload to fail") + + assert calls == [] + + +def test_patrol_delete_task_post_result_retries_business_failure(monkeypatch): + calls = [] + + class Response: + status_code = 200 + text = '{"success":false,"message":"bad payload"}' + + def json(self): + return {"success": False, "message": "bad payload"} + + def fake_post(url, **kwargs): + calls.append((url, kwargs)) + return Response() + + monkeypatch.setattr("config.DELETE_BRAND_API_BASE", "http://java.example") + monkeypatch.setattr("amazon.patrol_delete.requests.post", fake_post) + monkeypatch.setattr("amazon.patrol_delete.time.sleep", lambda seconds: None) + + try: + PatrolDeleteTask().post_result( + task_id=3089, + shop_name="郭亚芳", + country_sections=[{"country": "德国", "rows": [{"status": "全部", "quantity": "1"}]}], + cart_ratios=[{"country": "德国", "ratio": ""}], + ) + except RuntimeError as exc: + assert "最终失败" in str(exc) + else: + raise AssertionError("expected Java business failure to fail") + + assert len(calls) == 3 + + +def test_normalize_country_sections_omits_status_fields_for_complete_draft_rows(): + rows = PatrolDeleteTask._normalize_country_sections_for_result( + [ + { + "country": "意大利", + "rows": [ + { + "status": "商品信息草稿", + "quantity": "12", + "deleteQuantity": "3", + "processStatus": "已完成", + }, + { + "status": "缺少的信息", + "quantity": "1", + "deleteQuantity": "1", + "processStatus": "已完成", + }, + ], + } + ] + ) + + assert rows == [ + { + "country": "意大利", + "rows": [ + {"quantity": "12", "deleteQuantity": "3"}, + {"status": "缺少的信息", "quantity": "1", "deleteQuantity": "1", "processStatus": "已完成"}, + ], + } + ] + + +def test_replace_complete_draft_rows_uses_quick_view_rows(): + country_section = { + "country": "西班牙", + "rows": [ + {"status": "全部", "quantity": "6230", "deleteQuantity": "0", "processStatus": "无可删商品"}, + {"status": "商品信息草稿", "quantity": "", "deleteQuantity": "0", "processStatus": ""}, + ], + } + complete_draft_section = { + "country": "西班牙", + "rows": [ + {"type": "completeDraft", "quantity": "1", "deleteQuantity": "1"}, + {"type": "completeDraft", "quantity": "0", "deleteQuantity": "0"}, + ], + } + + merged = PatrolDeleteTask._replace_complete_draft_rows(country_section, complete_draft_section) + + assert merged == { + "country": "西班牙", + "rows": [ + {"status": "全部", "quantity": "6230", "deleteQuantity": "0", "processStatus": "无可删商品"}, + {"type": "completeDraft", "quantity": "1", "deleteQuantity": "1"}, + {"type": "completeDraft", "quantity": "0", "deleteQuantity": "0"}, + ], + } + assert PatrolDeleteTask._normalize_country_sections_for_result([merged]) == [ + { + "country": "西班牙", + "rows": [ + {"status": "全部", "quantity": "6230", "deleteQuantity": "0", "processStatus": "无可删商品"}, + {"quantity": "1", "deleteQuantity": "1"}, + {"quantity": "0", "deleteQuantity": "0"}, + ], + } + ] + + def test_patrol_delete_task_process_task_aggregates_country_results(monkeypatch): from config import runing_shop, runing_task @@ -104,16 +231,16 @@ def test_patrol_delete_task_process_task_aggregates_country_results(monkeypatch) "reopenRequired": True, } - monkeypatch.setattr(ProductTask, "open_shop", fake_open_shop) - monkeypatch.setattr(ProductTask, "process_country", fake_process_country) - monkeypatch.setattr(ProductTask, "post_result", fake_post_result) + monkeypatch.setattr(PatrolDeleteTask, "open_shop", fake_open_shop) + monkeypatch.setattr(PatrolDeleteTask, "process_country", fake_process_country) + monkeypatch.setattr(PatrolDeleteTask, "post_result", fake_post_result) country_sections = [ {"country": "德国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]}, {"country": "英国", "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "", "processStatus": ""}]}, ] cart_ratios = [{"country": "德国", "ratio": ""}, {"country": "英国", "ratio": ""}] - ProductTask().process_task( + PatrolDeleteTask().process_task( { "type": "patrol-delete-run", "data": { @@ -173,10 +300,10 @@ def test_patrol_delete_task_process_task_skips_missing_required_data(monkeypatch def fake_post_result(self, **kwargs): calls.append(kwargs) - monkeypatch.setattr(ProductTask, "post_result", fake_post_result) + monkeypatch.setattr(PatrolDeleteTask, "post_result", fake_post_result) - ProductTask().process_task({"type": "patrol-delete-run", "data": {"items": [{"shopName": "郭亚芳"}]}}) - ProductTask().process_task({"type": "patrol-delete-run", "data": {"taskId": 1, "items": []}}) + PatrolDeleteTask().process_task({"type": "patrol-delete-run", "data": {"items": [{"shopName": "郭亚芳"}]}}) + PatrolDeleteTask().process_task({"type": "patrol-delete-run", "data": {"taskId": 1, "items": []}}) assert calls == [] @@ -194,10 +321,10 @@ def test_patrol_delete_task_reports_shop_error(monkeypatch): def fake_open_shop(self, **kwargs): return None - monkeypatch.setattr(ProductTask, "open_shop", fake_open_shop) - monkeypatch.setattr(ProductTask, "post_result", fake_post_result) + monkeypatch.setattr(PatrolDeleteTask, "open_shop", fake_open_shop) + monkeypatch.setattr(PatrolDeleteTask, "post_result", fake_post_result) - ProductTask().process_task( + PatrolDeleteTask().process_task( { "type": "patrol-delete-run", "data": { @@ -216,91 +343,121 @@ def test_patrol_delete_task_reports_shop_error(monkeypatch): assert calls == [{"task_id": 2, "shop_name": "郭亚芳", "error": "店铺 郭亚芳 打开失败", "shop_done": True}] -def test_delete_all_listings_from_non_whitelisted_statuses_loops_until_no_candidates(monkeypatch): +def test_delete_all_listings_from_non_whitelisted_statuses_deletes_each_row_in_status(monkeypatch): driver = InventoryManage({}) - sections = [ + status_rows = [ [ - { - "country": "德国", - "rows": [ - {"status": "商品信息草稿", "quantity": "2", "deleteQuantity": "", "processStatus": ""}, - {"status": "全部", "quantity": "9", "deleteQuantity": "", "processStatus": ""}, - ], - } + {"status": "商品信息草稿", "quantity": "2", "deleteQuantity": "", "processStatus": ""}, + {"status": "全部", "quantity": "9", "deleteQuantity": "", "processStatus": ""}, ], [ - { - "country": "德国", - "rows": [ - {"status": "商品信息草稿", "quantity": "1", "deleteQuantity": "", "processStatus": ""}, - {"status": "全部", "quantity": "8", "deleteQuantity": "", "processStatus": ""}, - ], - } - ], - [ - { - "country": "德国", - "rows": [ - {"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""}, - ], - } - ], - [ - { - "country": "德国", - "rows": [ - {"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""}, - ], - } + {"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""}, ], ] state = {"index": 0, "selected": [], "deleted": 0} - def fake_get_listing_status_country_sections(shop_name, country): - current = sections[state["index"]] + def fake_read_listing_status_rows_for_delete(shop_name, country): + current = status_rows[min(state["index"], len(status_rows) - 1)] state["index"] += 1 return current def fake_select_listing_status(status): state["selected"].append(status) - def fake_get_first_inventory_row(status): - return {"sku": f"sku-{state['deleted'] + 1}"} + def fake_delete_selected_filtered_inventory_rows(status): + state["deleted"] += 2 + return { + "deletedCount": 2, + "target": {"sku": "bulk-selected", "rawText": "bulk-selected"}, + "successMessage": f"{status} 删除成功", + } - def fake_summarize_inventory_row(row): - return {"sku": row["sku"], "rawText": row["sku"]} - - def fake_delete_inventory_row(row, status): - state["deleted"] += 1 - return f"{status} 删除成功" - - monkeypatch.setattr(driver, "get_listing_status_country_sections", fake_get_listing_status_country_sections) + monkeypatch.setattr(driver, "_read_listing_status_rows_for_delete", fake_read_listing_status_rows_for_delete) monkeypatch.setattr(driver, "select_listing_status", fake_select_listing_status) - monkeypatch.setattr(driver, "_get_first_inventory_row", fake_get_first_inventory_row) - monkeypatch.setattr(driver, "_summarize_inventory_row", fake_summarize_inventory_row) - monkeypatch.setattr(driver, "_delete_inventory_row", fake_delete_inventory_row) + monkeypatch.setattr(driver, "_delete_selected_filtered_inventory_rows", fake_delete_selected_filtered_inventory_rows) monkeypatch.setattr(driver, "_wait_inventory_loader", lambda timeout=5: None) result = driver.delete_all_listings_from_non_whitelisted_statuses("郭亚芳", "德国") - assert state["selected"] == ["商品信息草稿", "商品信息草稿"] + assert state["selected"] == ["商品信息草稿"] assert state["deleted"] == 2 assert result["deleteCounts"] == {"商品信息草稿": 2} assert result["totalDeleted"] == 2 - assert result["stopReason"] == "no-candidates" + assert result["stopReason"] == "processed-candidates" assert result["statusRows"] == [{"status": "全部", "quantity": "7", "deleteQuantity": "", "processStatus": ""}] - assert [item["target"]["sku"] for item in result["results"]] == ["sku-1", "sku-2"] + assert [item["target"]["sku"] for item in result["results"]] == ["bulk-selected"] + + +def test_select_listing_status_uses_dropdown_option_not_visible_status(monkeypatch): + driver = InventoryManage({}) + calls = [] + + monkeypatch.setattr(driver, "open_listing_status_dropdown", lambda: calls.append("open") or {"ok": True}) + monkeypatch.setattr(driver, "_wait_inventory_loader", lambda timeout=5: None) + monkeypatch.setattr(driver, "_run_js", lambda script, *args: calls.append((script, args)) or {"ok": True}) + monkeypatch.setattr("amazon.patrol_delete.time.sleep", lambda seconds: None) + + result = driver.select_listing_status("商品信息草稿") + + assert result["mode"] == "dropdown-option" + assert calls[0] == "open" + assert calls[-1][1] == ({"status": "商品信息草稿"},) + + +def test_get_listing_status_dropdown_options_closes_dropdown_after_read(monkeypatch): + driver = InventoryManage({}) + calls = [] + + monkeypatch.setattr(driver, "open_listing_status_dropdown", lambda: calls.append("open") or {"ok": True}) + monkeypatch.setattr( + driver, + "_wait_listing_status_option_rows", + lambda: calls.append("read") or [{"raw_text": "详情页面已删除 (1)", "value": "DetailPageRemoved"}], + ) + monkeypatch.setattr(driver, "_close_listing_status_dropdown", lambda: calls.append("close")) + + rows = driver.get_listing_status_dropdown_options("郭亚芳", "德国") + + assert calls == ["open", "read", "close"] + assert rows[0]["status"] == "详情页面已删除" + + +def test_open_listing_status_dropdown_waits_until_component_enabled(monkeypatch): + driver = InventoryManage({}) + calls = [] + + class Wait: + def doc_loaded(self, raise_err=False): + calls.append("doc_loaded") + + class Tab: + wait = Wait() + + responses = [ + {"ok": False, "reason": "listing status dropdown disabled/loading"}, + {"ok": True, "selected": {"text": "全部在售不可售商品信息草稿"}}, + ] + + driver.tab = Tab() + monkeypatch.setattr(driver, "_wait_inventory_loader", lambda timeout=5: calls.append(("loader", timeout))) + monkeypatch.setattr(driver, "_run_js", lambda script: calls.append("js") or responses.pop(0)) + monkeypatch.setattr("amazon.patrol_delete.time.sleep", lambda seconds: calls.append(("sleep", seconds))) + + result = driver.open_listing_status_dropdown() + + assert result["ok"] is True + assert calls.count("js") == 2 def test_extract_country_cart_ratio_prefers_ratio2daysago_only(): - ratio = ProductTask._extract_country_cart_ratio( + ratio = PatrolDeleteTask._extract_country_cart_ratio( [ {"country": "德国", "ratio2DaysAgo": "25%", "ratio30DaysAgo": "19%"}, {"country": "英国", "ratio30DaysAgo": "40%"}, ], "德国", ) - missing_ratio2 = ProductTask._extract_country_cart_ratio( + missing_ratio2 = PatrolDeleteTask._extract_country_cart_ratio( [{"country": "英国", "ratio30DaysAgo": "40%"}], "英国", ) @@ -455,6 +612,20 @@ def test_build_deletable_status_rows_keeps_non_whitelisted_statuses(): ] +def test_build_deletable_status_rows_skips_group_labels_without_quantity(): + rows = [ + {"status": "商品信息草稿", "quantity": ""}, + {"status": "详情页面已删除", "quantity": None}, + {"status": "自定义异常状态", "quantity": "0"}, + {"status": "缺少的信息", "quantity": "1"}, + {"status": "全部", "quantity": ""}, + ] + + assert InventoryManage._build_deletable_status_rows(rows) == [ + {"status": "缺少的信息", "canonicalStatus": "缺少的信息", "quantity": 1}, + ] + + def test_build_deletable_status_rows_compatibility_map_prevents_whitelist_misdelete(): rows = [ {"status": "搜尋結果中禁止顯示", "quantity": "4"}, @@ -480,20 +651,43 @@ def test_build_deletable_status_rows_keeps_legacy_non_whitelist_statuses_deletab ] -def test_limit_delete_candidates_enforces_single_delete_cap(): - candidates = [ - {"status": "商品信息草稿", "canonicalStatus": "商品信息草稿", "quantity": 2}, - {"status": "自定义异常状态", "canonicalStatus": "自定义异常状态", "quantity": 5}, - ] - - assert InventoryManage._limit_delete_candidates(candidates, max_delete_count=1) == [candidates[0]] - - def test_is_delete_success_message_accepts_both_copy_variants(): assert InventoryManage._is_delete_success_message("1 个商品已经删除。所作更改需要15分钟才会显示在商品详情页面上") assert InventoryManage._is_delete_success_message("所做更改需要 15 分钟才会显示在商品详情页面上。 1 个商品已删除。") +def test_extract_delete_success_count_accepts_success_message(): + assert InventoryManage._extract_delete_success_count("所做更改需要 15 分钟才会显示在商品详情页面上。 3 个商品已删除。") == 3 + + +def test_build_country_section_result_sets_zero_delete_quantity_when_no_deletable_products(): + section = PatrolDeleteTask._build_country_section_result( + country_name="德国", + status_rows=[{"status": "全部", "quantity": "12"}], + delete_counts={}, + default_process_status="无可删商品", + ) + + assert section == { + "country": "德国", + "rows": [{"status": "全部", "quantity": "12", "deleteQuantity": "0", "processStatus": "无可删商品"}], + } + + +def test_build_country_section_result_sets_zero_delete_quantity_for_empty_no_deletable_result(): + section = PatrolDeleteTask._build_country_section_result( + country_name="德国", + status_rows=[], + delete_counts={}, + default_process_status="无可删商品", + ) + + assert section == { + "country": "德国", + "rows": [{"status": "全部", "quantity": "", "deleteQuantity": "0", "processStatus": "无可删商品"}], + } + + def test_parse_label_quantity_text_accepts_complete_draft_sample(): assert InventoryManage._parse_label_quantity_text("未提交的草稿 (0)") == ("未提交的草稿", 0)