fix: handle Shopee update confirmation and upload edge cases
This commit is contained in:
+421
-15
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user