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:
+140
-9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user