fix: stabilize Shopee cover replacement

This commit is contained in:
chengma
2026-07-01 08:35:46 +08:00
parent f7ea9ec0fa
commit 483bec427c
18 changed files with 782 additions and 330 deletions
+107 -69
View File
@@ -5,6 +5,7 @@ import os
import time
from urllib.parse import urlparse
from . import image_paths
from .cdp import CDP, close_tab, create_tab, create_tab_info, find_product_tab, http_get
@@ -55,8 +56,8 @@ JS_UPLOAD_STATE = (
"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 errRe=/(失敗|失败|錯誤|错误|不支援|不支持|格式|大小|尺寸|超過|超过|重複|重复|duplicate|error|fail|invalid|unsupported)/i;"
"var uploadRe=/(圖片|图片|封面|照片|相片|圖像|图像|image|photo|cover|upload|上傳|上传|檔案|文件|file|格式|大小|尺寸|像素|解析度|分辨率|超過|超过|重複|重复|duplicate)/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);"
@@ -84,6 +85,19 @@ JS_UPLOAD_STATE = (
"});})()"
)
JS_CLICK_UPLOAD_TILE = (
"(function(){"
"function visible(e){if(!e)return false;var r=e.getBoundingClientRect();var s=getComputedStyle(e);"
"return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}"
"var manager=document.querySelector('.shopee-image-manager');"
"var box=manager?manager.querySelector('.shopee-image-manager__upload,[class*=image-manager__upload]'):null;"
"if(!box)return JSON.stringify({clicked:false,reason:'NO_UPLOAD_TILE'});"
"if(!visible(box))return JSON.stringify({clicked:false,reason:'UPLOAD_TILE_HIDDEN'});"
"box.scrollIntoView({block:'center',inline:'center'});"
"box.click();"
"return JSON.stringify({clicked:true,reason:null});"
"})()"
)
JS_TITLE_STATE = (
"(function(){"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
@@ -317,6 +331,14 @@ def _upload_state(cdp):
return _json_value(cdp, JS_UPLOAD_STATE, default={}) or {}
def _click_upload_tile(cdp):
return _json_value(
cdp,
JS_CLICK_UPLOAD_TILE,
default={"clicked": False, "reason": "NO_UPLOAD_TILE"},
) or {"clicked": False, "reason": "NO_UPLOAD_TILE"}
def _post_update_state(cdp):
return _json_value(cdp, JS_POST_UPDATE_STATE, default=None) or {}
@@ -431,9 +453,24 @@ def _wait_image_manager_stable(
}
def _has_duplicate_upload_error(state):
texts = []
for key in ("errors", "error_toasts", "toasts", "page_error_toasts"):
values = (state or {}).get(key) or []
texts.extend(str(value) for value in values)
return any(
token in text.lower()
for text in texts
for token in ("重複", "重复", "duplicate")
)
def _cover_upload_error_message(result):
reason = result.get("reason") or "COVER_UPDATE_FAILED"
state = result.get("upload_state") or {}
if reason == "UPLOAD_DUPLICATE_IMAGE":
details = state.get("errors") or state.get("error_toasts") or state.get("toasts") or state.get("page_error_toasts") or []
return "新封面与现有商品图片重复:" + ";".join(map(str, details[:3])) if details else "新封面与现有商品图片重复"
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 "新封面上传失败"
@@ -443,6 +480,8 @@ def _cover_upload_error_message(result):
return "新封面上传仍在处理中,未取得 Shopee CDN 地址"
if reason == "UPLOAD_TIMEOUT":
return "新封面上传超时,未取得 Shopee CDN 地址"
if reason == "UPLOAD_TILE_NOT_READY":
return "新封面上传入口未可点击,未开始上传新封面"
if reason == "IMAGE_MANAGER_BUSY":
return "商品图片区域仍在加载,未开始上传新封面"
if reason == "IMAGE_COUNT_NOT_READY":
@@ -634,7 +673,7 @@ def collect(account, task, on_step=None) -> dict:
old_cover_src = read_cover_src(cdp)
out_path = _get(task, "old_cover_path")
if not out_path:
out_path = os.path.join(_image_root(account), f"{item_id}_old.jpg")
out_path = image_paths.task_image_path("images", task, account, "old")
_notify_collect_step(on_step, "download_cover")
old_cover_path = download_cover(old_cover_src, out_path)
return {
@@ -694,8 +733,8 @@ def change_title(cdp, new_title) -> 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.
When the image manager is full, only delete the current first image if the
old cover backup from the collect stage exists locally.
Cover replacement always deletes the current first Shopee image first, and
only proceeds when the old cover backup from the collect stage exists.
"""
image_win_path = os.path.abspath(str(image_win_path))
@@ -726,61 +765,52 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
before = stable.get("rects") or _image_rects(cdp)
count_before = len(before)
delete_result = None
if len(before) >= 9:
backup_path = _validated_old_cover_backup(old_cover_path)
if not backup_path:
return {
"ok": False,
"reason": "OLD_COVER_BACKUP_MISSING",
"count_before": count_before,
"old_cover_path": old_cover_path,
}
delete_result = _delete_first_cover(cdp, before)
if not delete_result.get("ok"):
return {
"ok": False,
"reason": delete_result.get("reason"),
"count_before": count_before,
"delete": delete_result,
}
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)
backup_path = _validated_old_cover_backup(old_cover_path)
if not backup_path:
return {
"ok": False,
"reason": "OLD_COVER_BACKUP_MISSING",
"count_before": count_before,
"old_cover_path": old_cover_path,
}
delete_result = _delete_first_cover(cdp, before)
if not delete_result.get("ok"):
return {
"ok": False,
"reason": delete_result.get("reason"),
"count_before": count_before,
"delete": delete_result,
}
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)
before_srcs = {r.get("src") for r in before}
upload_click = _click_upload_tile(cdp)
if not upload_click.get("clicked"):
return {
"ok": False,
"reason": "UPLOAD_TILE_NOT_READY",
"count_before": count_before,
"delete": delete_result,
"upload_click": upload_click,
"upload_state": _upload_state(cdp),
}
time.sleep(1)
oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')")
if not oid:
return {
@@ -788,6 +818,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"reason": "NO_UPLOAD_INPUT",
"count_before": count_before,
"delete": delete_result,
"upload_click": upload_click,
"upload_state": _upload_state(cdp),
}
@@ -807,6 +838,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"reason": "UPLOAD_INPUT_NOT_READY",
"count_before": count_before,
"delete": delete_result,
"upload_click": upload_click,
"upload_state": last_state,
}
blob_seen = bool(last_state.get("blob_count"))
@@ -816,6 +848,18 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
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)
if (last_state.get("errors") or last_state.get("error_toasts")) and not last_state.get("busy_count"):
reason = "UPLOAD_DUPLICATE_IMAGE" if _has_duplicate_upload_error(last_state) else "UPLOAD_PAGE_ERROR"
return {
"ok": False,
"reason": reason,
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_click": upload_click,
"upload_state": last_state,
"file_size": file_size,
}
ready = [
r for r in cur
if r.get("src") not in before_srcs
@@ -826,16 +870,6 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
if ready and (len(cur) > len(before) or len(ready) == 1):
new_src = ready[-1]["src"]
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,
@@ -843,6 +877,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_click": upload_click,
"upload_state": last_state,
"file_size": file_size,
}
@@ -856,6 +891,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_click": upload_click,
"upload_state": last_state,
"blob_seen": blob_seen,
"file_size": file_size,
@@ -881,6 +917,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"count_before": count_before,
"count_after": len(after),
"delete": delete_result,
"upload_click": upload_click,
}
@@ -1069,6 +1106,7 @@ def _close_applied_product(cdp, close_success_tab=False):
finally:
if close_success_tab and created_by_app and target_id:
try:
time.sleep(2)
close_tab(target_id, host=host)
except Exception:
pass