From 250c7e27ebeea612ac86ae0fc90cf422858a1675 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 14 Jul 2026 16:31:35 +0800 Subject: [PATCH] fix(collect): recognize short Shopee product titles --- app/editor.py | 275 +++++++++++++++++++++++++++++-------- docs/04-architecture.md | 10 +- docs/api.md | 6 +- docs/tasks/T-632.md | 10 +- tests/test_editor_login.py | 147 +++++++++++++++++++- 5 files changed, 378 insertions(+), 70 deletions(-) diff --git a/app/editor.py b/app/editor.py index a083d0b..b11783f 100644 --- a/app/editor.py +++ b/app/editor.py @@ -18,8 +18,19 @@ from .cdp import ( DEFAULT_REGION_HOST = "seller.shopee.tw" -TITLE_XPATH = "//input[@class='eds-input__input' and string-length(@modelvalue)>24]" +TITLE_FIELD_SELECTOR = '[data-product-edit-field-unique-id="name"]' +IMAGE_FIELD_SELECTOR = '[data-product-edit-field-unique-id="images"]' +TITLE_XPATH = ( + "//*[@data-product-edit-field-unique-id='name']" + "//input[contains(concat(' ', normalize-space(@class), ' '), ' eds-input__input ')]" +) +LEGACY_TITLE_XPATH = "//input[@class='eds-input__input' and string-length(@modelvalue)>24]" ITEMBOX_XPATH = ( + "//*[@data-product-edit-field-unique-id='images']" + "//div[contains(concat(' ', normalize-space(@class), ' '), " + "' shopee-image-manager__itembox ') and @data-draggable='true']" +) +LEGACY_ITEMBOX_XPATH = ( "//div[@class='container']/div[@class='can-drag shopee-image-manager__itembox' " "and @data-draggable='true']" ) @@ -35,22 +46,67 @@ LOGIN_PATH_MARKERS = ( "seller/accounts/signin", ) +JS_EDITOR_FIELD_HELPERS = "".join( + ( + "function cmVisible(e){if(!e)return false;var r=e.getBoundingClientRect();" + "var s=getComputedStyle(e);return r.width>0&&r.height>0&&" + "s.visibility!=='hidden'&&s.display!=='none';}", + "function cmTitleCandidate(){", + f"var root=document.querySelector({json.dumps(TITLE_FIELD_SELECTOR)});", + "if(root){var primary=[].slice.call(root.querySelectorAll('input.eds-input__input'))" + ".filter(cmVisible);return {node:primary.length===1?primary[0]:null,count:primary.length," + "source:'business',root_exists:true};}", + f"var snap=document.evaluate({json.dumps(LEGACY_TITLE_XPATH)},document,null," + "XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);", + "var legacy=[];for(var i=0;i0 && !!r.singleNodeValue && !!up);})()" + + JS_EDITOR_FIELD_HELPERS + + "var title=cmTitleCandidate();var image=cmMainImageContext();" + "var items=cmMainImageItems(image);var up=cmMainUploadInput(image);" + "var uploads=image.node?image.node.querySelectorAll(" + "'.shopee-image-manager__upload input[type=file]'):[];" + "var srcs=items.map(function(item){var img=item.querySelector('img');return img?img.src:null;})" + ".filter(Boolean);" + "var ready=title.count===1&&items.length>0&&uploads.length===1;" + "return JSON.stringify({ready:ready,title_count:title.count,title_source:title.source," + "title_field_exists:title.root_exists,title_ambiguous:title.count>1," + "image_field_exists:image.root_exists,image_manager_exists:!!image.node," + "image_count:items.length,cdn_count:srcs.filter(function(s){return /susercontent/.test(s);}).length," + "blob_count:srcs.filter(function(s){return /^blob:/.test(s);}).length," + "upload_input_exists:!!up,upload_input_count:uploads.length,current_url:location.href});})()" ) JS_RECTS = ( "(function(){" - f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null," - "XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);" - "var a=[];for(var i=0;i0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}" + + JS_EDITOR_FIELD_HELPERS + + "function visible(e){return cmVisible(e);}" "function text(e){return ((e&&((e.innerText||e.textContent)||''))||'').trim();}" - "var manager=document.querySelector('.shopee-image-manager');" - "var items=manager?[].slice.call(manager.querySelectorAll('.shopee-image-manager__itembox,[class*=image-manager__itembox]')):[];" + "var image=cmMainImageContext();var manager=image.node;var items=cmMainImageItems(image);" "var imgs=items.map(function(item){var img=item.querySelector('img');return img?img.src:null;}).filter(Boolean);" - "var upload=manager?manager.querySelector('.shopee-image-manager__upload input[type=file]'):null;" - "var uploadBox=manager?manager.querySelector('.shopee-image-manager__upload,[class*=image-manager__upload]'):null;" + "var upload=cmMainUploadInput(image);var uploadBox=cmMainUploadBox(image);" "var busy=manager?[].slice.call(manager.querySelectorAll('[class*=loading],[class*=Loading],[class*=spinner],[class*=Spinner],[class*=progress],[class*=Progress],[class*=uploading],[class*=Uploading]')).filter(visible):[];" "var errRe=/(失敗|失败|錯誤|错误|不支援|不支持|格式|大小|尺寸|超過|超过|重複|重复|duplicate|error|fail|invalid|unsupported)/i;" "var uploadRe=/(圖片|图片|封面|照片|相片|圖像|图像|image|photo|cover|upload|上傳|上传|檔案|文件|file|格式|大小|尺寸|像素|解析度|分辨率|超過|超过|重複|重复|duplicate)/i;" @@ -80,11 +134,13 @@ JS_UPLOAD_STATE = ( "var errorToasts=pageErrorToasts.filter(function(t){return uploadRe.test(t);}).slice(0,8);" "return JSON.stringify({" "manager_exists:!!manager," + "image_field_exists:image.root_exists," "count:items.length," "srcs:imgs.slice(0,12)," "blob_count:imgs.filter(function(s){return /^blob:/.test(s);}).length," "cdn_count:imgs.filter(function(s){return /susercontent/.test(s);}).length," "upload_input_exists:!!upload," + "upload_input_count:manager?manager.querySelectorAll('.shopee-image-manager__upload input[type=file]').length:0," "upload_input_disabled:!!(upload&&(upload.disabled||upload.getAttribute('aria-disabled')==='true'))," "upload_tile_visible:visible(uploadBox)," "busy_count:busy.length," @@ -99,10 +155,9 @@ JS_UPLOAD_STATE = ( JS_CLICK_UPLOAD_TILE = ( "(function(){" - "function visible(e){if(!e)return false;var r=e.getBoundingClientRect();var s=getComputedStyle(e);" - "return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}" - "var manager=document.querySelector('.shopee-image-manager');" - "var box=manager?manager.querySelector('.shopee-image-manager__upload,[class*=image-manager__upload]'):null;" + + JS_EDITOR_FIELD_HELPERS + + "function visible(e){return cmVisible(e);}" + "var image=cmMainImageContext();var box=cmMainUploadBox(image);" "if(!box)return JSON.stringify({clicked:false,reason:'NO_UPLOAD_TILE'});" "if(!visible(box))return JSON.stringify({clicked:false,reason:'UPLOAD_TILE_HIDDEN'});" "box.scrollIntoView({block:'center',inline:'center'});" @@ -112,18 +167,41 @@ JS_CLICK_UPLOAD_TILE = ( ) JS_TITLE_STATE = ( "(function(){" - f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," - "XPathResult.FIRST_ORDERED_NODE_TYPE,null);" - "var el=r.singleNodeValue;if(!el)return null;" - "return JSON.stringify({value:el.value,modelvalue:el.getAttribute('modelvalue')});})()" + + JS_EDITOR_FIELD_HELPERS + + "var title=cmTitleCandidate();var el=title.node;" + "return JSON.stringify({found:!!el,candidate_count:title.count,source:title.source," + "field_exists:title.root_exists,value:el?el.value:null," + "modelvalue:el?el.getAttribute('modelvalue'):null});})()" ) JS_FIRST_COVER = ( "(function(){" - f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null," - "XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);" - "if(!s.snapshotLength)return null;" - "var img=s.snapshotItem(0).querySelector('img');return img?img.src:null;})()" + + JS_EDITOR_FIELD_HELPERS + + "var image=cmMainImageContext();var items=cmMainImageItems(image);" + "if(!items.length)return null;" + "var img=items[0].querySelector('img');return img?img.src:null;})()" +) + +JS_SCROLL_MAIN_IMAGE_MANAGER = ( + "(function(){" + + JS_EDITOR_FIELD_HELPERS + + "var image=cmMainImageContext();var manager=image.node;" + "if(manager)manager.scrollIntoView({block:'center'});return !!manager;})()" +) + +JS_MAIN_UPLOAD_INPUT = ( + "(function(){" + + JS_EDITOR_FIELD_HELPERS + + "var image=cmMainImageContext();return cmMainUploadInput(image);})()" +) + +JS_DISPATCH_MAIN_UPLOAD_INPUT_EVENTS = ( + "(function(){" + + JS_EDITOR_FIELD_HELPERS + + "var image=cmMainImageContext();var up=cmMainUploadInput(image);" + "if(!up)return false;" + "up.dispatchEvent(new Event('input',{bubbles:true}));" + "up.dispatchEvent(new Event('change',{bubbles:true}));return true;})()" ) JS_FIND_UPDATE = ( @@ -229,7 +307,11 @@ JS_PAGE_TOASTS = ( "function record(e){var root=rootOf(e);var value=text(root)||text(e);if(!value)return null;return {text:value,html:((root.outerHTML||e.outerHTML)||'').slice(0,2000),url:location.href,visible:visible(root)||visible(e),created_at:(new Date()).toISOString()};}" "var cached=(window.__cmshopee_toasts||[]).slice(-10);" "var current=Array.from(document.querySelectorAll('.eds-toasts .eds-toast,.eds-toast,.eds-toast__content,[role=alert],[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')).map(record).filter(Boolean);" - "var seen={};var out=[];cached.concat(current).forEach(function(item){var key=[item.text,item.html,item.url].join('|');if(seen[key])return;seen[key]=true;out.push(item);});" + "var positions={};var out=[];" + "cached.forEach(function(item){var copy=Object.assign({},item,{visible:false});var key=[copy.text,copy.url].join('|');" + "if(positions[key]!==undefined){out[positions[key]]=copy;return;}positions[key]=out.length;out.push(copy);});" + "current.forEach(function(item){var key=[item.text,item.url].join('|');" + "if(positions[key]!==undefined){out[positions[key]]=item;return;}positions[key]=out.length;out.push(item);});" "return JSON.stringify(out.slice(-10));" "})()" ) @@ -255,10 +337,10 @@ JS_POST_UPDATE_STATE = ( JS_CLICK_FIRST_DELETE = ( "(function(){" - f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null," - "XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);" - "if(!s.snapshotLength)return JSON.stringify({clicked:false,reason:'NO_IMAGE'});" - "var item=s.snapshotItem(0);" + + JS_EDITOR_FIELD_HELPERS + + "var image=cmMainImageContext();var items=cmMainImageItems(image);" + "if(!items.length)return JSON.stringify({clicked:false,reason:'NO_IMAGE'});" + "var item=items[0];" "['mouseover','mouseenter','mousemove'].forEach(function(n){" "item.dispatchEvent(new MouseEvent(n,{bubbles:true,view:window}));});" "var selectors=['.shopee-image-manager__icon--delete','[class*=\"icon--delete\"]','[class*=\"delete\"]'];" @@ -456,15 +538,29 @@ def _product_unavailable_toast_text(toasts): return None -def _open_product_ready_error(cdp, fallback): +def _same_page_url(left, right): + try: + left_url = urlparse(str(left or "")) + right_url = urlparse(str(right or "")) + except Exception: + return False + return bool( + left_url.netloc + and left_url.netloc == right_url.netloc + and left_url.path.rstrip("/") == right_url.path.rstrip("/") + ) + + +def _open_product_ready_error(cdp, fallback, current_url=""): toasts = read_page_toasts(cdp) invalid_text = _product_unavailable_toast_text(toasts) if invalid_text: return product_unavailable_error_message(invalid_text) + page_url = str(current_url or _current_url(cdp) or "") for toast in reversed(toasts): text = str(toast.get("text") or "").strip() - if text: - return f"{fallback}:{text}" + if text and toast.get("visible") and _same_page_url(toast.get("url"), page_url): + return f"{fallback};页面提示(可能无关):{text}" return fallback @@ -472,6 +568,58 @@ def _title_state(cdp): return _json_value(cdp, JS_TITLE_STATE, default={}) or {} +def _ready_state(cdp): + raw = cdp.val(JS_READY) + if isinstance(raw, bool): + return {"ready": raw} + if isinstance(raw, dict): + return raw + if raw in (None, ""): + return {} + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + + +def _ready_error_message(fallback, state): + if not state: + return fallback + + details = [] + title_count = state.get("title_count") + if title_count == 1: + details.append("商品名称输入框正常") + elif isinstance(title_count, int) and title_count > 1: + details.append(f"商品名称输入框匹配到{title_count}个,无法确定唯一字段") + elif title_count is not None: + details.append("未识别商品名称输入框") + + image_count = state.get("image_count") + if not state.get("image_manager_exists") and "image_manager_exists" in state: + details.append("未识别商品主图区域") + elif isinstance(image_count, int) and image_count > 0: + details.append(f"商品图片{image_count}张") + elif image_count is not None: + details.append("商品图片尚未加载") + + upload_count = state.get("upload_input_count") + if upload_count == 1: + details.append("主图上传入口正常") + elif isinstance(upload_count, int) and upload_count > 1: + details.append(f"主图上传入口匹配到{upload_count}个,无法确定唯一入口") + elif "upload_input_exists" in state or upload_count is not None: + details.append("主图上传入口尚未加载") + + current_url = str(state.get("current_url") or "") + if current_url: + try: + parsed = urlparse(current_url) + current_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + except Exception: + pass + details.append(f"当前页面:{current_url}") + return f"{fallback}:{';'.join(details)}" if details else fallback + + def _image_rects(cdp): return _json_value(cdp, JS_RECTS, default=[]) or [] @@ -644,13 +792,14 @@ def _cover_upload_error_message(result): def _wait_ready(cdp, timeout=60): end = time.time() + timeout - while time.time() < end: + last_state = {} + first_probe = True + while first_probe or time.time() < end: + first_probe = False try: - if cdp.val(JS_READY): - cdp.val( - "(function(){var m=document.querySelector('.shopee-image-manager');" - "if(m)m.scrollIntoView({block:'center'});return 1;})()" - ) + last_state = _ready_state(cdp) + if last_state.get("ready"): + cdp.val(JS_SCROLL_MAIN_IMAGE_MANAGER) time.sleep(0.5) return True except Exception: @@ -658,8 +807,17 @@ def _wait_ready(cdp, timeout=60): invalid_text = _product_unavailable_toast_text(read_page_toasts(cdp)) if invalid_text: raise EditorError(product_unavailable_error_message(invalid_text)) + if time.time() >= end: + break time.sleep(1) - raise EditorError(_open_product_ready_error(cdp, "等待蝦皮商品编辑器就绪超时")) + fallback = _ready_error_message("等待蝦皮商品编辑器就绪超时", last_state) + raise EditorError( + _open_product_ready_error( + cdp, + fallback, + current_url=last_state.get("current_url") if last_state else "", + ) + ) def _ensure_page_domains(cdp): @@ -1073,9 +1231,9 @@ def change_title(cdp, new_title) -> dict: expr = ( "(function(){" - f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," - "XPathResult.FIRST_ORDERED_NODE_TYPE,null);var el=r.singleNodeValue;" - "if(!el)return 'NO_INPUT';" + + JS_EDITOR_FIELD_HELPERS + + "var title=cmTitleCandidate();var el=title.node;" + "if(!el)return title.count>1?'AMBIGUOUS_INPUT':'NO_INPUT';" "var s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;" f"el.focus();s.call(el,{json.dumps(str(new_title))});" "el.dispatchEvent(new Event('input',{bubbles:true}));" @@ -1087,7 +1245,14 @@ def change_title(cdp, new_title) -> dict: value = state.get("value") modelvalue = state.get("modelvalue") ok = value == str(new_title) and modelvalue == str(new_title) - return {"ok": ok, "written": written, "value": value, "modelvalue": modelvalue} + return { + "ok": ok, + "written": written, + "value": value, + "modelvalue": modelvalue, + "candidate_count": state.get("candidate_count"), + "source": state.get("source"), + } def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict: @@ -1101,10 +1266,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict if not os.path.exists(image_win_path): raise FileNotFoundError(f"封面图片不存在: {image_win_path}") - cdp.val( - "(function(){var m=document.querySelector('.shopee-image-manager');" - "if(m)m.scrollIntoView({block:'center'});return !!m;})()" - ) + cdp.val(JS_SCROLL_MAIN_IMAGE_MANAGER) time.sleep(0.5) stable = _wait_image_manager_stable( cdp, @@ -1172,7 +1334,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict "upload_state": _upload_state(cdp), } time.sleep(1) - oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')") + oid = cdp.object_id(JS_MAIN_UPLOAD_INPUT) if not oid: return { "ok": False, @@ -1184,12 +1346,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict } cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [image_win_path]}) - changed = cdp.val( - "(function(){var up=document.querySelector('.shopee-image-manager__upload input[type=file]');" - "if(!up)return false;" - "up.dispatchEvent(new Event('input',{bubbles:true}));" - "up.dispatchEvent(new Event('change',{bubbles:true}));return true;})()" - ) + changed = cdp.val(JS_DISPATCH_MAIN_UPLOAD_INPUT_EVENTS) time.sleep(2) new_src = None last_state = _upload_state(cdp) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 9cc619a..3a1f8cc 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -477,14 +477,14 @@ data/images///__new. # AI 生成的新 | 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。①采集和⑥只读商品页关闭本轮自动新建 target 后,最多等待 2 秒确认 target 从 `/json` 消失;超时只记诊断,不覆盖成功结果,复用的用户已有 tab 不关闭。③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 | | 登录 target 竞态 | `/json/close/` 返回成功不代表 target 已立即从 `/json` 消失。登录检测不得固定使用枚举到的第一个 Shopee page;连接或 Cookie API 因 target 销毁失败时应快速重选有效页面。只有 Cookie API 成功返回且确实缺会话 Cookie 才是 `NO_SESSION_COOKIE`;一次都未成功读取是 `LOGIN_CHECK_TARGET_UNAVAILABLE` | | 前台激活 | ①采集和⑥商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | -| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 | -| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” | +| SPA 就绪 | 不用 load 事件;轮询“唯一商品名称输入框 + 至少一张主图 itembox + 唯一主图上传输入框”三者都在。脚本返回标题命中数、主图/上传入口状态和当前 URL 的就绪快照;超时错误必须指出具体缺失组件,不能只报泛化超时 | +| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。明确商品失效/不存在/无权限类 toast 即使已经隐藏,也优先成为 `open_product` 失败原因并驱动①阶段列显示“商品失效”;其他普通 toast 只有仍可见且属于当前页面 URL 时,才以“页面提示(可能无关)”附加在就绪快照后。已隐藏的物流、备货、库存、价格等编辑校验提示不得覆盖真正缺失的就绪组件;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” | -| 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` | +| 标题输入框 | 主定位为 `data-product-edit-field-unique-id="name"` 业务字段内唯一可见 `input.eds-input__input`,不再用标题字符数判断身份;仅当该业务字段根不存在时,才回退旧 XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]`。主定位命中多个时明确失败,不猜测写入 | | 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 | -| 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 | +| 主图范围与读旧封面 | 主定位为 `data-product-edit-field-unique-id="images"` 内的 `.shopee-image-manager`,其中可拖拽且 `data-draggable=true` 的第一张 itembox `img.src` 是旧封面(`susercontent` CDN);只有业务字段根不存在时才回退旧全局图片管理器/itembox 定位 | | 商品套图原主图读取 | 复用 `open_product(..., bring_to_front=False)` 后台只读打开商品详情页,使用已验证 itembox 顺序读取全部主图 `img.src` 并返回 `{index, src}`;不要求上传 input 之外的新选择器、不改标题/封面、不拖拽、不点击更新;URL 读取完成后再由最多2个图片下载 worker 落盘;本轮自动新建 tab 按采集规则关闭,复用用户已有 tab 不关闭 | -| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;上传前先点击 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` | +| 上传输入框 | 与主图读取共用 `images` 业务字段内同一个 `.shopee-image-manager`,从中取唯一 `.shopee-image-manager__upload input[type=file]`;上传前先点击同一 manager 内 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` | | 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。若手动上传成功但自动上传一直转圈,优先检查是否绕过了上传块点击导致 Shopee 前端上传队列未完整初始化;代码应走“点击上传块 → 等待 → 重新取 input → `DOM.setFileInputFiles`”的人工等价路径。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理;`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即失败并提示新封面与现有商品图片重复 | | 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` | | 换封面删除 | 更新封面统一先删当前第一张,不再只限满 9 张;先确认本地旧封面备份存在,再点第一张删除(`.shopee-image-manager__icon--delete` 或同类 delete 标记)并在可见 dialog/modal/popover 内点删除/确认按钮。关键顺序:删除前只等当前图片列表稳定,不检查上传 input;删除后不能只看数量减少,必须等图片管理器达到删除后数量、无 busy/blob、上传 input 恢复并短暂稳定,再重新获取 input 上传新图、确认取得 Shopee CDN 地址后拖到第一位;备份缺失则拒绝删除。已在 9 图测试商品 `29671243750` 上实测不提交流程,8 图商品也按同一替换语义删除第一张后再上传 | diff --git a/docs/api.md b/docs/api.md index 28980e6..1b8374c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -259,7 +259,7 @@ login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, co is_logged_in(account) -> bool # login_status(...).logged_in;重定向登录页或缺 SPC_ST/SPC_U → False install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间 read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断 -open_product(account, item_id) -> CDP # 连端口、导航商品页、等就绪;标记该 tab 是否本轮自动新建;失败时上浮最近错误 toast,并清理本轮自动新建的失败 tab +open_product(account, item_id) -> CDP # 连端口、导航商品页、等业务字段就绪;标记该 tab 是否本轮自动新建;失败时返回缺失组件/有效错误 toast,并清理本轮自动新建的失败 tab # 采集(只读) read_title(cdp) -> str @@ -284,13 +284,13 @@ apply_task(account, task, close_success_tab=False) -> dict - `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。 - `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。 -- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听;若标题输入框、图片管理器、上传入口等关键元素等待超时,或页面明显不是商品编辑页,应读取最近 `.eds-toast__content`。如果存在错误 toast,例如 `please input correct product id`,返回/抛出的错误信息必须包含该文案,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;不得记录 Cookie、密码、token。调用方只在明确商品失效/商品不存在/无权限类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。 +- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听。标题主定位是 `data-product-edit-field-unique-id="name"` 内唯一可见的 `input.eds-input__input`,主图和上传入口共同限定在 `data-product-edit-field-unique-id="images"` 内同一个 `.shopee-image-manager`;只有相应业务字段根不存在时才使用旧 DOM 回退,不得再用标题长度判断主定位。就绪检查返回标题命中数、主图数量/CDN/blob、上传 input 数量和当前 URL;超时必须先说明缺少标题、主图还是上传入口。明确商品失效/不存在/无权限 toast(如 `please input correct product id`)即使已隐藏也上浮,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;普通物流/备货等 toast 只有仍可见且属于当前页面 URL 时才作为“可能无关”的附加提示,不能覆盖就绪快照。不得记录 Cookie、密码、token。调用方只在明确商品失效类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。 - `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。 - ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。 - `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。 - T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。 -- T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。 +- T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,在 `images` 业务字段内同一个主图 manager 中先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。 ## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP) diff --git a/docs/tasks/T-632.md b/docs/tasks/T-632.md index 64af2ca..51c2dc7 100644 --- a/docs/tasks/T-632.md +++ b/docs/tasks/T-632.md @@ -3,7 +3,7 @@ id: T-632 title: ①采集短标题就绪识别与页面错误诊断修复 phase: 2 deps: [T-404b, T-630] -status: TODO +status: DONE created: 2026-07-14 --- @@ -117,3 +117,11 @@ detail=等待蝦皮商品编辑器就绪超时:此物流選項不支援較長 - 不修改③删除第一张封面、上传、拖拽第一位、更新确认框和成功后关闭 tab 的业务顺序,不点击线上「更新」做本任务验收。 - 不修改 SQLite/Excel schema、AI/cmhub、提示词或 GUI 布局。 - 不提交用户导出的 `48363984966.html`、运营 Excel、`data/` 日志、账号登录态或其他业务数据。 + +## 执行记录 + +- 2026-07-14:标题读取、就绪判断和标题写入统一改用 `name` 业务字段内唯一可见输入框;仅在业务字段根不存在时回退旧长度 XPath,主定位多候选时停止并给出明确诊断。 +- 2026-07-14:主图读取、矩形定位、上传状态、上传入口、删除第一张和上传事件统一限定到 `images` 业务字段内同一个图片管理器;保留旧 DOM 受控回退,不改变③删除、上传、拖拽和提交顺序。 +- 2026-07-14:页面就绪脚本改为结构化快照,超时会分别报告标题、主图、上传入口及当前页面;隐藏的普通物流/备货 toast 不再覆盖缺失组件,明确商品失效 toast 仍可上浮。 +- 2026-07-14:真实商品 `48363984966` 只读 CDP 验证通过:标题命中 1 个(长度 20)、主图 5 张且均为 `susercontent`、上传入口 1 个;`collect()` 顺利经过 `open_product/wait_ready/read_title/read_cover/download_cover`,临时下载封面 131477 字节。因复用用户已有 tab,按规则只断开 CDP、不关闭页面;未执行标题写入或线上更新。 +- 2026-07-14:验证通过:`tests.test_editor_login` 57 项、`tests.test_gui` 182 项、全量单元测试 486 项;`python -m ruff check app tests main.py`、`py -3.10 -m compileall -q app main.py`、`git diff --check` 均通过。全量测试使用仅含本任务改动的干净工作树,未纳入工作区中用户未提交的提示词改名等无关变更。 diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index 0254ba5..f8d9415 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -39,18 +39,23 @@ class FakeCDP: class FakeProductCDP: - def __init__(self, ws, ready=True, toasts=None, rects=None): + def __init__(self, ws, ready=True, toasts=None, rects=None, url=""): self.ws = ws self.ready = ready self.toasts = list(toasts or []) self.rects = list(rects or []) + self.url = url self.closed = False self.sent = [] + self.evaluated = [] self.toast_observer_installed = False def val(self, expr): + self.evaluated.append(expr) if expr == editor.JS_READY: - return self.ready + return json.dumps(self.ready) if isinstance(self.ready, dict) else self.ready + if expr == "location.href": + return self.url if expr == editor.JS_INSTALL_TOAST_OBSERVER: self.toast_observer_installed = True return True @@ -68,6 +73,32 @@ class FakeProductCDP: self.closed = True +class FakeTitleCDP: + def __init__(self, title="短标题"): + self.title = title + self.write_expr = "" + + def val(self, expr): + if expr == editor.JS_TITLE_STATE: + return json.dumps( + { + "found": True, + "candidate_count": 1, + "source": "business", + "field_exists": True, + "value": self.title, + "modelvalue": self.title, + } + ) + self.write_expr = expr + marker = "s.call(el," + if marker in expr: + encoded = expr.split(marker, 1)[1].split(");", 1)[0] + self.title = json.loads(encoded) + return self.title + return None + + def cover_rects(count, prefix="old", start=0): return [ { @@ -84,6 +115,26 @@ def cover_rects(count, prefix="old", start=0): ] +def ready_snapshot(**overrides): + state = { + "ready": True, + "title_count": 1, + "title_source": "business", + "title_field_exists": True, + "title_ambiguous": False, + "image_field_exists": True, + "image_manager_exists": True, + "image_count": 5, + "cdn_count": 5, + "blob_count": 0, + "upload_input_exists": True, + "upload_input_count": 1, + "current_url": "https://seller.shopee.tw/portal/product/48363984966", + } + state.update(overrides) + return state + + class FakeUpdateCDP: def __init__( self, @@ -636,6 +687,98 @@ class EditorLoginTests(unittest.TestCase): fake.sent, ) + def test_editor_business_selectors_replace_title_length_guess(self): + self.assertIn('data-product-edit-field-unique-id="name"', editor.TITLE_FIELD_SELECTOR) + self.assertIn("data-product-edit-field-unique-id='name'", editor.TITLE_XPATH) + self.assertNotIn("string-length", editor.TITLE_XPATH) + self.assertIn("string-length", editor.LEGACY_TITLE_XPATH) + self.assertIn('data-product-edit-field-unique-id="images"', editor.IMAGE_FIELD_SELECTOR) + self.assertIn("data-product-edit-field-unique-id='images'", editor.ITEMBOX_XPATH) + self.assertIn("source:'legacy'", editor.JS_EDITOR_FIELD_HELPERS) + + def test_wait_ready_accepts_short_title_business_field_snapshot(self): + fake = FakeProductCDP("ws-new", ready=ready_snapshot()) + + with mock.patch("app.editor.time.sleep"): + result = editor._wait_ready(fake, timeout=0) + + self.assertTrue(result) + self.assertIn(editor.JS_SCROLL_MAIN_IMAGE_MANAGER, fake.evaluated) + + def test_wait_ready_reports_missing_title_without_hidden_logistics_toast(self): + state = ready_snapshot(ready=False, title_count=0) + fake = FakeProductCDP( + "ws-new", + ready=state, + url=state["current_url"], + toasts=[ + { + "text": "此物流選項不支援較長備貨商品", + "url": state["current_url"], + "visible": False, + } + ], + ) + + with self.assertRaises(editor.EditorError) as ctx: + editor._wait_ready(fake, timeout=0) + + message = str(ctx.exception) + self.assertIn("未识别商品名称输入框", message) + self.assertIn("商品图片5张", message) + self.assertIn("主图上传入口正常", message) + self.assertNotIn("物流選項", message) + + def test_wait_ready_reports_ambiguous_business_title_inputs(self): + fake = FakeProductCDP("ws-new", ready=ready_snapshot(ready=False, title_count=2)) + + with self.assertRaises(editor.EditorError) as ctx: + editor._wait_ready(fake, timeout=0) + + self.assertIn("商品名称输入框匹配到2个,无法确定唯一字段", str(ctx.exception)) + + def test_ready_error_appends_only_visible_same_page_toast_as_hint(self): + url = "https://seller.shopee.tw/portal/product/48363984966" + fake = FakeProductCDP( + "ws-new", + url=url, + toasts=[{"text": "页面暂时繁忙", "url": url, "visible": True}], + ) + + message = editor._open_product_ready_error(fake, "商品编辑器未就绪", current_url=url) + + self.assertEqual("商品编辑器未就绪;页面提示(可能无关):页面暂时繁忙", message) + self.assertIn("Object.assign({},item,{visible:false})", editor.JS_PAGE_TOASTS) + + def test_read_and_change_title_share_business_field_locator(self): + fake = FakeTitleCDP(title="二十字以内的有效短标题") + + self.assertEqual("二十字以内的有效短标题", editor.read_title(fake)) + with mock.patch("app.editor.time.sleep"): + result = editor.change_title(fake, "更新后的短标题") + + self.assertTrue(result["ok"]) + self.assertEqual("business", result["source"]) + self.assertIn(editor.JS_EDITOR_FIELD_HELPERS, fake.write_expr) + self.assertNotIn(editor.TITLE_XPATH, fake.write_expr) + + def test_main_image_scripts_share_scoped_business_field_helpers(self): + scripts = ( + editor.JS_READY, + editor.JS_RECTS, + editor.JS_UPLOAD_STATE, + editor.JS_CLICK_UPLOAD_TILE, + editor.JS_FIRST_COVER, + editor.JS_SCROLL_MAIN_IMAGE_MANAGER, + editor.JS_MAIN_UPLOAD_INPUT, + editor.JS_DISPATCH_MAIN_UPLOAD_INPUT_EVENTS, + editor.JS_CLICK_FIRST_DELETE, + ) + for script in scripts: + with self.subTest(script=script[:40]): + self.assertIn(editor.JS_EDITOR_FIELD_HELPERS, script) + self.assertIn(json.dumps(editor.IMAGE_FIELD_SELECTOR), script) + def test_wait_ready_raises_product_unavailable_from_hidden_toast(self): fake = FakeProductCDP( "ws-new",