fix: handle Shopee update confirmation and upload edge cases

This commit is contained in:
chengma
2026-06-30 11:44:27 +08:00
parent cd1256bc5e
commit 580a7a570d
6 changed files with 877 additions and 33 deletions
+421 -15
View File
@@ -43,6 +43,47 @@ JS_RECTS = (
"return JSON.stringify(a);})()" "return JSON.stringify(a);})()"
) )
JS_UPLOAD_STATE = (
"(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';}"
"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 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 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=/(失敗|失败|錯誤|错误|不支援|不支持|格式|大小|尺寸|超過|超过|error|fail|invalid|unsupported)/i;"
"var uploadRe=/(圖片|图片|封面|照片|相片|圖像|图像|image|photo|cover|upload|上傳|上传|檔案|文件|file|格式|大小|尺寸|像素|解析度|分辨率|超過|超过)/i;"
"var texts=manager?[].slice.call(manager.querySelectorAll('*')).filter(visible).map(text).filter(Boolean):[];"
"var errors=texts.filter(function(t){return errRe.test(t);}).slice(0,8);"
"var roots=[].slice.call(document.querySelectorAll('.eds-modal__content,.eds-modal__box,[role=dialog]')).filter(visible);"
"var modalTexts=roots.map(text).filter(Boolean);"
"var crop=modalTexts.find(function(t){return /(裁剪|裁切|剪裁|crop)/i.test(t);})||'';"
"var toastTexts=[].slice.call(document.querySelectorAll('[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')).filter(visible).map(text).filter(Boolean).slice(0,8);"
"var pageErrorToasts=toastTexts.filter(function(t){return errRe.test(t);}).slice(0,8);"
"var errorToasts=pageErrorToasts.filter(function(t){return uploadRe.test(t);}).slice(0,8);"
"return JSON.stringify({"
"manager_exists:!!manager,"
"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_disabled:!!(upload&&(upload.disabled||upload.getAttribute('aria-disabled')==='true')),"
"upload_tile_visible:visible(uploadBox),"
"busy_count:busy.length,"
"errors:errors,"
"crop_modal:!!crop,"
"crop_text:crop,"
"toasts:toastTexts,"
"error_toasts:errorToasts,"
"page_error_toasts:pageErrorToasts"
"});})()"
)
JS_TITLE_STATE = ( JS_TITLE_STATE = (
"(function(){" "(function(){"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null," f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
@@ -76,6 +117,59 @@ JS_CLICK_UPDATE = (
"b.click();return 'CLICKED';})()" "b.click();return 'CLICKED';})()"
) )
JS_FIND_UPDATE_CONFIRM = (
"(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';}"
"function text(e){return ((e&&((e.innerText||e.textContent)||''))||'').trim();}"
"function updateTitle(t){return /(確定|确定)/.test(t)&&/更新商品/.test(t);}"
"function title(root){return text(root.querySelector('.eds-modal__title,[class*=modal__title],[class*=Modal__title]'))||text(root);}"
"function buttons(root){var scope=root.querySelector('.eds-modal__footer,[class*=modal__footer],[class*=Modal__footer]')||root;"
"return [].slice.call(scope.querySelectorAll('button,[role=button]')).filter(visible);}"
"function hasUpdateButton(root){return buttons(root).some(function(b){return text(b)==='更新'&&!b.disabled&&!/disabled/i.test(b.className);});}"
"function uniq(items){var out=[];items.forEach(function(e){if(e&&out.indexOf(e)<0)out.push(e);});return out;}"
"var selector='.eds-modal__content,.eds-modal__box,[role=dialog]';"
"var roots=[].slice.call(document.querySelectorAll(selector));"
"var titles=[].slice.call(document.querySelectorAll('.eds-modal__title,[class*=modal__title],[class*=Modal__title]'))"
".filter(visible).filter(function(t){return updateTitle(text(t));})"
".map(function(t){return t.closest(selector);});"
"roots=uniq(roots.concat(titles)).filter(visible).filter(function(r){return updateTitle(title(r));}).filter(hasUpdateButton);"
"if(!roots.length)return JSON.stringify({present:false});"
"var root=roots[roots.length-1];"
"var seen=buttons(root).map(function(b){return text(b);}).filter(Boolean);"
"return JSON.stringify({present:true,title:title(root),buttons:seen.slice(0,8)});})()"
)
JS_CLICK_UPDATE_CONFIRM = (
"(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';}"
"function text(e){return ((e&&((e.innerText||e.textContent)||''))||'').trim();}"
"function updateTitle(t){return /(確定|确定)/.test(t)&&/更新商品/.test(t);}"
"function title(root){return text(root.querySelector('.eds-modal__title,[class*=modal__title],[class*=Modal__title]'))||text(root);}"
"function buttons(root){var scope=root.querySelector('.eds-modal__footer,[class*=modal__footer],[class*=Modal__footer]')||root;"
"return [].slice.call(scope.querySelectorAll('button,[role=button]')).filter(visible);}"
"function hasUpdateButton(root){return buttons(root).some(function(b){return text(b)==='更新'&&!b.disabled&&!/disabled/i.test(b.className);});}"
"function uniq(items){var out=[];items.forEach(function(e){if(e&&out.indexOf(e)<0)out.push(e);});return out;}"
"var selector='.eds-modal__content,.eds-modal__box,[role=dialog]';"
"var roots=[].slice.call(document.querySelectorAll(selector));"
"var titles=[].slice.call(document.querySelectorAll('.eds-modal__title,[class*=modal__title],[class*=Modal__title]'))"
".filter(visible).filter(function(t){return updateTitle(text(t));})"
".map(function(t){return t.closest(selector);});"
"roots=uniq(roots.concat(titles)).filter(visible).filter(function(r){return updateTitle(title(r));}).filter(hasUpdateButton);"
"if(!roots.length)return JSON.stringify({present:false,clicked:false,reason:'NO_UPDATE_CONFIRM_MODAL'});"
"var root=roots[roots.length-1];"
"var allButtons=buttons(root);"
"var seen=allButtons.map(function(b){return text(b);}).filter(Boolean);"
"var exact=allButtons.filter(function(b){var t=text(b);"
"return t==='更新'&&!b.disabled&&!/disabled/i.test(b.className);});"
"var primary=exact.filter(function(b){return /eds-button--primary/.test(b.className);});"
"var candidates=primary.length?primary:exact;"
"if(!candidates.length)return JSON.stringify({present:true,clicked:false,reason:'NO_UPDATE_CONFIRM_BUTTON',buttons:seen.slice(0,8)});"
"candidates[0].click();"
"return JSON.stringify({present:true,clicked:true,reason:null,text:text(candidates[0]),buttons:seen.slice(0,8)});})()"
)
JS_TOASTS = ( JS_TOASTS = (
"(function(){var es=[].slice.call(document.querySelectorAll(" "(function(){var es=[].slice.call(document.querySelectorAll("
"'[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]'));" "'[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]'));"
@@ -84,6 +178,25 @@ JS_TOASTS = (
"return JSON.stringify(t.slice(0,5));})()" "return JSON.stringify(t.slice(0,5));})()"
) )
JS_POST_UPDATE_STATE = (
"(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';}"
"function text(e){return ((e&&((e.innerText||e.textContent)||''))||'').trim();}"
"var toastTexts=[].slice.call(document.querySelectorAll('[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]'))"
".filter(visible).map(text).filter(Boolean).slice(0,8);"
"var errRe=/(失敗|失败|錯誤|错误|不支援|不支持|無法|无法|不允許|不允许|error|fail|invalid|請|请)/i;"
"var okRe=/(成功|已更新|更新完成|儲存成功|保存成功|success|saved)/i;"
"var url=location.href;"
"return JSON.stringify({"
"url:url,"
"redirected_to_list:url.indexOf('/portal/product/list/')>=0,"
"toasts:toastTexts,"
"error_toasts:toastTexts.filter(function(t){return errRe.test(t);}),"
"success_toasts:toastTexts.filter(function(t){return okRe.test(t);})"
"});})()"
)
JS_CLICK_FIRST_DELETE = ( JS_CLICK_FIRST_DELETE = (
"(function(){" "(function(){"
f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null," f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null,"
@@ -200,6 +313,147 @@ def _image_rects(cdp):
return _json_value(cdp, JS_RECTS, default=[]) or [] return _json_value(cdp, JS_RECTS, default=[]) or []
def _upload_state(cdp):
return _json_value(cdp, JS_UPLOAD_STATE, default={}) or {}
def _post_update_state(cdp):
return _json_value(cdp, JS_POST_UPDATE_STATE, default=None) or {}
def _wait_post_update(cdp, timeout=8):
end = time.time() + timeout
last_state = {}
last_error_state = None
while time.time() < end:
last_state = _post_update_state(cdp)
if not last_state:
return {"ok": True, "observed_success": False, "observed_failure": False}
error_toasts = last_state.get("error_toasts") or []
success_toasts = last_state.get("success_toasts") or []
redirected = bool(last_state.get("redirected_to_list"))
if redirected or success_toasts:
last_state.update({"ok": True, "reason": None, "observed_success": True, "observed_failure": False})
return last_state
if error_toasts:
last_error_state = dict(last_state)
time.sleep(0.5)
last_state = last_state or _post_update_state(cdp)
if not last_state:
return {"ok": True, "observed_success": False, "observed_failure": False}
success_toasts = last_state.get("success_toasts") or []
redirected = bool(last_state.get("redirected_to_list"))
if redirected or success_toasts:
last_state.update({"ok": True, "reason": None, "observed_success": True, "observed_failure": False})
return last_state
error_state = last_error_state or (last_state if last_state.get("error_toasts") else None)
if error_state:
error_state.update({"ok": False, "reason": "POST_UPDATE_ERROR", "observed_success": False, "observed_failure": True})
return error_state
last_state.update({"ok": True, "reason": None, "observed_success": False, "observed_failure": False})
return last_state
def _upload_input_ready(state):
return bool((state or {}).get("upload_input_exists")) and not bool((state or {}).get("upload_input_disabled"))
def _unstable_image_manager_reason(state, rects, expected_count, require_upload_input=True):
state = state or {}
if state.get("crop_modal"):
return "UPLOAD_CROP_REQUIRED"
if state.get("errors") or state.get("error_toasts"):
return "UPLOAD_PAGE_ERROR"
if expected_count is not None and len(rects) != expected_count:
return "IMAGE_COUNT_NOT_READY"
if state.get("busy_count") or any(str(r.get("src") or "").startswith("blob:") for r in rects):
return "IMAGE_MANAGER_BUSY"
if require_upload_input and not _upload_input_ready(state):
return "UPLOAD_INPUT_NOT_READY"
return "IMAGE_MANAGER_NOT_STABLE"
def _wait_image_manager_stable(
cdp,
expected_count=None,
timeout=20,
settle_seconds=1.0,
poll=0.5,
require_upload_input=True,
):
end = time.time() + timeout
last_rects = []
last_state = {}
last_signature = None
stable_hits = 0
while time.time() < end:
last_rects = _image_rects(cdp)
last_state = _upload_state(cdp)
signature = tuple(r.get("src") for r in last_rects)
count_ok = expected_count is None or len(last_rects) == expected_count
no_blob = not any(str(r.get("src") or "").startswith("blob:") for r in last_rects)
ready = (
bool(last_rects)
and count_ok
and no_blob
and not last_state.get("busy_count")
and not last_state.get("crop_modal")
and not last_state.get("errors")
and not last_state.get("error_toasts")
and (not require_upload_input or _upload_input_ready(last_state))
)
if ready and signature == last_signature:
stable_hits += 1
elif ready:
last_signature = signature
stable_hits = 1
else:
last_signature = signature
stable_hits = 0
if stable_hits >= 2:
if settle_seconds:
time.sleep(settle_seconds)
return {"ok": True, "rects": last_rects, "upload_state": last_state}
time.sleep(poll)
return {
"ok": False,
"reason": _unstable_image_manager_reason(
last_state,
last_rects,
expected_count,
require_upload_input=require_upload_input,
),
"rects": last_rects,
"upload_state": last_state,
"expected_count": expected_count,
"require_upload_input": require_upload_input,
"count_after": len(last_rects),
}
def _cover_upload_error_message(result):
reason = result.get("reason") or "COVER_UPDATE_FAILED"
state = result.get("upload_state") or {}
if reason == "UPLOAD_PAGE_ERROR":
details = state.get("errors") or state.get("error_toasts") or state.get("toasts") or []
return "新封面上传失败:" + ";".join(map(str, details[:3])) if details else "新封面上传失败"
if reason == "UPLOAD_CROP_REQUIRED":
return "新封面上传后出现裁剪确认框,当前版本未自动处理裁剪弹窗"
if reason == "UPLOAD_STILL_PROCESSING":
return "新封面上传仍在处理中,未取得 Shopee CDN 地址"
if reason == "UPLOAD_TIMEOUT":
return "新封面上传超时,未取得 Shopee CDN 地址"
if reason == "IMAGE_MANAGER_BUSY":
return "商品图片区域仍在加载,未开始上传新封面"
if reason == "IMAGE_COUNT_NOT_READY":
return "删除旧封面后图片数量未稳定,未开始上传新封面"
if reason == "UPLOAD_INPUT_NOT_READY":
return "商品图片上传入口未恢复可用,未开始上传新封面"
if reason == "IMAGE_MANAGER_NOT_STABLE":
return "商品图片区域未稳定,未开始上传新封面"
return str(reason)
def _wait_ready(cdp, timeout=60): def _wait_ready(cdp, timeout=60):
end = time.time() + timeout end = time.time() + timeout
while time.time() < end: while time.time() < end:
@@ -437,7 +691,7 @@ def change_title(cdp, new_title) -> dict:
return {"ok": ok, "written": written, "value": value, "modelvalue": modelvalue} return {"ok": ok, "written": written, "value": value, "modelvalue": modelvalue}
def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=90) -> dict: def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict:
"""Upload an image and drag it to the first position. """Upload an image and drag it to the first position.
When the image manager is full, only delete the current first image if the When the image manager is full, only delete the current first image if the
@@ -453,7 +707,24 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=90) -> dict:
"if(m)m.scrollIntoView({block:'center'});return !!m;})()" "if(m)m.scrollIntoView({block:'center'});return !!m;})()"
) )
time.sleep(0.5) time.sleep(0.5)
before = _image_rects(cdp) stable = _wait_image_manager_stable(
cdp,
timeout=20,
settle_seconds=1.0,
require_upload_input=False,
)
if not stable.get("ok"):
rects = stable.get("rects") or []
return {
"ok": False,
"reason": stable.get("reason"),
"count_before": len(rects),
"count_after": len(rects),
"upload_state": stable.get("upload_state"),
"stable": stable,
}
before = stable.get("rects") or _image_rects(cdp)
count_before = len(before) count_before = len(before)
delete_result = None delete_result = None
if len(before) >= 9: if len(before) >= 9:
@@ -473,24 +744,78 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=90) -> dict:
"count_before": count_before, "count_before": count_before,
"delete": delete_result, "delete": delete_result,
} }
before = _image_rects(cdp) stable = _wait_image_manager_stable(
cdp,
expected_count=delete_result.get("count_after"),
timeout=30,
settle_seconds=2.0,
require_upload_input=True,
)
if not stable.get("ok"):
return {
"ok": False,
"reason": stable.get("reason"),
"count_before": count_before,
"count_after": stable.get("count_after"),
"delete": delete_result,
"upload_state": stable.get("upload_state"),
"stable": stable,
}
before = stable.get("rects") or _image_rects(cdp)
else:
stable = _wait_image_manager_stable(
cdp,
expected_count=len(before),
timeout=20,
settle_seconds=1.0,
require_upload_input=True,
)
if not stable.get("ok"):
return {
"ok": False,
"reason": stable.get("reason"),
"count_before": count_before,
"count_after": stable.get("count_after"),
"upload_state": stable.get("upload_state"),
"stable": stable,
}
before = stable.get("rects") or _image_rects(cdp)
before_srcs = {r.get("src") for r in before} before_srcs = {r.get("src") for r in before}
oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')") oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')")
if not oid: if not oid:
return {"ok": False, "reason": "NO_UPLOAD_INPUT", "count_before": count_before, "delete": delete_result} return {
"ok": False,
"reason": "NO_UPLOAD_INPUT",
"count_before": count_before,
"delete": delete_result,
"upload_state": _upload_state(cdp),
}
cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [image_win_path]}) cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [image_win_path]})
cdp.val( changed = cdp.val(
"(function(){var up=document.querySelector('.shopee-image-manager__upload input[type=file]');" "(function(){var up=document.querySelector('.shopee-image-manager__upload input[type=file]');"
"if(!up)return false;" "if(!up)return false;"
"up.dispatchEvent(new Event('input',{bubbles:true}));" "up.dispatchEvent(new Event('input',{bubbles:true}));"
"up.dispatchEvent(new Event('change',{bubbles:true}));return true;})()" "up.dispatchEvent(new Event('change',{bubbles:true}));return true;})()"
) )
time.sleep(2)
new_src = None new_src = None
last_state = _upload_state(cdp)
if not changed and not last_state.get("busy_count") and not last_state.get("blob_count"):
return {
"ok": False,
"reason": "UPLOAD_INPUT_NOT_READY",
"count_before": count_before,
"delete": delete_result,
"upload_state": last_state,
}
blob_seen = bool(last_state.get("blob_count"))
file_size = os.path.getsize(image_win_path) if os.path.exists(image_win_path) else None
end = time.time() + timeout end = time.time() + timeout
while time.time() < end: while time.time() < end:
time.sleep(1.5)
cur = _image_rects(cdp) cur = _image_rects(cdp)
last_state = _upload_state(cdp)
blob_seen = blob_seen or any(str(r.get("src") or "").startswith("blob:") for r in cur)
ready = [ ready = [
r for r in cur r for r in cur
if r.get("src") not in before_srcs if r.get("src") not in before_srcs
@@ -498,11 +823,43 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=90) -> dict:
and "susercontent" in r.get("src") and "susercontent" in r.get("src")
and "blob:" not in r.get("src") and "blob:" not in r.get("src")
] ]
if len(cur) > len(before) and ready: if ready and (len(cur) > len(before) or len(ready) == 1):
new_src = ready[-1]["src"] new_src = ready[-1]["src"]
break break
if (last_state.get("errors") or last_state.get("error_toasts")) and not last_state.get("busy_count"):
return {
"ok": False,
"reason": "UPLOAD_PAGE_ERROR",
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_state": last_state,
"file_size": file_size,
}
if last_state.get("crop_modal"):
return {
"ok": False,
"reason": "UPLOAD_CROP_REQUIRED",
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_state": last_state,
"file_size": file_size,
}
time.sleep(1.5)
if not new_src: if not new_src:
return {"ok": False, "reason": "UPLOAD_TIMEOUT", "count_before": count_before, "delete": delete_result} cur = _image_rects(cdp)
reason = "UPLOAD_STILL_PROCESSING" if blob_seen or (last_state or {}).get("busy_count") else "UPLOAD_TIMEOUT"
return {
"ok": False,
"reason": reason,
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_state": last_state,
"blob_seen": blob_seen,
"file_size": file_size,
}
time.sleep(1) time.sleep(1)
cur = _image_rects(cdp) cur = _image_rects(cdp)
@@ -595,8 +952,8 @@ def _delete_first_cover(cdp, before, timeout=15):
} }
def click_update(cdp) -> dict: def click_update(cdp, confirm_timeout=3, post_timeout=8) -> dict:
"""Click the Shopee update button if it is present and enabled.""" """Click the Shopee update button, confirm Shopee's final modal, and observe submit result."""
info = _json_value(cdp, JS_FIND_UPDATE, default={"found": False}) info = _json_value(cdp, JS_FIND_UPDATE, default={"found": False})
if not info.get("found"): if not info.get("found"):
@@ -604,15 +961,64 @@ def click_update(cdp) -> dict:
if info.get("disabled"): if info.get("disabled"):
return {"clicked": False, "reason": "UPDATE_DISABLED", "toasts": []} return {"clicked": False, "reason": "UPDATE_DISABLED", "toasts": []}
result = cdp.val(JS_CLICK_UPDATE) result = cdp.val(JS_CLICK_UPDATE)
time.sleep(3 if result == "CLICKED" else 0) if result != "CLICKED":
toasts = _json_value(cdp, JS_TOASTS, default=[]) or [] toasts = _json_value(cdp, JS_TOASTS, default=[]) or []
return {"clicked": False, "reason": result, "toasts": toasts}
confirm_result = _confirm_update_modal(cdp, timeout=confirm_timeout)
if confirm_result.get("present") and not confirm_result.get("clicked"):
toasts = _json_value(cdp, JS_TOASTS, default=[]) or []
return {
"clicked": False,
"reason": confirm_result.get("reason") or "UPDATE_CONFIRM_NOT_CLICKED",
"toasts": toasts,
"confirm": confirm_result,
}
post_update = _wait_post_update(cdp, timeout=post_timeout)
toasts = post_update.get("toasts") if post_update else None
if toasts is None:
toasts = _json_value(cdp, JS_TOASTS, default=[]) or []
if post_update.get("observed_failure"):
return {
"clicked": False,
"reason": post_update.get("reason") or "POST_UPDATE_ERROR",
"toasts": toasts,
"confirm": confirm_result,
"post_update": post_update,
}
return { return {
"clicked": result == "CLICKED", "clicked": True,
"reason": None if result == "CLICKED" else result, "reason": None,
"toasts": toasts, "toasts": toasts,
"confirm": confirm_result,
"post_update": post_update,
} }
def _confirm_update_modal(cdp, timeout=3):
end = time.time() + timeout
last_present = None
while time.time() < end:
state = _json_value(
cdp,
JS_FIND_UPDATE_CONFIRM,
default={"present": False},
) or {"present": False}
if state.get("present"):
confirm_attempt = _json_value(
cdp,
JS_CLICK_UPDATE_CONFIRM,
default={"present": True, "clicked": False, "reason": "NO_UPDATE_CONFIRM_BUTTON"},
) or {}
last_present = confirm_attempt
if confirm_attempt.get("clicked"):
time.sleep(1)
return confirm_attempt
time.sleep(0.5)
return last_present or {"present": False, "clicked": False, "reason": "NO_UPDATE_CONFIRM_MODAL"}
def apply_task(account, task, close_success_tab=False) -> dict: def apply_task(account, task, close_success_tab=False) -> dict:
"""Apply generated title/cover to Shopee. """Apply generated title/cover to Shopee.
@@ -638,7 +1044,7 @@ def apply_task(account, task, close_success_tab=False) -> dict:
old_cover_path=_get(task, "old_cover_path"), old_cover_path=_get(task, "old_cover_path"),
) )
if not cover_result.get("ok"): if not cover_result.get("ok"):
return {"committed": False, "error": cover_result.get("reason"), "cover": cover_result} return {"committed": False, "error": _cover_upload_error_message(cover_result), "cover": cover_result}
update_result = click_update(cdp) update_result = click_update(cdp)
committed = bool(update_result.get("clicked", False)) committed = bool(update_result.get("clicked", False))
return { return {
+6 -4
View File
@@ -374,7 +374,7 @@ images/<slug>/<item_id>_new.<ext> # AI 生成的新封面
- 每条/每张完成**立即 `set_generated` 写库**(实时落库,停止或崩溃不丢已生成的)。 - 每条/每张完成**立即 `set_generated` 写库**(实时落库,停止或崩溃不丢已生成的)。
- **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。 - **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。
- 进度:`标题 x/n · 封面 x/n · 失败 n`。 - 进度:`标题 x/n · 封面 x/n · 失败 n`。
- 生成后 stage=generated;**不设逐条人工审核阶段**。双击任务弹窗查看新旧封面(纯查看),可选对某行 `重生成`;新标题直接用 AI 输出(不可编辑)。 - 生成后 stage=generated;**不设逐条人工审核阶段**。双击任务弹窗查看新旧封面(纯查看);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。
- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`config.json` 的 `ai` 段)。 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`config.json` 的 `ai` 段)。
提示词管理: 提示词管理:
@@ -389,10 +389,12 @@ images/<slug>/<item_id>_new.<ext> # AI 生成的新封面
- 弹确认前先读取 `config.json` 的 `shopee_update`:`dry_run=false` 且未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。`dry_run=true` 时只预览,不受真实提交开关限制。 - 弹确认前先读取 `config.json` 的 `shopee_update`:`dry_run=false` 且未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。`dry_run=true` 时只预览,不受真实提交开关限制。
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。 - 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
- 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。 - 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。
- `click_update` 点击页面「更新」后必须处理 Shopee 站点侧二次确认框。2026-06-29 真实测试商品实测:页面会出现 `.eds-modal__content` / `.eds-modal__box`,标题为 `確定您要更新商品嗎?`,正文提示建议优化,底部两个按钮分别是 `立即優化` 与主按钮 `更新`。实现时只允许在标题匹配该确认框、且按钮位于可见 modal footer 内时点击 `button.eds-button--primary` / 文案 `更新`;不得点击 `立即優化`。如果弹窗出现但未成功点击主按钮,当前任务必须视为未提交失败,不得写 `committed=1`。
- 满 9 张封面时,T-502 的删除流程必须先确认该任务已有本地旧封面备份:`old_cover_path` 非空且文件存在。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。 - 满 9 张封面时,T-502 的删除流程必须先确认该任务已有本地旧封面备份:`old_cover_path` 非空且文件存在。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。
- 默认串行、单条失败继续;⑤ 开启 `parallel_accounts` 后按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。 - 默认串行、单条失败继续;⑤ 开启 `parallel_accounts` 后按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。
- `dry_run=true` 时只写 `run_logs/run_log_events` 和弹窗/状态栏预览,不调用 `editor.apply_task()`,不做账号登录预检,不写任务状态,不回写 Excel。 - `dry_run=true` 时只写 `run_logs/run_log_events` 和弹窗/状态栏预览,不调用 `editor.apply_task()`,不做账号登录预检,不写任务状态,不回写 Excel。
- 真实更新每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与 dry-run 都写运行日志,日志 payload 走脱敏工具。 - 真实更新每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与 dry-run 都写运行日志,日志 payload 走脱敏工具。
- T-404a 已在③提供「重置更新状态」:仅当前选中单条,保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 以便重复测试上传/提交;若 `committed=1`,必须提示线上已提交过、本地重置不回滚 Shopee、重复更新会再次提交,并保留 committed 历史事实/运行日志。
- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;失败任务和复用的用户已有 tab 不关闭。 - 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;失败任务和复用的用户已有 tab 不关闭。
### 6.4 登录检测 ### 6.4 登录检测
@@ -412,10 +414,10 @@ images/<slug>/<item_id>_new.<ext> # AI 生成的新封面
| 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 | | 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 |
| 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 | | 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 |
| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;`DOM.setFileInputFiles` 传 Windows 路径 | | 上传输入框 | `.shopee-image-manager__upload input[type=file]`;`DOM.setFileInputFiles` 传 Windows 路径 |
| 上传成功 | 张数 +1 且新图 src 为 `susercontent` | | 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理 |
| 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` | | 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` |
| 满 9 张 | 上限 9;T-502 换封面时先确认本地旧封面备份存在,再点第一张删除(`.shopee-image-manager__icon--delete` 或同类 delete 标记)并在可见 dialog/modal/popover 内点删除/确认按钮,随后上传新图并拖到第一位;备份缺失则拒绝删除。已在 9 图测试商品 `29671243750` 上实测不提交流程 | | 满 9 张 | 上限 9;T-502 换封面时先确认本地旧封面备份存在,再点第一张删除(`.shopee-image-manager__icon--delete` 或同类 delete 标记)并在可见 dialog/modal/popover 内点删除/确认按钮。关键顺序:删除前只等当前图片列表稳定,不检查上传 input;删除后不能只看数量减少,必须等图片管理器达到删除后数量、无 busy/blob、上传 input 恢复并短暂稳定,再重新获取 input 上传新图、确认取得 Shopee CDN 地址后拖到第一位;备份缺失则拒绝删除。已在 9 图测试商品 `29671243750` 上实测不提交流程 |
| 更新按钮 | `button.eds-button` 中 `<span>更新</span>`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败 | | 更新按钮 | 页面主更新按钮为 `button.eds-button` 中 `<span>更新</span>`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败。2026-06-29 实测点击后会弹 Shopee 站点侧确认框:可见 `.eds-modal__content` / `.eds-modal__box`,标题 `確定您要更新商品嗎?`,footer 中 `立即優化` 为次按钮,`更新` 为 `eds-button--primary` 主按钮;代码必须点击确认框内主按钮 `更新` 才算提交,不点 `立即優化`。2026-06-30 实测确认成功后会跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time` 商品列表页,代码需记录 `post_update.url/redirected_to_list` 作为提交后观测结果。判断优先级:跳转到 `/portal/product/list/` 是强成功信号,应优先于残留/短暂 error toast;只有在未跳转列表页、无成功 toast,且错误 toast 持续存在时,才判 `POST_UPDATE_ERROR`。未处理确认框时不得认为已提交 |
| 登录检测 | 重定向到登录页或缺 `SPC_ST` → 未登录 | | 登录检测 | 重定向到登录页或缺 `SPC_ST` → 未登录 |
高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。删满 9 张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。 高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。删满 9 张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。
+2 -1
View File
@@ -75,7 +75,8 @@
| T-401 | Tab③ 列出已生成任务 + 按批次/店铺/商品ID/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/商品ID/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE | | T-401 | Tab③ 列出已生成任务 + 按批次/店铺/商品ID/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/商品ID/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE |
| T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未登录引导保护;未确认时不调用 apply | DONE | | T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未登录引导保护;未确认时不调用 apply | DONE |
| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE | | T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE |
| T-404 | 真实 Shopee 单条更新冒烟验收(设置安全开关完成后执行) | T-403, T-501c | 仅用测试商品;默认先只测标题更新,封面更新作为可选子项;必须经过 Tab③ 二次确认和设置安全开关;验证线上提交结果、Excel 更新状态、失败/取消语义;未通过前不得扩大批量真实更新 | TODO | | T-404a | ②/③ 选中记录重置(联调重试辅助) | T-303, T-403, T-504 | 现状:② AI生成和③ 更新shopee列表没有重置选中记录的功能,生成/上传/提交成功后难以用同一商品重复测试。方案:② 增加「重置生成结果」按钮,仅作用当前选中单条,确认后清空本地 `new_title/new_cover_path`、清空 `last_error`,把任务退回 `stage=collected/status=success`,默认不删除本地新封面文件,可在确认框提示路径;③ 增加「重置更新状态」按钮,仅作用当前选中单条,保留 `new_title/new_cover_path`,确认后把本地状态退回可更新(`stage=generated/status=pending`、清空 `last_error`),用于重复测试上传/提交。若记录 `committed=1`,确认框必须醒目提示“线上已提交过,本地重置不会回滚 Shopee,重复更新会再次提交线上”,并保留 committed 历史事实/运行日志;运行中禁用;两类重置都写 `run_type=reset` 运行日志,不触碰 Shopee、不回写 Excel,后续由手动回写同步本地状态 | DONE |
| T-404 | 真实 Shopee 单条更新冒烟验收(设置安全开关完成后执行) | T-403, T-501c | 仅用测试商品;默认先只测标题更新,封面更新作为可选子项;必须经过 Tab③ 程序侧二次确认和设置安全开关。2026-06-29 实测标题/封面已替换、页面主「更新」已点击,但 Shopee 又弹出站点侧确认框 `確定您要更新商品嗎?`,旧代码未点弹窗主按钮导致未真正提交;当前已补代码与 mock 单测,完成 T-404 前仍必须在测试商品实测验证:检测可见 `.eds-modal__content` / `.eds-modal__box` 标题匹配该确认框,只点击 footer 主按钮 `更新`(`eds-button--primary`),不点击 `立即優化`,确认框未处理成功则任务失败且不得写 `committed=1`。验证线上提交结果、Excel 更新状态、失败/取消语义;2026-06-30 又补上传前/删图后图片管理器稳定等待;随后发现并修复回归:满 9 张时删除前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;又发现并修复物流/备货页面级校验 toast 被误判为封面上传失败:上传阶段只识别图片/文件/上传相关错误,物流错误留到提交阶段处理。2026-06-30 用户复跑确认浏览器已成功更新标题/图片并跳转商品列表,但 GUI 仍报 `POST_UPDATE_ERROR`;原因是提交后观测把残留 error toast 优先于列表页跳转。已修正为列表页跳转优先判成功,错误 toast 仅在未跳转且持续存在时判失败;仍需真机复跑确认,未通过前不得扩大批量真实更新 | TODO |
## Phase 5 · 设置与收尾 ## Phase 5 · 设置与收尾
+12 -11
View File
@@ -5,11 +5,11 @@
## 当前快照 ## 当前快照
- 日期:2026-06-29 - 日期:2026-06-30
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 满 9 张封面删除再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁。 - 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 满 9 张封面删除再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置。
- 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`replace_cover()` 已实现满 9 张时先校验本地旧封面备份,再点第一张删除、可见确认框、上传新图并拖到第一位的代码路径;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览/AI生成运行日志与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` dry-run 预览或真实更新/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、两列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全和 dry-run/多账号并行设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 - 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现满 9 张时先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复、上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:满 9 张删除前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-404a 本地生成结果/更新状态重置函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览/AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/重置更新状态/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` dry-run 预览或真实更新/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、两列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全和 dry-run/多账号并行设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。
- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、满 9 张封面备份缺失阻断/删除确认/上传拖首位 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志与双击预览/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行/dry-run/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、dry-run/多账号并行设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 - 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、满 9 张封面备份缺失阻断/删除确认/满格上传入口初始不可用/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览与重置生成结果/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、重置更新状态、Shopee 更新安全拦截、`ApplyWorker` 串行/dry-run/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、dry-run/多账号并行设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。
- 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。
## 既定设计要点(文档已定) ## 既定设计要点(文档已定)
@@ -33,15 +33,15 @@
| `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 |
| `docs/troubleshooting.md` | 已有 | 常见问题排查;已记录 `config/ai_models.json` 缺失 `category` 导致 GUI 启动报 “AI 模型 category 必须是 text 或 image” 的原因、修复和验证步骤 | | `docs/troubleshooting.md` | 已有 | 常见问题排查;已记录 `config/ai_models.json` 缺失 `category` 导致 GUI 启动报 “AI 模型 category 必须是 text 或 image” 的原因、修复和验证步骤 |
| `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 | | `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 |
| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-503/T-504 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览、AI生成运行日志与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、Shopee 更新安全拦截、开始更新确认弹窗、`ApplyWorker` 串行/dry-run/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全与执行模式设置 | | `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-404a/T-501/T-501b/T-501c/T-503/T-504 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览、AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、重置更新状态、Shopee 更新安全拦截、开始更新确认弹窗、`ApplyWorker` 串行/dry-run/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全与执行模式设置 |
| `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 |
| `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 |
| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集步骤回调、采集后关闭自动新建商品页 tab、上传拖封面、满 9 张先校验旧封面备份再删第一张并上传、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | | `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502 + T-404 补丁产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、上传并检测 CDN 后拖封面、满 9 张先校验旧封面备份再删第一张并等上传入口恢复、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 |
| `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全与 dry-run/多账号并行默认值;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | | `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全与 dry-run/多账号并行默认值;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 |
| `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁产出:本地 `logs/cmshopee.log` 诊断日志、大小滚动、异常类型/traceback/step/耗时记录,结构化 payload 和自由文本脱敏后写入 | | `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁产出:本地 `logs/cmshopee.log` 诊断日志、大小滚动、异常类型/traceback/step/耗时记录,结构化 payload 和自由文本脱敏后写入 |
| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;标题/封面步骤级回调;封面按 resolution/jpg_quality 保存;批量生成先标题后封面、进度回调、逐条落库、失败标记、事件/错误回调、停止取消未开始项 | | `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;标题/封面步骤级回调;封面按 resolution/jpg_quality 保存;批量生成先标题后封面、进度回调、逐条落库、失败标记、事件/错误回调、停止取消未开始项 |
| `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 | | `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 |
| `app/db.py` | 已有 | T-003/T-504 产出:batches/accounts/tasks schema;run_logs/run_log_events;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库;运行日志写入与查询 | | `app/db.py` | 已有 | T-003/T-404a/T-504 产出:batches/accounts/tasks schema;run_logs/run_log_events;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库;生成结果/更新状态本地重置;运行日志写入与查询 |
| `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir |
| `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | | `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 |
| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-207/T-301/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | | `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-207/T-301/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers |
@@ -55,15 +55,15 @@
- 找到/新建商品详情页 tab,等编辑器就绪。 - 找到/新建商品详情页 tab,等编辑器就绪。
- 改标题:原生 setter + 派发事件,`value` 与 `modelvalue` 双等于新值。 - 改标题:原生 setter + 派发事件,`value` 与 `modelvalue` 双等于新值。
- 换封面:`setFileInputFiles` 上传(`.shopee-image-manager__upload input[type=file]`)→ 等 CDN 链接 → 等 1 秒 → `Input.dispatchMouseEvent` 拖到第一位(落点 `first.left - 0.30*w`);使用 `1_TY030.jpg` 在测试商品验证通过。 - 换封面:`setFileInputFiles` 上传(`.shopee-image-manager__upload input[type=file]`)→ 等 CDN 链接 → 等 1 秒 → `Input.dispatchMouseEvent` 拖到第一位(落点 `first.left - 0.30*w`);使用 `1_TY030.jpg` 在测试商品验证通过。
- 「更新」按钮:可点才点,禁用态识别;③ 未批量确认前不提交。 - 「更新」按钮:可点才点,禁用态识别;③ 未批量确认前不提交。2026-06-29 测试商品实测:页面主「更新」后会出现 Shopee 站点侧确认框 `.eds-modal__content` / `.eds-modal__box`,标题 `確定您要更新商品嗎?`,底部 `立即優化` / 主按钮 `更新`;2026-06-30 用户复跑确认点击弹窗主按钮后提交成功,并跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time`,代码已记录该跳转结果。
- Tab① 真机冒烟(2026-06-27):账号 `papa`,导入临时 Excel 5 行,采集 5 条旧标题/旧封面,下载 5 张旧封面并回写临时 Excel 成功。 - Tab① 真机冒烟(2026-06-27):账号 `papa`,导入临时 Excel 5 行,采集 5 条旧标题/旧封面,下载 5 张旧封面并回写临时 Excel 成功。
## 任务看板状态 ## 任务看板状态
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。
- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(满 9 张封面删除再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁。 - 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(满 9 张封面删除再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)。
- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟)**。T-206 删除批次依赖 T-404,T-505 全流程诊断日志扩展依赖 T-207/T-504,建议先完成真实更新单条验收。 - 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟)**。T-206 删除批次依赖 T-404,T-505 全流程诊断日志扩展依赖 T-207/T-504。
## 当前已知限制 ## 当前已知限制
@@ -73,7 +73,8 @@
- T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。 - T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。
- T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。
- 本地 `config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。 - 本地 `config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。
- T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;真实 Shopee 更新冒烟仍未执行,下一步 T-404 只允许在测试商品范围内做单条验收,默认先只测标题更新,封面更新作为可选子项。 - T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;2026-06-29 已用测试商品跑到真实编辑页并完成标题/封面替换、页面主「更新」点击,但被 Shopee 站点侧确认框拦住。当前代码已补确认框处理、确认后列表页跳转观测、上传状态诊断、删除后稳定等待和 mock 单测;2026-06-30 用户复跑发现稳定等待顺序回归:满 9 张时删除前要求上传 input 可用,导致不删除第一张图;当前已修正为删除前只等图片列表稳定、删除后再等上传入口恢复,并补 mock 回归测试;2026-06-30 又发现物流/备货页面级校验 toast 会被误判成封面上传失败,当前已修复为上传阶段只识别图片/文件/上传相关错误,物流错误留到提交阶段处理。2026-06-30 用户复跑确认浏览器已成功更新标题/图片并跳转商品列表,但 GUI 仍报 `POST_UPDATE_ERROR`;已修正提交后观测优先级:列表页跳转优先判成功,残留 error toast 不再覆盖成功跳转,错误 toast 仅在未跳转且持续存在时判失败。当前已完成 T-404a 选中记录重置,下一步复跑 T-404 单条真实提交验收。
- T-404a 已完成:②「重置生成结果」清空本地 AI 结果并退回 collected,默认不删除本地新封面文件;③「重置更新状态」保留新标题/新封面并退回 generated/pending;两者只作用当前选中单条、运行中禁用、写 `run_type=reset` 运行日志。③ 对 `committed=1` 会提示线上已提交过、本地重置不回滚 Shopee、重复更新会再次提交。
- T-502 满 9 张封面删除流程已完成代码路径、mock 单测和真实 9 图商品不提交流程实测;删除第一张前必须已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失备份时拒绝删除线上图片。本轮实测只操作编辑页并关闭测试 tab,未点击「更新」保存线上。 - T-502 满 9 张封面删除流程已完成代码路径、mock 单测和真实 9 图商品不提交流程实测;删除第一张前必须已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失备份时拒绝删除线上图片。本轮实测只操作编辑页并关闭测试 tab,未点击「更新」保存线上。
## 当前可运行内容 ## 当前可运行内容
+88
View File
@@ -724,3 +724,91 @@
- 代码:`app/ai.py` 为标题/封面生成增加 `on_step` 与 `on_event/on_error` 回调,覆盖模型加载、请求构建、请求发送、响应解析、图片保存、写库等步骤;失败错误进入任务 `last_error` 前先脱敏。 - 代码:`app/ai.py` 为标题/封面生成增加 `on_step` 与 `on_event/on_error` 回调,覆盖模型加载、请求构建、请求发送、响应解析、图片保存、写库等步骤;失败错误进入任务 `last_error` 前先脱敏。
- UI/日志:`GenerateWorker` 创建 `run_type=generate` 的 `run_logs/run_log_events`,② 页面新增「AI生成运行日志」;图片生成异常同时写本地 `logs/cmshopee.log` 脱敏 traceback。 - UI/日志:`GenerateWorker` 创建 `run_type=generate` 的 `run_logs/run_log_events`,② 页面新增「AI生成运行日志」;图片生成异常同时写本地 `logs/cmshopee.log` 脱敏 traceback。
- 测试:新增覆盖 GenerateWorker 图片生成失败时 DB 运行日志、本地诊断日志和 token 脱敏,以及 ② 页面加载最近一次 generate 日志。 - 测试:新增覆盖 GenerateWorker 图片生成失败时 DB 运行日志、本地诊断日志和 token 脱敏,以及 ② 页面加载最近一次 generate 日志。
## 【2026-06-29】T-404 冒烟发现 · Shopee 更新二次确认框
- 现象:用户用测试商品走完整流程,③ 更新shopee 已替换标题和封面,并自动点击页面主「更新」按钮;随后 Shopee 弹出站点侧确认框 `確定您要更新商品嗎?`,底部按钮为 `立即優化` 和主按钮 `更新`,当前代码没有点击最终确认,因此未真正提交。
- 结论:T-404 不能标 DONE;`click_update()` 的提交成功定义需要扩展为“页面主更新按钮已点击,且 Shopee 站点侧确认框未出现或已点击确认框内主按钮「更新」”。
- 方案:基于用户提供 HTML,检测可见 `.eds-modal__box`,标题匹配 `確定您要更新商品嗎?`/简繁变体;只在该 modal footer 内点击 `button.eds-button--primary` 且文案为 `更新` 的按钮;明确不点击 `立即優化`。弹窗出现但未确认成功时,该任务返回失败、保留现场、不得写 `committed=1`。
- 文档:同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/06-tasks.md`、`docs/current-state.md`。本轮只更新文档,未改代码。
## 【2026-06-29】T-404 补丁 · 点击 Shopee 更新确认框主按钮
- 代码:`app/editor.py` 新增 `JS_FIND_UPDATE_CONFIRM` / `JS_CLICK_UPDATE_CONFIRM`,`click_update(cdp, confirm_timeout=3)` 点击页面主「更新」后等待 Shopee 站点侧确认框;若可见 `.eds-modal__box` 标题匹配 `確定/确定 + 更新商品`,只点击同一 modal 内文案精确为 `更新` 的按钮,优先 `eds-button--primary`,不点击 `立即優化`。
- 失败语义:确认框出现但未点到主按钮时返回 `clicked=False` 和 `NO_UPDATE_CONFIRM_BUTTON` / `UPDATE_CONFIRM_NOT_CLICKED`,`apply_task()` 不写 committed 成功,失败商品保留现场。
- 测试:`tests/test_editor_login.py` 增加 Shopee 更新确认框成功点击、确认框未确认失败、无确认框保持原成功语义的 mock 覆盖。
- 文档:同步 `docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`。T-404 仍为 TODO,下一步需要用户用测试商品复跑真实提交验收。
## 【2026-06-29】设计登记 · ②/③ 选中记录重置
- 背景:用户真实联调时,② AI生成和③ 更新shopee列表没有重置选中记录功能;商品一旦生成/上传/提交成功,就不方便用同一商品重复测试图片上传和 Shopee 提交流程。
- 决策:新增 T-404a「②/③ 选中记录重置(联调重试辅助)」,排在 T-404 真实单条冒烟前。先做单选记录,不做批量重置,避免误操作放大线上风险。
- ② 方案:增加「重置生成结果」。确认后只改本地 DB,清空 `new_title/new_cover_path/last_error`,退回 `stage=collected/status=success`,默认不删除本地新封面文件;若记录已提交过线上,确认框必须提示本地重置不回滚 Shopee。
- ③ 方案:增加「重置更新状态」。确认后保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending`,用于重复测试上传/提交;若 `committed=1`,必须醒目提示线上已提交过、重复更新会再次提交,并保留 committed 历史事实/运行日志。
- 日志与边界:两类重置都写 `run_type=reset` 运行日志;运行中禁用;不触碰 Shopee,不自动回写 Excel,后续由用户按需手动回写结果。
- 文档:同步 `docs/06-tasks.md`、`docs/routes.md`、`docs/api.md`、`docs/04-architecture.md`、`docs/current-state.md`。本轮只更新文档,未改代码。
## 【2026-06-30】T-404a 完成 · ②/③ 选中记录重置
- 代码:`app/db.py` 新增 `get_task()`、`reset_generated()`、`reset_apply_status()`;`app/gui.py` 在② AI生成增加「重置生成结果」,在③ 更新shopee增加「重置更新状态」。两者仅作用当前选中单条,运行中禁用,确认后只改本地 SQLite,不触碰 Shopee,不自动回写 Excel。
- 行为:② 清空 `new_title/new_cover_path/last_error` 并退回 `stage=collected/status=success`,默认不删除本地新封面文件;③ 保留 `new_title/new_cover_path`,退回 `stage=generated/status=pending`。已 `committed=1` 的记录会在确认框提示线上已提交过、本地重置不回滚 Shopee、重复更新会再次提交。
- 日志:两类重置都创建 `run_type=reset` 的 `run_logs/run_log_events`,记录 action、task_id、alias、item_id。
- 测试:新增 `tests/test_db.py` reset 生命周期覆盖;新增 `tests/test_gui.py` 覆盖②重置生成结果、③重置更新状态、确认框提示和 reset 运行日志。已运行 `python -m py_compile app/db.py app/gui.py tests/test_db.py tests/test_gui.py`、`python -m unittest discover -s tests -p "test_db.py"`、`python -m unittest discover -s tests -p "test_gui.py"`。
- 文档:`docs/06-tasks.md` 将 T-404a 标为 DONE;同步 `docs/current-state.md`、`docs/api.md`、`docs/04-architecture.md`。下一个任务仍是 T-404 真实 Shopee 单条更新冒烟,必须用测试商品复跑站点侧确认框主按钮「更新」路径后才能标 DONE。
## 【2026-06-30】T-404 补丁 · 兼容 Shopee `.eds-modal__content` 更新确认框
- 现象:用户实跑最新代码后仍停在 Shopee 站点侧确认框,真实 DOM 根节点为 `.eds-modal__content`,标题 `確定您要更新商品嗎?`,footer 按钮为 `立即優化` 和主按钮 `更新`。
- 原因:确认框检测候选 root 过宽,可能把 `.eds-modal__title` 等子节点当成最后一个候选,导致点击逻辑在没有 footer 按钮的 root 中查找「更新」失败。
- 代码:`app/editor.py` 的 `JS_FIND_UPDATE_CONFIRM` / `JS_CLICK_UPDATE_CONFIRM` 改为优先查找 `.eds-modal__content,.eds-modal__box,[role=dialog]`,并要求候选 root 标题匹配更新确认框且包含可点击「更新」按钮;按钮查找优先限定在 `.eds-modal__footer` 内,仍只点击文案精确为「更新」的主按钮,不点击「立即優化」。
- 测试:`tests/test_editor_login.py` 增加 `.eds-modal__content` 结构覆盖,防止回退到只支持 `.eds-modal__box`。T-404 仍需用户用测试商品复跑真实提交确认后才能标 DONE。
## 【2026-06-30】T-404 补丁 · 新封面上传状态诊断
- 现象:用户实跑最新代码时,新封面上传后页面一直转圈,程序等待一段时间后提示更新失败。
- 现状确认:旧 `replace_cover()` 已有上传成功检测,但只判断“图片列表出现新 `susercontent` CDN 地址”;若页面一直 loading/blob 预览或 Shopee 返回页面错误,最终只给 `UPLOAD_TIMEOUT`,无法定位是仍在上传、格式/大小错误还是裁剪弹窗。
- 代码:`app/editor.py` 新增 `JS_UPLOAD_STATE` / `_upload_state()`,上传等待期间采集图片管理器状态:当前 src、blob 数、CDN 数、loading/progress 数、错误文案、裁剪弹窗和 toast;默认上传等待从 90 秒调到 180 秒。
- 失败语义:`replace_cover()` 现在区分 `UPLOAD_STILL_PROCESSING`(仍在转圈或 blob 预览)、`UPLOAD_PAGE_ERROR`(页面/Toast 出现失败错误)、`UPLOAD_CROP_REQUIRED`(出现裁剪弹窗)、`UPLOAD_TIMEOUT`(没有任何新图/CDN/忙碌状态);失败结果包含 `upload_state`、`count_after`、`blob_seen`、`file_size`。`apply_task()` 会把封面失败转换成更可读的中文错误。
- 测试:`tests/test_editor_login.py` 新增上传仍在处理中、页面上传错误、apply_task 封面失败中文错误的覆盖。T-404 仍需用户用测试商品复跑真实上传和提交路径。
## 【2026-06-30】T-404 补丁 · 删除旧封面后等待图片管理器稳定
- 现象:用户实跑最新代码后,新封面上传后仍停在转圈并最终提示更新失败;怀疑满 9 张时删除旧图后立刻上传,Shopee 图片管理组件尚未完成重排。
- 分析:判断方向成立。旧流程在 `_delete_first_cover()` 里只等待图片数量减少,随后立刻重新取上传 input 并 `DOM.setFileInputFiles`;但数量减少不等于 Vue 组件已重渲染完成、上传入口已恢复、loading/blob 已消失。
- 代码:`JS_UPLOAD_STATE` 增加上传 input 存在/禁用状态;`replace_cover()` 上传前先等图片管理器稳定,初始图片稳定后再操作,满 9 张删除确认后等待删除后的数量、无 busy/blob、上传 input 恢复并短暂稳定,再重新获取 input 上传;触发文件选择后等待 2 秒再轮询新图 CDN。新增 `IMAGE_MANAGER_BUSY`、`IMAGE_COUNT_NOT_READY`、`UPLOAD_INPUT_NOT_READY` 等失败语义。
- 测试:`tests/test_editor_login.py` 增加“删除后 busy 两轮,必须等稳定后才 setFileInputFiles”的覆盖;`python -m unittest discover -s tests -p "test_editor_login.py"` 通过(23 tests),`python -m compileall app main.py` 通过,`python -m unittest discover -s tests` 通过(130 tests),`git diff --check` 无空白错误(仅 LF/CRLF 提示)。
- 边界:本轮仍未直接操作真实 Shopee 测试商品,T-404 仍为 TODO,需要用户复跑单条真实上传与提交路径后才能标 DONE。
## 【2026-06-30】T-404 补丁 · 记录更新成功后跳回商品列表页
- 现象:用户复跑最新代码后,Shopee 站点侧确认框里的主按钮「更新」已成功点击,提交成功后当前 tab 跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time`,但浏览器 tab 没有关闭。
- 分析:代码已有可选关闭逻辑:只有 ⑤ 开启 `close_success_tab` 且商品页是 `open_product()` 本轮自动新建 tab,成功提交后才调用 `close_tab()`;默认不关闭,复用用户已有 tab 也不关闭。旧代码没有把“跳回商品列表页”作为提交后观测结果记录。
- 代码:`click_update()` 新增提交后状态探测,点击确认框主按钮后等待并记录 `post_update.url`、`redirected_to_list`、toast 结果;若出现失败 toast,则本条视为未提交失败;如果没有可观测跳转/成功 toast,仍沿用点击确认成功的兼容语义。
- 测试:`tests/test_editor_login.py` 增加确认后跳回商品列表页的 mock 覆盖;`python -m unittest discover -s tests -p "test_editor_login.py"` 通过(24 tests),`python -m compileall app main.py` 通过,`python -m unittest discover -s tests` 通过(131 tests),`git diff --check` 无空白错误(仅 LF/CRLF 提示)。
- 边界:是否关闭 tab 仍由 ⑤“成功后关闭本次新开编辑页”和 `created_by_app` 决定;跳到商品列表页本身不会强制关闭用户原本打开的 tab。
## 【2026-06-30】T-404 回归分析 · 满 9 张删除前误等上传入口
- 现象:用户复跑最新代码后,商品详情页加载完成,但没有删除原商品图片第一张图,也没有后续上传和更新确认。
- 原因:上次为解决“删除后立刻上传导致转圈”新增了 `_wait_image_manager_stable()`,但在 `replace_cover()` 开头、删除旧图之前就要求上传 input 存在且未禁用。满 9 张时 Shopee 可能因已满格隐藏或禁用上传入口,所以前置等待返回 `UPLOAD_INPUT_NOT_READY`,流程在删除前中断。
- 正确顺序:满 9 张时,删除前只等待现有图片列表稳定(无 loading/blob、src 稳定),不检查上传 input;完成第一张删除并确认图片数量减少后,再等待上传 input 恢复可用并短暂稳定,然后上传新图。
- 测试缺口:mock 中满 9 张场景默认 `upload_input_ready=True`,没有覆盖真实页面“满格时上传入口不可用”的状态,导致回归未被单测发现。
- 修复:`_wait_image_manager_stable()` 增加 `require_upload_input` 参数;`replace_cover()` 初始稳定等待传 `False`,满 9 张先删除第一张;删除后和未满 9 张上传前再传 `True` 等上传入口恢复。
- 测试:`tests/test_editor_login.py` 新增 `test_replace_cover_full_slots_deletes_before_upload_input_is_ready`,模拟满 9 张时上传入口初始不可用、删除后恢复;`python -m unittest discover -s tests -p "test_editor_login.py"` 通过(25 tests),`python -m compileall app main.py` 通过,`python -m unittest discover -s tests` 通过(132 tests),`git diff --check` 无空白错误(仅 LF/CRLF 提示)。
- 下一步:用户用测试商品复跑真实 T-404 封面上传和更新确认链路。
## 【2026-06-30】T-404 回归分析 · 物流校验被误判为封面上传失败
- 现象:用户复跑最新代码后,程序能删除第一张图片,但一直没有上传新图片,最后报 `新封面上传失败:此物流選項不支援較長備貨商品`。
- 原因:`JS_UPLOAD_STATE` 在封面上传阶段扫描了全局 toast/message/notice,并把包含 `不支援` 的文本都归入 `error_toasts`。`此物流選項不支援較長備貨商品` 是物流/备货页面级校验,不是图片上传错误;当前代码在删除后等待上传入口恢复时看到该全局错误,提前返回 `UPLOAD_PAGE_ERROR`,因此没有继续执行 `DOM.setFileInputFiles`。
- 正确边界:封面上传阶段只应把图片管理器内错误,或图片/文件/上传相关的全局 toast,当作封面上传失败。物流、备货、价格、库存等页面级校验应保留给点击「更新」提交阶段处理,不能阻断上传新图。
- 修复:`JS_UPLOAD_STATE` 新增图片/文件/上传相关 `uploadRe` 过滤;全局页面错误仍记录在 `page_error_toasts`,但只有匹配图片上传语义的 toast 才进入 `error_toasts` 并阻断封面上传。`JS_POST_UPDATE_STATE` 同步把 `不支援/不支持/無法/无法` 识别为提交阶段错误,避免真正点击「更新」后误判成功。
- 测试:`tests/test_editor_login.py` 新增 `test_replace_cover_ignores_unrelated_logistics_page_error_toast` 和 `test_click_update_reports_logistics_validation_after_confirm`;`python -m unittest discover -s tests -p "test_editor_login.py"` 通过(27 tests),`python -m compileall app main.py` 通过,`python -m unittest discover -s tests` 通过(134 tests),`git diff --check` 无空白错误(仅 LF/CRLF 提示)。
- 下一步:用户复跑真实 T-404 链路。
## 【2026-06-30】T-404 回归分析 · 成功跳转列表页后 GUI 误报 POST_UPDATE_ERROR
- 现象:用户复跑最新代码后,浏览器上标题和封面都已成功更新,点击 Shopee 确认框主按钮「更新」后跳转到 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time`,但 GUI 仍提示 `任务 1 商品 28431952912 更新失败:POST_UPDATE_ERROR`。
- 原因:`click_update()` 的提交后观测 `_wait_post_update()` 当前先检查 `error_toasts`,再检查 `redirected_to_list`。若页面上存在残留或短暂的物流/备货 error toast,即使 Shopee 已接受提交并跳转到商品列表页,也会先返回 `POST_UPDATE_ERROR`,导致 GUI 把真实成功误判为失败。
- 正确判断优先级:跳转到 `/portal/product/list/` 是提交成功的强信号,必须优先于残留/短暂 error toast;只有在未跳转商品列表、无成功 toast,且错误 toast 持续存在到观测窗口结束时,才应判 `POST_UPDATE_ERROR`。
- 解决方案:调整 `_wait_post_update()` 顺序与策略:每轮先判断 `redirected_to_list` 或成功 toast;错误 toast 只记录为诊断,延迟到未见成功信号且持续存在时再判失败。GUI 错误显示应尽量带出 `post_update.error_toasts` 的具体文案,避免只显示 `POST_UPDATE_ERROR`。
- 修复:`_wait_post_update()` 改为先判断 `redirected_to_list` 或成功 toast;错误 toast 只记录为 `last_error_state`,观测窗口结束仍没有成功信号时才返回 `POST_UPDATE_ERROR`。
- 测试:`tests/test_editor_login.py` 新增 `test_click_update_treats_list_redirect_as_success_even_with_stale_error_toast`,保留 `test_click_update_reports_logistics_validation_after_confirm` 覆盖未跳转时错误仍失败;`python -m unittest discover -s tests -p "test_editor_login.py"` 通过(28 tests),`python -m compileall app main.py` 通过,`python -m unittest discover -s tests` 通过(135 tests),`git diff --check` 无空白错误(仅 LF/CRLF 提示)。
- 下一步:用户复跑真实 T-404 链路,确认 GUI 与浏览器成功状态一致。
+348 -2
View File
@@ -68,8 +68,108 @@ def cover_rects(count, prefix="old", start=0):
] ]
class FakeUpdateCDP:
def __init__(
self,
confirm_present=True,
confirm_click=True,
require_content_selector=False,
post_url=None,
post_toasts=None,
):
self.confirm_present = confirm_present
self.confirm_click = confirm_click
self.require_content_selector = require_content_selector
self.post_url = post_url
self.post_toasts = post_toasts
self.main_clicked = False
self.confirm_clicked = False
def val(self, expr):
if expr == editor.JS_FIND_UPDATE:
return json.dumps({"found": True, "disabled": False})
if expr == editor.JS_CLICK_UPDATE:
self.main_clicked = True
return "CLICKED"
if expr == editor.JS_FIND_UPDATE_CONFIRM:
if self.require_content_selector and ".eds-modal__content" not in expr:
return json.dumps({"present": False})
return json.dumps(
{
"present": self.confirm_present,
"title": "確定您要更新商品嗎?" if self.confirm_present else "",
"buttons": ["立即優化", "更新"] if self.confirm_present else [],
}
)
if expr == editor.JS_CLICK_UPDATE_CONFIRM:
if self.require_content_selector and ".eds-modal__content" not in expr:
return json.dumps(
{
"present": False,
"clicked": False,
"reason": "NO_UPDATE_CONFIRM_MODAL",
}
)
if not self.confirm_present:
return json.dumps(
{
"present": False,
"clicked": False,
"reason": "NO_UPDATE_CONFIRM_MODAL",
}
)
if not self.confirm_click:
return json.dumps(
{
"present": True,
"clicked": False,
"reason": "NO_UPDATE_CONFIRM_BUTTON",
"buttons": ["立即優化"],
}
)
self.confirm_clicked = True
self.confirm_present = False
return json.dumps(
{
"present": True,
"clicked": True,
"reason": None,
"text": "更新",
"buttons": ["立即優化", "更新"],
}
)
if expr == editor.JS_POST_UPDATE_STATE:
if self.post_url is None and self.post_toasts is None:
return None
url = self.post_url or "https://seller.shopee.tw/portal/product/51100639510"
toasts = self.post_toasts or []
error_words = ("失敗", "失败", "錯誤", "错误", "不支援", "不支持", "無法", "无法", "error", "fail", "invalid")
return json.dumps(
{
"url": url,
"redirected_to_list": "/portal/product/list/" in url,
"toasts": toasts,
"error_toasts": [t for t in toasts if any(word in t for word in error_words)],
"success_toasts": [t for t in toasts if "成功" in t or "success" in t.lower()],
}
)
if expr == editor.JS_TOASTS:
return json.dumps([])
return None
class FakeCoverCDP: class FakeCoverCDP:
def __init__(self, count=9, delete_click=True, confirm_click=True): def __init__(
self,
count=9,
delete_click=True,
confirm_click=True,
upload_mode="success",
post_delete_busy_polls=0,
upload_input_ready=True,
restore_upload_after_delete=False,
page_error_toasts=None,
):
self.initial = cover_rects(count) self.initial = cover_rects(count)
self.after_delete = cover_rects(max(0, count - 1), prefix="kept", start=1) self.after_delete = cover_rects(max(0, count - 1), prefix="kept", start=1)
self.new_rect = { self.new_rect = {
@@ -84,14 +184,26 @@ class FakeCoverCDP:
} }
self.delete_click = delete_click self.delete_click = delete_click
self.confirm_click = confirm_click self.confirm_click = confirm_click
self.upload_mode = upload_mode
self.post_delete_busy_polls = post_delete_busy_polls
self.upload_input_ready = upload_input_ready
self.restore_upload_after_delete = restore_upload_after_delete
self.page_error_toasts = page_error_toasts or []
self.delete_clicked = False self.delete_clicked = False
self.confirm_clicked = False self.confirm_clicked = False
self.uploaded = False self.uploaded = False
self.uploaded_after_stable = None
self.dragged = False self.dragged = False
self.sent = [] self.sent = []
self.drag_calls = [] self.drag_calls = []
def _rects(self): def _rects(self):
if self.uploaded and self.upload_mode == "processing":
blob_rect = dict(self.new_rect)
blob_rect["src"] = "blob:https://seller.shopee.tw/uploading"
return self.after_delete + [blob_rect]
if self.uploaded and self.upload_mode == "error":
return self.after_delete
if self.uploaded and self.dragged: if self.uploaded and self.dragged:
return [self.new_rect] + self.after_delete return [self.new_rect] + self.after_delete
if self.uploaded: if self.uploaded:
@@ -100,9 +212,42 @@ class FakeCoverCDP:
return self.after_delete return self.after_delete
return self.initial return self.initial
def _upload_ready(self):
return self.upload_input_ready or (self.restore_upload_after_delete and self.confirm_clicked)
def _state(self, busy_count=0, errors=None, crop_modal=False):
rects = self._rects()
srcs = [r["src"] for r in rects]
upload_ready = self._upload_ready()
return {
"manager_exists": True,
"count": len(rects),
"srcs": srcs,
"blob_count": len([src for src in srcs if src.startswith("blob:")]),
"cdn_count": len([src for src in srcs if "susercontent" in src]),
"upload_input_exists": upload_ready,
"upload_input_disabled": (not upload_ready) or busy_count > 0,
"upload_tile_visible": True,
"busy_count": busy_count,
"errors": errors or [],
"crop_modal": crop_modal,
"toasts": list(self.page_error_toasts),
"error_toasts": [],
"page_error_toasts": list(self.page_error_toasts),
}
def val(self, expr): def val(self, expr):
if expr == editor.JS_RECTS: if expr == editor.JS_RECTS:
return json.dumps(self._rects()) return json.dumps(self._rects())
if expr == editor.JS_UPLOAD_STATE:
if self.uploaded and self.upload_mode == "processing":
return json.dumps(self._state(busy_count=1))
if self.uploaded and self.upload_mode == "error":
return json.dumps(self._state(errors=["圖片格式錯誤"]))
if self.confirm_clicked and not self.uploaded and self.post_delete_busy_polls > 0:
self.post_delete_busy_polls -= 1
return json.dumps(self._state(busy_count=1))
return json.dumps(self._state())
if expr == editor.JS_CLICK_FIRST_DELETE: if expr == editor.JS_CLICK_FIRST_DELETE:
if not self.delete_click: if not self.delete_click:
return json.dumps({"clicked": False, "reason": "NO_DELETE_BUTTON"}) return json.dumps({"clicked": False, "reason": "NO_DELETE_BUTTON"})
@@ -116,11 +261,12 @@ class FakeCoverCDP:
return True return True
def object_id(self, expr): def object_id(self, expr):
return "upload-input" return "upload-input" if self._upload_ready() else None
def send(self, method, params=None): def send(self, method, params=None):
self.sent.append((method, params or {})) self.sent.append((method, params or {}))
if method == "DOM.setFileInputFiles": if method == "DOM.setFileInputFiles":
self.uploaded_after_stable = self.post_delete_busy_polls == 0
self.uploaded = True self.uploaded = True
return {} return {}
@@ -322,6 +468,103 @@ class EditorLoginTests(unittest.TestCase):
self.assertTrue(cdp.closed) self.assertTrue(cdp.closed)
close_tab.assert_not_called() close_tab.assert_not_called()
def test_click_update_confirms_shopee_update_modal(self):
cdp = FakeUpdateCDP(confirm_present=True, confirm_click=True)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1)
self.assertTrue(result["clicked"])
self.assertTrue(cdp.main_clicked)
self.assertTrue(cdp.confirm_clicked)
self.assertEqual("更新", result["confirm"]["text"])
self.assertIn("立即優化", result["confirm"]["buttons"])
def test_click_update_handles_real_eds_modal_content_confirm_dom(self):
cdp = FakeUpdateCDP(
confirm_present=True,
confirm_click=True,
require_content_selector=True,
)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1)
self.assertTrue(result["clicked"])
self.assertTrue(cdp.confirm_clicked)
self.assertIn(".eds-modal__content", editor.JS_FIND_UPDATE_CONFIRM)
self.assertIn(".eds-modal__content", editor.JS_CLICK_UPDATE_CONFIRM)
self.assertIn("hasUpdateButton", editor.JS_CLICK_UPDATE_CONFIRM)
self.assertEqual("更新", result["confirm"]["text"])
def test_click_update_records_product_list_redirect_after_confirm(self):
cdp = FakeUpdateCDP(
confirm_present=True,
confirm_click=True,
post_url="https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time",
)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1, post_timeout=0.1)
self.assertTrue(result["clicked"])
self.assertTrue(result["post_update"]["redirected_to_list"])
self.assertEqual(
"https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time",
result["post_update"]["url"],
)
def test_click_update_treats_list_redirect_as_success_even_with_stale_error_toast(self):
cdp = FakeUpdateCDP(
confirm_present=True,
confirm_click=True,
post_url="https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time",
post_toasts=["此物流選項不支援較長備貨商品"],
)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1, post_timeout=0.1)
self.assertTrue(result["clicked"])
self.assertTrue(result["post_update"]["redirected_to_list"])
self.assertIn("此物流選項不支援較長備貨商品", result["post_update"]["error_toasts"])
def test_click_update_reports_logistics_validation_after_confirm(self):
cdp = FakeUpdateCDP(
confirm_present=True,
confirm_click=True,
post_toasts=["此物流選項不支援較長備貨商品"],
)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1, post_timeout=0.1)
self.assertFalse(result["clicked"])
self.assertEqual("POST_UPDATE_ERROR", result["reason"])
self.assertIn("此物流選項不支援較長備貨商品", result["post_update"]["error_toasts"])
def test_click_update_fails_when_shopee_update_modal_is_not_confirmed(self):
cdp = FakeUpdateCDP(confirm_present=True, confirm_click=False)
result = editor.click_update(cdp, confirm_timeout=0.01)
self.assertFalse(result["clicked"])
self.assertTrue(cdp.main_clicked)
self.assertFalse(cdp.confirm_clicked)
self.assertEqual("NO_UPDATE_CONFIRM_BUTTON", result["reason"])
self.assertIn("立即優化", result["confirm"]["buttons"])
def test_click_update_keeps_existing_success_when_no_shopee_confirm_modal(self):
cdp = FakeUpdateCDP(confirm_present=False)
result = editor.click_update(cdp, confirm_timeout=0.01)
self.assertTrue(result["clicked"])
self.assertTrue(cdp.main_clicked)
self.assertFalse(cdp.confirm_clicked)
self.assertFalse(result["confirm"]["present"])
def test_apply_task_closes_auto_created_product_tab_after_success_when_enabled(self): def test_apply_task_closes_auto_created_product_tab_after_success_when_enabled(self):
cdp = FakeProductCDP("ws-new") cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new" cdp.target_id = "target-new"
@@ -434,6 +677,109 @@ class EditorLoginTests(unittest.TestCase):
self.assertEqual(8, result["delete"]["count_after"]) self.assertEqual(8, result["delete"]["count_after"])
self.assertEqual("https://susercontent.com/new-cover.jpg", result["new_src"]) self.assertEqual("https://susercontent.com/new-cover.jpg", result["new_src"])
def test_replace_cover_full_slots_deletes_before_upload_input_is_ready(self):
cdp = FakeCoverCDP(
count=9,
upload_input_ready=False,
restore_upload_after_delete=True,
)
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=__file__,
timeout=1,
)
self.assertTrue(result["ok"])
self.assertTrue(cdp.delete_clicked)
self.assertTrue(cdp.confirm_clicked)
self.assertTrue(cdp.uploaded)
self.assertTrue(cdp.dragged)
self.assertEqual(9, result["count_before"])
self.assertEqual(8, result["delete"]["count_after"])
def test_replace_cover_ignores_unrelated_logistics_page_error_toast(self):
cdp = FakeCoverCDP(
count=9,
page_error_toasts=["此物流選項不支援較長備貨商品"],
)
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=__file__,
timeout=1,
)
self.assertTrue(result["ok"])
self.assertTrue(cdp.delete_clicked)
self.assertTrue(cdp.uploaded)
self.assertTrue(cdp.dragged)
def test_replace_cover_waits_for_deleted_images_to_settle_before_uploading(self):
cdp = FakeCoverCDP(count=9, post_delete_busy_polls=2)
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=__file__,
timeout=1,
)
self.assertTrue(result["ok"])
self.assertTrue(cdp.uploaded_after_stable)
self.assertEqual(0, cdp.post_delete_busy_polls)
def test_replace_cover_reports_upload_still_processing_with_state(self):
cdp = FakeCoverCDP(count=8, upload_mode="processing")
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(cdp, __file__, timeout=0.01)
self.assertFalse(result["ok"])
self.assertEqual("UPLOAD_STILL_PROCESSING", result["reason"])
self.assertTrue(result["blob_seen"])
self.assertEqual(1, result["upload_state"]["busy_count"])
self.assertIn("blob:", "\n".join(result["upload_state"]["srcs"]))
def test_replace_cover_reports_page_upload_error_with_state(self):
cdp = FakeCoverCDP(count=8, upload_mode="error")
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(cdp, __file__, timeout=1)
self.assertFalse(result["ok"])
self.assertEqual("UPLOAD_PAGE_ERROR", result["reason"])
self.assertIn("圖片格式錯誤", result["upload_state"]["errors"])
def test_apply_task_cover_failure_returns_readable_upload_error(self):
cdp = FakeProductCDP("ws-new")
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.replace_cover",
return_value={
"ok": False,
"reason": "UPLOAD_STILL_PROCESSING",
"upload_state": {"busy_count": 1, "errors": [], "toasts": []},
},
), mock.patch("app.editor.click_update") as click_update:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_cover_path": "new.jpg"},
)
self.assertFalse(result["committed"])
self.assertIn("新封面上传仍在处理中", result["error"])
click_update.assert_not_called()
def test_replace_cover_full_slots_reports_missing_delete_button(self): def test_replace_cover_full_slots_reports_missing_delete_button(self):
cdp = FakeCoverCDP(count=9, delete_click=False) cdp = FakeCoverCDP(count=9, delete_click=False)