feat: 完成T-502满9张封面替换

实现 replace_cover 满9张时先校验 old_cover_path 本地备份,备份缺失返回 OLD_COVER_BACKUP_MISSING 且不删除线上图片。

备份存在时点击第一张删除按钮并处理可见确认框,删除后继续上传新封面并拖到第一位;apply_task 会把任务 old_cover_path 传入 replace_cover。

补充 editor 单测覆盖备份缺失阻断、删除确认后上传拖首位、删除按钮缺失和 old_cover_path 传递;同步需求、架构、编码规则、任务看板、API、routes、current-state 与 progress。

已在测试商品 29671243750 上验证 9图删除和满9张替换的不提交流程;未点击更新保存线上。
This commit is contained in:
chengma
2026-06-29 09:49:53 +08:00
parent b05a856796
commit 82d390c25a
10 changed files with 345 additions and 24 deletions
+140 -9
View File
@@ -84,6 +84,46 @@ JS_TOASTS = (
"return JSON.stringify(t.slice(0,5));})()"
)
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);"
"['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\"]'];"
"var btn=null;"
"for(var i=0;i<selectors.length&&!btn;i++){btn=item.querySelector(selectors[i]);}"
"if(!btn){var all=[].slice.call(item.querySelectorAll('*'));"
"btn=all.find(function(e){var t=[e.className,e.innerText,e.title,e.getAttribute('aria-label')].join(' ');"
"return /(delete|删除|刪除)/i.test(t);});}"
"if(!btn)return JSON.stringify({clicked:false,reason:'NO_DELETE_BUTTON'});"
"if(btn.scrollIntoView)btn.scrollIntoView({block:'center'});"
"btn.click();return JSON.stringify({clicked:true,reason:null});})()"
)
JS_CLICK_DELETE_CONFIRM = (
"(function(){"
"function visible(e){var r=e.getBoundingClientRect();var s=getComputedStyle(e);"
"return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}"
"var rootSelectors='[role=dialog],[class*=dialog],[class*=Dialog],[class*=modal],"
"[class*=Modal],[class*=popover],[class*=Popover],[class*=popup],[class*=Popup]';"
"var roots=[].slice.call(document.querySelectorAll(rootSelectors)).filter(visible);"
"var yes=/(删除|刪除|確認|确认|確定|确定|OK|Yes)/i;"
"var no=/(取消|cancel|否|No)/i;"
"var seen=[];var candidates=[];"
"roots.forEach(function(root){[].slice.call(root.querySelectorAll('button,[role=button]')).forEach(function(b){"
"var text=((b.innerText||b.textContent||b.getAttribute('aria-label')||'')+'').trim();"
"if(!text||!visible(b))return;seen.push(text);"
"if(b.disabled||/disabled/i.test(b.className)||no.test(text)||!yes.test(text))return;"
"candidates.push({button:b,text:text,score:/(删除|刪除)/i.test(text)?0:1});});});"
"if(!candidates.length)return JSON.stringify({clicked:false,reason:'NO_CONFIRM_BUTTON',buttons:seen.slice(0,8)});"
"candidates.sort(function(a,b){return a.score-b.score;});"
"candidates[0].button.click();"
"return JSON.stringify({clicked:true,reason:null,text:candidates[0].text});})()"
)
class EditorError(RuntimeError):
"""Raised for expected editor automation failures with user-readable messages."""
@@ -383,11 +423,11 @@ def change_title(cdp, new_title) -> dict:
return {"ok": ok, "written": written, "value": value, "modelvalue": modelvalue}
def replace_cover(cdp, image_win_path, timeout=90) -> dict:
def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=90) -> dict:
"""Upload an image and drag it to the first position.
Full 9-image deletion is intentionally left for T-502 because its confirm dialog
selector is not verified yet.
When the image manager is full, only delete the current first image if the
old cover backup from the collect stage exists locally.
"""
image_win_path = os.path.abspath(str(image_win_path))
@@ -400,12 +440,30 @@ def replace_cover(cdp, image_win_path, timeout=90) -> dict:
)
time.sleep(0.5)
before = _image_rects(cdp)
count_before = len(before)
delete_result = None
if len(before) >= 9:
return {"ok": False, "reason": "FULL_IMAGE_SLOTS", "count_before": len(before)}
backup_path = _validated_old_cover_backup(old_cover_path)
if not backup_path:
return {
"ok": False,
"reason": "OLD_COVER_BACKUP_MISSING",
"count_before": count_before,
"old_cover_path": old_cover_path,
}
delete_result = _delete_first_cover(cdp, before)
if not delete_result.get("ok"):
return {
"ok": False,
"reason": delete_result.get("reason"),
"count_before": count_before,
"delete": delete_result,
}
before = _image_rects(cdp)
before_srcs = {r.get("src") for r in before}
oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')")
if not oid:
return {"ok": False, "reason": "NO_UPLOAD_INPUT", "count_before": len(before)}
return {"ok": False, "reason": "NO_UPLOAD_INPUT", "count_before": count_before, "delete": delete_result}
cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [image_win_path]})
cdp.val(
@@ -430,13 +488,13 @@ def replace_cover(cdp, image_win_path, timeout=90) -> dict:
new_src = ready[-1]["src"]
break
if not new_src:
return {"ok": False, "reason": "UPLOAD_TIMEOUT", "count_before": len(before)}
return {"ok": False, "reason": "UPLOAD_TIMEOUT", "count_before": count_before, "delete": delete_result}
time.sleep(1)
cur = _image_rects(cdp)
new_rect = next((r for r in cur if r.get("src") == new_src), None)
if not new_rect:
return {"ok": False, "reason": "NEW_IMAGE_NOT_FOUND", "new_src": new_src}
return {"ok": False, "reason": "NEW_IMAGE_NOT_FOUND", "new_src": new_src, "delete": delete_result}
first = cur[0]
cdp.drag(new_rect["x"], new_rect["y"], first["left"] - first["w"] * 0.30, first["y"])
time.sleep(1.2)
@@ -449,8 +507,77 @@ def replace_cover(cdp, image_win_path, timeout=90) -> dict:
"reason": None if cover_ok else "DRAG_NOT_FIRST",
"new_src": new_src,
"index": index,
"count_before": len(before),
"count_before": count_before,
"count_after": len(after),
"delete": delete_result,
}
def _validated_old_cover_backup(old_cover_path):
if not old_cover_path:
return None
path = os.path.abspath(str(old_cover_path))
if not os.path.exists(path):
return None
return path
def _delete_first_cover(cdp, before, timeout=15):
count_before = len(before)
if not before:
return {"ok": False, "reason": "NO_IMAGE", "count_before": 0}
first = before[0]
try:
cdp.send(
"Input.dispatchMouseEvent",
{"type": "mouseMoved", "x": first["x"], "y": first["y"]},
)
time.sleep(0.2)
except Exception:
pass
click_result = _json_value(
cdp,
JS_CLICK_FIRST_DELETE,
default={"clicked": False, "reason": "NO_DELETE_BUTTON"},
) or {}
if not click_result.get("clicked"):
return {
"ok": False,
"reason": click_result.get("reason") or "NO_DELETE_BUTTON",
"count_before": count_before,
"click": click_result,
}
confirm_result = None
end = time.time() + timeout
while time.time() < end:
cur = _image_rects(cdp)
if len(cur) < count_before:
return {
"ok": True,
"reason": None,
"count_before": count_before,
"count_after": len(cur),
"click": click_result,
"confirm": confirm_result,
}
confirm_attempt = _json_value(
cdp,
JS_CLICK_DELETE_CONFIRM,
default={"clicked": False, "reason": "NO_CONFIRM_BUTTON"},
) or {}
if confirm_attempt.get("clicked"):
confirm_result = confirm_attempt
time.sleep(0.5)
cur = _image_rects(cdp)
return {
"ok": False,
"reason": "DELETE_TIMEOUT",
"count_before": count_before,
"count_after": len(cur),
"click": click_result,
"confirm": confirm_result,
}
@@ -491,7 +618,11 @@ def apply_task(account, task, close_success_tab=False) -> dict:
if not title_result.get("ok"):
return {"committed": False, "error": "标题写入后 value/modelvalue 未同步", "title": title_result}
if new_cover_path:
cover_result = replace_cover(cdp, new_cover_path)
cover_result = replace_cover(
cdp,
new_cover_path,
old_cover_path=_get(task, "old_cover_path"),
)
if not cover_result.get("ok"):
return {"committed": False, "error": cover_result.get("reason"), "cover": cover_result}
update_result = click_update(cdp)