2026-06-26 18:02:57 +08:00
|
|
|
|
"""Shopee editor operations built on the verified CDP primitives."""
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import time
|
|
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
|
from . import appconfig, image_paths, product_status
|
2026-07-14 12:08:12 +08:00
|
|
|
|
from .cdp import (
|
|
|
|
|
|
CDP,
|
|
|
|
|
|
close_tab,
|
|
|
|
|
|
close_tab_and_wait,
|
|
|
|
|
|
create_tab,
|
|
|
|
|
|
create_tab_info,
|
|
|
|
|
|
find_product_tab,
|
|
|
|
|
|
http_get,
|
|
|
|
|
|
)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT_REGION_HOST = "seller.shopee.tw"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
TITLE_FIELD_SELECTOR = '[data-product-edit-field-unique-id="name"]'
|
|
|
|
|
|
IMAGE_FIELD_SELECTOR = '[data-product-edit-field-unique-id="images"]'
|
|
|
|
|
|
TITLE_XPATH = (
|
|
|
|
|
|
"//*[@data-product-edit-field-unique-id='name']"
|
|
|
|
|
|
"//input[contains(concat(' ', normalize-space(@class), ' '), ' eds-input__input ')]"
|
|
|
|
|
|
)
|
|
|
|
|
|
LEGACY_TITLE_XPATH = "//input[@class='eds-input__input' and string-length(@modelvalue)>24]"
|
2026-06-26 18:02:57 +08:00
|
|
|
|
ITEMBOX_XPATH = (
|
2026-07-14 16:31:35 +08:00
|
|
|
|
"//*[@data-product-edit-field-unique-id='images']"
|
|
|
|
|
|
"//div[contains(concat(' ', normalize-space(@class), ' '), "
|
|
|
|
|
|
"' shopee-image-manager__itembox ') and @data-draggable='true']"
|
|
|
|
|
|
)
|
|
|
|
|
|
LEGACY_ITEMBOX_XPATH = (
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"//div[@class='container']/div[@class='can-drag shopee-image-manager__itembox' "
|
|
|
|
|
|
"and @data-draggable='true']"
|
|
|
|
|
|
)
|
2026-07-09 15:04:20 +08:00
|
|
|
|
COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS = 120
|
|
|
|
|
|
COVER_FOREGROUND_UPLOAD_RECOVERY_REASONS = {"UPLOAD_STILL_PROCESSING", "UPLOAD_TIMEOUT"}
|
|
|
|
|
|
COVER_FOREGROUND_DRAG_RECOVERY_REASONS = {"DRAG_NOT_FIRST", "NEW_IMAGE_NOT_FOUND"}
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
2026-06-27 09:42:41 +08:00
|
|
|
|
LOGIN_PATH_MARKERS = (
|
|
|
|
|
|
"/login",
|
|
|
|
|
|
"account/signin",
|
2026-07-02 22:38:08 +08:00
|
|
|
|
"accounts.shopee.tw/seller/login",
|
2026-06-27 09:42:41 +08:00
|
|
|
|
"seller/login",
|
|
|
|
|
|
"seller/accounts/signin",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-14 16:31:35 +08:00
|
|
|
|
JS_EDITOR_FIELD_HELPERS = "".join(
|
|
|
|
|
|
(
|
|
|
|
|
|
"function cmVisible(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 cmTitleCandidate(){",
|
|
|
|
|
|
f"var root=document.querySelector({json.dumps(TITLE_FIELD_SELECTOR)});",
|
|
|
|
|
|
"if(root){var primary=[].slice.call(root.querySelectorAll('input.eds-input__input'))"
|
|
|
|
|
|
".filter(cmVisible);return {node:primary.length===1?primary[0]:null,count:primary.length,"
|
|
|
|
|
|
"source:'business',root_exists:true};}",
|
|
|
|
|
|
f"var snap=document.evaluate({json.dumps(LEGACY_TITLE_XPATH)},document,null,"
|
|
|
|
|
|
"XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);",
|
|
|
|
|
|
"var legacy=[];for(var i=0;i<snap.snapshotLength;i++){var item=snap.snapshotItem(i);"
|
|
|
|
|
|
"if(cmVisible(item))legacy.push(item);}",
|
|
|
|
|
|
"return {node:legacy.length===1?legacy[0]:null,count:legacy.length,"
|
|
|
|
|
|
"source:'legacy',root_exists:false};}",
|
|
|
|
|
|
"function cmMainImageContext(){",
|
|
|
|
|
|
f"var root=document.querySelector({json.dumps(IMAGE_FIELD_SELECTOR)});",
|
|
|
|
|
|
"if(root)return {node:root.querySelector('.shopee-image-manager'),"
|
|
|
|
|
|
"source:'business',root_exists:true};",
|
|
|
|
|
|
"return {node:document.querySelector('.shopee-image-manager'),"
|
|
|
|
|
|
"source:'legacy',root_exists:false};}",
|
|
|
|
|
|
"function cmMainImageItems(context){",
|
|
|
|
|
|
"if(context&&context.root_exists){var manager=context.node;return manager?"
|
|
|
|
|
|
"[].slice.call(manager.querySelectorAll("
|
|
|
|
|
|
"'.shopee-image-manager__itembox.can-drag[data-draggable=\"true\"]')):[];}",
|
|
|
|
|
|
f"var snap=document.evaluate({json.dumps(LEGACY_ITEMBOX_XPATH)},document,null,"
|
|
|
|
|
|
"XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);",
|
|
|
|
|
|
"var items=[];for(var i=0;i<snap.snapshotLength;i++)items.push(snap.snapshotItem(i));"
|
|
|
|
|
|
"return items;}",
|
|
|
|
|
|
"function cmMainUploadInput(context){var manager=context&&context.node;return manager?"
|
|
|
|
|
|
"manager.querySelector('.shopee-image-manager__upload input[type=file]'):null;}",
|
|
|
|
|
|
"function cmMainUploadBox(context){var manager=context&&context.node;return manager?"
|
|
|
|
|
|
"manager.querySelector('.shopee-image-manager__upload,[class*=image-manager__upload]'):null;}",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
JS_READY = (
|
|
|
|
|
|
"(function(){"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "var title=cmTitleCandidate();var image=cmMainImageContext();"
|
|
|
|
|
|
"var items=cmMainImageItems(image);var up=cmMainUploadInput(image);"
|
|
|
|
|
|
"var uploads=image.node?image.node.querySelectorAll("
|
|
|
|
|
|
"'.shopee-image-manager__upload input[type=file]'):[];"
|
|
|
|
|
|
"var srcs=items.map(function(item){var img=item.querySelector('img');return img?img.src:null;})"
|
|
|
|
|
|
".filter(Boolean);"
|
|
|
|
|
|
"var ready=title.count===1&&items.length>0&&uploads.length===1;"
|
|
|
|
|
|
"return JSON.stringify({ready:ready,title_count:title.count,title_source:title.source,"
|
|
|
|
|
|
"title_field_exists:title.root_exists,title_ambiguous:title.count>1,"
|
|
|
|
|
|
"image_field_exists:image.root_exists,image_manager_exists:!!image.node,"
|
|
|
|
|
|
"image_count:items.length,cdn_count:srcs.filter(function(s){return /susercontent/.test(s);}).length,"
|
|
|
|
|
|
"blob_count:srcs.filter(function(s){return /^blob:/.test(s);}).length,"
|
|
|
|
|
|
"upload_input_exists:!!up,upload_input_count:uploads.length,current_url:location.href});})()"
|
2026-06-26 18:02:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
JS_RECTS = (
|
|
|
|
|
|
"(function(){"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "var image=cmMainImageContext();var items=cmMainImageItems(image);"
|
|
|
|
|
|
"var a=[];for(var i=0;i<items.length;i++){"
|
|
|
|
|
|
"var el=items[i];var r=el.getBoundingClientRect();var im=el.querySelector('img');"
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"a.push({i:i,x:r.left+r.width/2,y:r.top+r.height/2,left:r.left,top:r.top,"
|
|
|
|
|
|
"w:r.width,h:r.height,src:im?im.src:null});}"
|
|
|
|
|
|
"return JSON.stringify(a);})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
|
JS_PRODUCT_STATUS_ALERTS = (
|
|
|
|
|
|
"(function(){"
|
|
|
|
|
|
"var nodes=[].slice.call(document.querySelectorAll('.eds-alert.eds-alert--warning'));"
|
|
|
|
|
|
"return JSON.stringify(nodes.map(function(node){"
|
|
|
|
|
|
"var title=node.querySelector('.eds-alert-title');"
|
|
|
|
|
|
"var desc=node.querySelector('.eds-alert-desc');"
|
|
|
|
|
|
"return {title:(title&&(title.innerText||title.textContent)||'').trim(),"
|
|
|
|
|
|
"description:(desc&&(desc.innerText||desc.textContent)||'').trim()};"
|
|
|
|
|
|
"}));})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-30 11:44:27 +08:00
|
|
|
|
|
|
|
|
|
|
JS_UPLOAD_STATE = (
|
|
|
|
|
|
"(function(){"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "function visible(e){return cmVisible(e);}"
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"function text(e){return ((e&&((e.innerText||e.textContent)||''))||'').trim();}"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
"var image=cmMainImageContext();var manager=image.node;var items=cmMainImageItems(image);"
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"var imgs=items.map(function(item){var img=item.querySelector('img');return img?img.src:null;}).filter(Boolean);"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
"var upload=cmMainUploadInput(image);var uploadBox=cmMainUploadBox(image);"
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"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):[];"
|
2026-07-01 08:35:46 +08:00
|
|
|
|
"var errRe=/(失敗|失败|錯誤|错误|不支援|不支持|格式|大小|尺寸|超過|超过|重複|重复|duplicate|error|fail|invalid|unsupported)/i;"
|
|
|
|
|
|
"var uploadRe=/(圖片|图片|封面|照片|相片|圖像|图像|image|photo|cover|upload|上傳|上传|檔案|文件|file|格式|大小|尺寸|像素|解析度|分辨率|超過|超过|重複|重复|duplicate)/i;"
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"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,"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
"image_field_exists:image.root_exists,"
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"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,"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
"upload_input_count:manager?manager.querySelectorAll('.shopee-image-manager__upload input[type=file]').length:0,"
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"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"
|
|
|
|
|
|
"});})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-01 08:35:46 +08:00
|
|
|
|
JS_CLICK_UPLOAD_TILE = (
|
|
|
|
|
|
"(function(){"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "function visible(e){return cmVisible(e);}"
|
|
|
|
|
|
"var image=cmMainImageContext();var box=cmMainUploadBox(image);"
|
2026-07-01 08:35:46 +08:00
|
|
|
|
"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});"
|
|
|
|
|
|
"})()"
|
|
|
|
|
|
)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
JS_TITLE_STATE = (
|
|
|
|
|
|
"(function(){"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "var title=cmTitleCandidate();var el=title.node;"
|
|
|
|
|
|
"return JSON.stringify({found:!!el,candidate_count:title.count,source:title.source,"
|
|
|
|
|
|
"field_exists:title.root_exists,value:el?el.value:null,"
|
|
|
|
|
|
"modelvalue:el?el.getAttribute('modelvalue'):null});})()"
|
2026-06-26 18:02:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
JS_FIRST_COVER = (
|
|
|
|
|
|
"(function(){"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "var image=cmMainImageContext();var items=cmMainImageItems(image);"
|
|
|
|
|
|
"if(!items.length)return null;"
|
|
|
|
|
|
"var img=items[0].querySelector('img');return img?img.src:null;})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
JS_SCROLL_MAIN_IMAGE_MANAGER = (
|
|
|
|
|
|
"(function(){"
|
|
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "var image=cmMainImageContext();var manager=image.node;"
|
|
|
|
|
|
"if(manager)manager.scrollIntoView({block:'center'});return !!manager;})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
JS_MAIN_UPLOAD_INPUT = (
|
|
|
|
|
|
"(function(){"
|
|
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "var image=cmMainImageContext();return cmMainUploadInput(image);})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
JS_DISPATCH_MAIN_UPLOAD_INPUT_EVENTS = (
|
|
|
|
|
|
"(function(){"
|
|
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "var image=cmMainImageContext();var up=cmMainUploadInput(image);"
|
|
|
|
|
|
"if(!up)return false;"
|
|
|
|
|
|
"up.dispatchEvent(new Event('input',{bubbles:true}));"
|
|
|
|
|
|
"up.dispatchEvent(new Event('change',{bubbles:true}));return true;})()"
|
2026-06-26 18:02:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
JS_FIND_UPDATE = (
|
|
|
|
|
|
"(function(){var bs=[].slice.call(document.querySelectorAll('button.eds-button'));"
|
|
|
|
|
|
"var b=bs.find(function(b){var sp=b.querySelector('span');"
|
|
|
|
|
|
"return (sp?sp.innerText:(b.innerText||'')).trim()==='更新' && b.offsetParent!==null;});"
|
|
|
|
|
|
"if(!b)return JSON.stringify({found:false});"
|
|
|
|
|
|
"var dis=b.disabled||/disabled/i.test(b.className);"
|
|
|
|
|
|
"return JSON.stringify({found:true,disabled:dis});})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
JS_CLICK_UPDATE = (
|
|
|
|
|
|
"(function(){var bs=[].slice.call(document.querySelectorAll('button.eds-button'));"
|
|
|
|
|
|
"var b=bs.find(function(b){var sp=b.querySelector('span');"
|
|
|
|
|
|
"return (sp?sp.innerText:(b.innerText||'')).trim()==='更新' && b.offsetParent!==null;});"
|
|
|
|
|
|
"if(!b)return 'NO_BTN';if(b.disabled||/disabled/i.test(b.className))return 'DISABLED';"
|
|
|
|
|
|
"b.click();return 'CLICKED';})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-30 11:44:27 +08:00
|
|
|
|
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)});})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
JS_TOASTS = (
|
|
|
|
|
|
"(function(){var es=[].slice.call(document.querySelectorAll("
|
|
|
|
|
|
"'[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]'));"
|
|
|
|
|
|
"var t=es.filter(function(e){return e.offsetParent!==null;})"
|
|
|
|
|
|
".map(function(e){return (e.innerText||'').trim();}).filter(Boolean);"
|
|
|
|
|
|
"return JSON.stringify(t.slice(0,5));})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-02 21:46:32 +08:00
|
|
|
|
JS_TOAST_OBSERVER_SOURCE = (
|
|
|
|
|
|
"(function(){"
|
|
|
|
|
|
"if(window.__cmshopee_toast_observer_installed)return true;"
|
|
|
|
|
|
"window.__cmshopee_toast_observer_installed=true;"
|
|
|
|
|
|
"window.__cmshopee_toasts=window.__cmshopee_toasts||[];"
|
|
|
|
|
|
"function text(e){return ((e&&e.innerText)||(e&&e.textContent)||'').trim();}"
|
|
|
|
|
|
"function visible(e){if(!e)return false;var s=getComputedStyle(e);var r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&r.width>0&&r.height>0;}"
|
|
|
|
|
|
"function rootOf(e){return e.closest?e.closest('.eds-toast,.eds-toasts,[role=alert],[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')||e:e;}"
|
|
|
|
|
|
"function record(e){var root=rootOf(e);var value=text(root)||text(e);if(!value)return;window.__cmshopee_toasts.push({text:value,html:((root.outerHTML||e.outerHTML)||'').slice(0,2000),url:location.href,visible:visible(root)||visible(e),created_at:(new Date()).toISOString()});}"
|
|
|
|
|
|
"function scan(){try{Array.from(document.querySelectorAll('.eds-toasts .eds-toast,.eds-toast,.eds-toast__content,[role=alert],[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')).forEach(record);window.__cmshopee_toasts=window.__cmshopee_toasts.slice(-20);}catch(e){}}"
|
|
|
|
|
|
"try{if(document.documentElement||document.body){var mo=new MutationObserver(function(){scan();});mo.observe(document.documentElement||document.body,{childList:true,subtree:true,characterData:true,attributes:true,attributeFilter:['style','class']});window.__cmshopee_toast_observer=mo;}}catch(e){}"
|
|
|
|
|
|
"scan();return true;"
|
|
|
|
|
|
"})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
JS_INSTALL_TOAST_OBSERVER = JS_TOAST_OBSERVER_SOURCE
|
|
|
|
|
|
|
|
|
|
|
|
JS_PAGE_TOASTS = (
|
|
|
|
|
|
"(function(){"
|
|
|
|
|
|
"function text(e){return ((e&&e.innerText)||(e&&e.textContent)||'').trim();}"
|
|
|
|
|
|
"function visible(e){if(!e)return false;var s=getComputedStyle(e);var r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&r.width>0&&r.height>0;}"
|
|
|
|
|
|
"function rootOf(e){return e.closest?e.closest('.eds-toast,.eds-toasts,[role=alert],[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')||e:e;}"
|
|
|
|
|
|
"function record(e){var root=rootOf(e);var value=text(root)||text(e);if(!value)return null;return {text:value,html:((root.outerHTML||e.outerHTML)||'').slice(0,2000),url:location.href,visible:visible(root)||visible(e),created_at:(new Date()).toISOString()};}"
|
|
|
|
|
|
"var cached=(window.__cmshopee_toasts||[]).slice(-10);"
|
|
|
|
|
|
"var current=Array.from(document.querySelectorAll('.eds-toasts .eds-toast,.eds-toast,.eds-toast__content,[role=alert],[class*=toast],[class*=Toast],[class*=message],[class*=Message],[class*=notice]')).map(record).filter(Boolean);"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
"var positions={};var out=[];"
|
|
|
|
|
|
"cached.forEach(function(item){var copy=Object.assign({},item,{visible:false});var key=[copy.text,copy.url].join('|');"
|
|
|
|
|
|
"if(positions[key]!==undefined){out[positions[key]]=copy;return;}positions[key]=out.length;out.push(copy);});"
|
|
|
|
|
|
"current.forEach(function(item){var key=[item.text,item.url].join('|');"
|
|
|
|
|
|
"if(positions[key]!==undefined){out[positions[key]]=item;return;}positions[key]=out.length;out.push(item);});"
|
2026-07-02 21:46:32 +08:00
|
|
|
|
"return JSON.stringify(out.slice(-10));"
|
|
|
|
|
|
"})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-30 11:44:27 +08:00
|
|
|
|
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);})"
|
|
|
|
|
|
"});})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-29 09:49:53 +08:00
|
|
|
|
JS_CLICK_FIRST_DELETE = (
|
|
|
|
|
|
"(function(){"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "var image=cmMainImageContext();var items=cmMainImageItems(image);"
|
|
|
|
|
|
"if(!items.length)return JSON.stringify({clicked:false,reason:'NO_IMAGE'});"
|
|
|
|
|
|
"var item=items[0];"
|
2026-06-29 09:49:53 +08:00
|
|
|
|
"['mouseover','mouseenter','mousemove'].forEach(function(n){"
|
|
|
|
|
|
"item.dispatchEvent(new MouseEvent(n,{bubbles:true,view:window}));});"
|
|
|
|
|
|
"var selectors=['.shopee-image-manager__icon--delete','[class*=\"icon--delete\"]','[class*=\"delete\"]'];"
|
|
|
|
|
|
"var btn=null;"
|
|
|
|
|
|
"for(var i=0;i<selectors.length&&!btn;i++){btn=item.querySelector(selectors[i]);}"
|
|
|
|
|
|
"if(!btn){var all=[].slice.call(item.querySelectorAll('*'));"
|
|
|
|
|
|
"btn=all.find(function(e){var t=[e.className,e.innerText,e.title,e.getAttribute('aria-label')].join(' ');"
|
|
|
|
|
|
"return /(delete|删除|刪除)/i.test(t);});}"
|
|
|
|
|
|
"if(!btn)return JSON.stringify({clicked:false,reason:'NO_DELETE_BUTTON'});"
|
|
|
|
|
|
"if(btn.scrollIntoView)btn.scrollIntoView({block:'center'});"
|
|
|
|
|
|
"btn.click();return JSON.stringify({clicked:true,reason:null});})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
JS_CLICK_DELETE_CONFIRM = (
|
|
|
|
|
|
"(function(){"
|
|
|
|
|
|
"function visible(e){var r=e.getBoundingClientRect();var s=getComputedStyle(e);"
|
|
|
|
|
|
"return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}"
|
|
|
|
|
|
"var rootSelectors='[role=dialog],[class*=dialog],[class*=Dialog],[class*=modal],"
|
|
|
|
|
|
"[class*=Modal],[class*=popover],[class*=Popover],[class*=popup],[class*=Popup]';"
|
|
|
|
|
|
"var roots=[].slice.call(document.querySelectorAll(rootSelectors)).filter(visible);"
|
|
|
|
|
|
"var yes=/(删除|刪除|確認|确认|確定|确定|OK|Yes)/i;"
|
|
|
|
|
|
"var no=/(取消|cancel|否|No)/i;"
|
|
|
|
|
|
"var seen=[];var candidates=[];"
|
|
|
|
|
|
"roots.forEach(function(root){[].slice.call(root.querySelectorAll('button,[role=button]')).forEach(function(b){"
|
|
|
|
|
|
"var text=((b.innerText||b.textContent||b.getAttribute('aria-label')||'')+'').trim();"
|
|
|
|
|
|
"if(!text||!visible(b))return;seen.push(text);"
|
|
|
|
|
|
"if(b.disabled||/disabled/i.test(b.className)||no.test(text)||!yes.test(text))return;"
|
|
|
|
|
|
"candidates.push({button:b,text:text,score:/(删除|刪除)/i.test(text)?0:1});});});"
|
|
|
|
|
|
"if(!candidates.length)return JSON.stringify({clicked:false,reason:'NO_CONFIRM_BUTTON',buttons:seen.slice(0,8)});"
|
|
|
|
|
|
"candidates.sort(function(a,b){return a.score-b.score;});"
|
|
|
|
|
|
"candidates[0].button.click();"
|
|
|
|
|
|
"return JSON.stringify({clicked:true,reason:null,text:candidates[0].text});})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
class EditorError(RuntimeError):
|
|
|
|
|
|
"""Raised for expected editor automation failures with user-readable messages."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get(obj, *names, default=None):
|
|
|
|
|
|
for name in names:
|
|
|
|
|
|
if isinstance(obj, dict) and name in obj:
|
|
|
|
|
|
return obj[name]
|
|
|
|
|
|
if hasattr(obj, name):
|
|
|
|
|
|
return getattr(obj, name)
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cdp_host(account=None):
|
|
|
|
|
|
explicit = _get(account, "cdp_host", "debug_host")
|
|
|
|
|
|
if explicit:
|
|
|
|
|
|
return str(explicit).replace("http://", "").replace("https://", "").rstrip("/")
|
|
|
|
|
|
port = _get(account, "debug_port", "port")
|
|
|
|
|
|
if port:
|
|
|
|
|
|
return f"127.0.0.1:{port}"
|
|
|
|
|
|
return os.environ.get("CDP_HOST", "127.0.0.1:9222")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _region_host(account=None):
|
|
|
|
|
|
value = _get(account, "region_host", "host", default=DEFAULT_REGION_HOST)
|
|
|
|
|
|
value = str(value or DEFAULT_REGION_HOST).strip()
|
|
|
|
|
|
if "://" in value:
|
|
|
|
|
|
value = urlparse(value).netloc
|
|
|
|
|
|
return value.strip("/") or DEFAULT_REGION_HOST
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 12:05:33 +08:00
|
|
|
|
def _seller_home_url(account):
|
|
|
|
|
|
return f"https://{_region_host(account)}/"
|
2026-06-27 09:42:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
def _product_url(account, item_id):
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"https://{_region_host(account)}/portal/product/{item_id}"
|
|
|
|
|
|
"?pageEntry=product_list&ignore-html-cache=1"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _item_id(task):
|
|
|
|
|
|
value = _get(task, "item_id", "itemid", "product_id")
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
raise EditorError("缺少商品 id,无法打开商品页")
|
|
|
|
|
|
return str(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _image_root(account):
|
|
|
|
|
|
alias = _get(account, "slug", "alias", "account_name", default="default")
|
|
|
|
|
|
safe = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(alias)).strip("_")
|
|
|
|
|
|
return os.path.join("images", safe or "default")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 09:42:41 +08:00
|
|
|
|
def _is_login_url(url):
|
|
|
|
|
|
value = (url or "").lower()
|
2026-07-02 22:38:08 +08:00
|
|
|
|
try:
|
|
|
|
|
|
parsed = urlparse(value)
|
|
|
|
|
|
if parsed.netloc.startswith("accounts.shopee.") and parsed.path.startswith("/seller/login"):
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-06-27 09:42:41 +08:00
|
|
|
|
return any(marker in value for marker in LOGIN_PATH_MARKERS)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
def _json_value(cdp, expr, default=None):
|
|
|
|
|
|
raw = cdp.val(expr)
|
2026-07-02 21:46:32 +08:00
|
|
|
|
if raw is None or raw == "":
|
2026-06-26 18:02:57 +08:00
|
|
|
|
return default
|
2026-07-02 21:46:32 +08:00
|
|
|
|
if isinstance(raw, (dict, list)):
|
|
|
|
|
|
return raw
|
2026-06-26 18:02:57 +08:00
|
|
|
|
return json.loads(raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-02 21:46:32 +08:00
|
|
|
|
PRODUCT_UNAVAILABLE_TEXT_MARKERS = (
|
|
|
|
|
|
"please input correct product id",
|
|
|
|
|
|
"商品失效",
|
|
|
|
|
|
"商品不存在",
|
|
|
|
|
|
"商品已删除",
|
|
|
|
|
|
"商品已下架",
|
|
|
|
|
|
"無效商品",
|
|
|
|
|
|
"无效商品",
|
|
|
|
|
|
"無效的商品",
|
|
|
|
|
|
"无效的商品",
|
|
|
|
|
|
"無權限",
|
|
|
|
|
|
"无权限",
|
|
|
|
|
|
"沒有權限",
|
|
|
|
|
|
"没有权限",
|
|
|
|
|
|
"無法查看商品",
|
|
|
|
|
|
"无法查看商品",
|
|
|
|
|
|
"product not found",
|
|
|
|
|
|
"item not found",
|
|
|
|
|
|
"not exist",
|
|
|
|
|
|
"not found",
|
|
|
|
|
|
"invalid product",
|
|
|
|
|
|
"invalid item",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def install_toast_observer(cdp) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
cdp.send("Page.addScriptToEvaluateOnNewDocument", {"source": JS_TOAST_OBSERVER_SOURCE})
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
cdp.val(JS_INSTALL_TOAST_OBSERVER)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_page_toasts(cdp):
|
|
|
|
|
|
try:
|
|
|
|
|
|
values = _json_value(cdp, JS_PAGE_TOASTS, default=[])
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return []
|
|
|
|
|
|
if not isinstance(values, list):
|
|
|
|
|
|
return []
|
|
|
|
|
|
toasts = []
|
|
|
|
|
|
for entry in values:
|
|
|
|
|
|
if isinstance(entry, str):
|
|
|
|
|
|
text = entry.strip()
|
|
|
|
|
|
if text:
|
|
|
|
|
|
toasts.append({"text": text, "html": "", "url": "", "visible": False, "created_at": ""})
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not isinstance(entry, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
text = str(entry.get("text") or "").strip()
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
continue
|
|
|
|
|
|
toasts.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"text": text,
|
|
|
|
|
|
"html": str(entry.get("html") or "")[:2000],
|
|
|
|
|
|
"url": str(entry.get("url") or ""),
|
|
|
|
|
|
"visible": bool(entry.get("visible")),
|
|
|
|
|
|
"created_at": str(entry.get("created_at") or ""),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return toasts[-10:]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_product_unavailable_error(text):
|
|
|
|
|
|
value = str(text or "").strip().lower()
|
|
|
|
|
|
return bool(value) and any(marker.lower() in value for marker in PRODUCT_UNAVAILABLE_TEXT_MARKERS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def product_unavailable_error_message(text):
|
|
|
|
|
|
value = str(text or "").strip()
|
|
|
|
|
|
if value.startswith("商品失效"):
|
|
|
|
|
|
return value
|
|
|
|
|
|
return f"商品失效:{value or '商品详情页无法加载'}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _product_unavailable_toast_text(toasts):
|
|
|
|
|
|
for toast in reversed(toasts or []):
|
|
|
|
|
|
text = str(toast.get("text") or "").strip()
|
|
|
|
|
|
if is_product_unavailable_error(text):
|
|
|
|
|
|
return text
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 16:31:35 +08:00
|
|
|
|
def _same_page_url(left, right):
|
|
|
|
|
|
try:
|
|
|
|
|
|
left_url = urlparse(str(left or ""))
|
|
|
|
|
|
right_url = urlparse(str(right or ""))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return bool(
|
|
|
|
|
|
left_url.netloc
|
|
|
|
|
|
and left_url.netloc == right_url.netloc
|
|
|
|
|
|
and left_url.path.rstrip("/") == right_url.path.rstrip("/")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _open_product_ready_error(cdp, fallback, current_url=""):
|
2026-07-02 21:46:32 +08:00
|
|
|
|
toasts = read_page_toasts(cdp)
|
|
|
|
|
|
invalid_text = _product_unavailable_toast_text(toasts)
|
|
|
|
|
|
if invalid_text:
|
|
|
|
|
|
return product_unavailable_error_message(invalid_text)
|
2026-07-14 16:31:35 +08:00
|
|
|
|
page_url = str(current_url or _current_url(cdp) or "")
|
2026-07-02 21:46:32 +08:00
|
|
|
|
for toast in reversed(toasts):
|
|
|
|
|
|
text = str(toast.get("text") or "").strip()
|
2026-07-14 16:31:35 +08:00
|
|
|
|
if text and toast.get("visible") and _same_page_url(toast.get("url"), page_url):
|
|
|
|
|
|
return f"{fallback};页面提示(可能无关):{text}"
|
2026-07-02 21:46:32 +08:00
|
|
|
|
return fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
def _title_state(cdp):
|
|
|
|
|
|
return _json_value(cdp, JS_TITLE_STATE, default={}) or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 16:31:35 +08:00
|
|
|
|
def _ready_state(cdp):
|
|
|
|
|
|
raw = cdp.val(JS_READY)
|
|
|
|
|
|
if isinstance(raw, bool):
|
|
|
|
|
|
return {"ready": raw}
|
|
|
|
|
|
if isinstance(raw, dict):
|
|
|
|
|
|
return raw
|
|
|
|
|
|
if raw in (None, ""):
|
|
|
|
|
|
return {}
|
|
|
|
|
|
parsed = json.loads(raw)
|
|
|
|
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ready_error_message(fallback, state):
|
|
|
|
|
|
if not state:
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
|
|
|
|
|
|
details = []
|
|
|
|
|
|
title_count = state.get("title_count")
|
|
|
|
|
|
if title_count == 1:
|
|
|
|
|
|
details.append("商品名称输入框正常")
|
|
|
|
|
|
elif isinstance(title_count, int) and title_count > 1:
|
|
|
|
|
|
details.append(f"商品名称输入框匹配到{title_count}个,无法确定唯一字段")
|
|
|
|
|
|
elif title_count is not None:
|
|
|
|
|
|
details.append("未识别商品名称输入框")
|
|
|
|
|
|
|
|
|
|
|
|
image_count = state.get("image_count")
|
|
|
|
|
|
if not state.get("image_manager_exists") and "image_manager_exists" in state:
|
|
|
|
|
|
details.append("未识别商品主图区域")
|
|
|
|
|
|
elif isinstance(image_count, int) and image_count > 0:
|
|
|
|
|
|
details.append(f"商品图片{image_count}张")
|
|
|
|
|
|
elif image_count is not None:
|
|
|
|
|
|
details.append("商品图片尚未加载")
|
|
|
|
|
|
|
|
|
|
|
|
upload_count = state.get("upload_input_count")
|
|
|
|
|
|
if upload_count == 1:
|
|
|
|
|
|
details.append("主图上传入口正常")
|
|
|
|
|
|
elif isinstance(upload_count, int) and upload_count > 1:
|
|
|
|
|
|
details.append(f"主图上传入口匹配到{upload_count}个,无法确定唯一入口")
|
|
|
|
|
|
elif "upload_input_exists" in state or upload_count is not None:
|
|
|
|
|
|
details.append("主图上传入口尚未加载")
|
|
|
|
|
|
|
|
|
|
|
|
current_url = str(state.get("current_url") or "")
|
|
|
|
|
|
if current_url:
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = urlparse(current_url)
|
|
|
|
|
|
current_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
details.append(f"当前页面:{current_url}")
|
|
|
|
|
|
return f"{fallback}:{';'.join(details)}" if details else fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
def _image_rects(cdp):
|
|
|
|
|
|
return _json_value(cdp, JS_RECTS, default=[]) or []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 11:44:27 +08:00
|
|
|
|
def _upload_state(cdp):
|
|
|
|
|
|
return _json_value(cdp, JS_UPLOAD_STATE, default={}) or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 08:35:46 +08:00
|
|
|
|
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"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 11:44:27 +08:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 08:35:46 +08:00
|
|
|
|
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")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 11:44:27 +08:00
|
|
|
|
def _cover_upload_error_message(result):
|
|
|
|
|
|
reason = result.get("reason") or "COVER_UPDATE_FAILED"
|
|
|
|
|
|
state = result.get("upload_state") or {}
|
2026-07-01 08:35:46 +08:00
|
|
|
|
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 "新封面与现有商品图片重复"
|
2026-06-30 11:44:27 +08:00
|
|
|
|
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":
|
2026-07-07 14:58:54 +08:00
|
|
|
|
return "新封面上传仍在处理中,未取得蝦皮 CDN 地址"
|
2026-06-30 11:44:27 +08:00
|
|
|
|
if reason == "UPLOAD_TIMEOUT":
|
2026-07-07 14:58:54 +08:00
|
|
|
|
return "新封面上传超时,未取得蝦皮 CDN 地址"
|
2026-07-01 08:35:46 +08:00
|
|
|
|
if reason == "UPLOAD_TILE_NOT_READY":
|
|
|
|
|
|
return "新封面上传入口未可点击,未开始上传新封面"
|
2026-06-30 11:44:27 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
def _wait_ready(cdp, timeout=60):
|
|
|
|
|
|
end = time.time() + timeout
|
2026-07-14 16:31:35 +08:00
|
|
|
|
last_state = {}
|
|
|
|
|
|
first_probe = True
|
|
|
|
|
|
while first_probe or time.time() < end:
|
|
|
|
|
|
first_probe = False
|
2026-06-26 18:02:57 +08:00
|
|
|
|
try:
|
2026-07-14 16:31:35 +08:00
|
|
|
|
last_state = _ready_state(cdp)
|
|
|
|
|
|
if last_state.get("ready"):
|
|
|
|
|
|
cdp.val(JS_SCROLL_MAIN_IMAGE_MANAGER)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-07-02 21:46:32 +08:00
|
|
|
|
invalid_text = _product_unavailable_toast_text(read_page_toasts(cdp))
|
|
|
|
|
|
if invalid_text:
|
|
|
|
|
|
raise EditorError(product_unavailable_error_message(invalid_text))
|
2026-07-14 16:31:35 +08:00
|
|
|
|
if time.time() >= end:
|
|
|
|
|
|
break
|
2026-06-26 18:02:57 +08:00
|
|
|
|
time.sleep(1)
|
2026-07-14 16:31:35 +08:00
|
|
|
|
fallback = _ready_error_message("等待蝦皮商品编辑器就绪超时", last_state)
|
|
|
|
|
|
raise EditorError(
|
|
|
|
|
|
_open_product_ready_error(
|
|
|
|
|
|
cdp,
|
|
|
|
|
|
fallback,
|
|
|
|
|
|
current_url=last_state.get("current_url") if last_state else "",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_page_domains(cdp):
|
|
|
|
|
|
for domain in ("Page", "Runtime", "DOM", "Network"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
cdp.send(f"{domain}.enable")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 09:42:41 +08:00
|
|
|
|
def _current_url(cdp):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return cdp.val("location.href") or ""
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 12:08:12 +08:00
|
|
|
|
def _wait_for_login_probe(cdp, timeout=8, initial_url=""):
|
|
|
|
|
|
deadline = time.monotonic() + max(0.0, float(timeout))
|
|
|
|
|
|
last_url = str(initial_url or "")
|
2026-06-27 09:42:41 +08:00
|
|
|
|
cookie_names = set()
|
2026-07-14 12:08:12 +08:00
|
|
|
|
cookie_read_succeeded = False
|
|
|
|
|
|
probe_error = None
|
|
|
|
|
|
first_probe = True
|
|
|
|
|
|
while first_probe or time.monotonic() < deadline:
|
|
|
|
|
|
first_probe = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
last_url = cdp.val("location.href") or last_url
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"url": last_url,
|
|
|
|
|
|
"cookie_names": cookie_names,
|
|
|
|
|
|
"cookie_read_succeeded": cookie_read_succeeded,
|
|
|
|
|
|
"target_available": False,
|
|
|
|
|
|
"probe_error": exc.__class__.__name__,
|
|
|
|
|
|
}
|
|
|
|
|
|
if _is_login_url(last_url):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"url": last_url,
|
|
|
|
|
|
"cookie_names": cookie_names,
|
|
|
|
|
|
"cookie_read_succeeded": cookie_read_succeeded,
|
|
|
|
|
|
"target_available": True,
|
|
|
|
|
|
"probe_error": probe_error,
|
|
|
|
|
|
}
|
2026-06-27 09:42:41 +08:00
|
|
|
|
try:
|
|
|
|
|
|
cookies = cdp.send("Network.getAllCookies").get("cookies", [])
|
2026-07-14 12:08:12 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"url": last_url,
|
|
|
|
|
|
"cookie_names": cookie_names,
|
|
|
|
|
|
"cookie_read_succeeded": cookie_read_succeeded,
|
|
|
|
|
|
"target_available": False,
|
|
|
|
|
|
"probe_error": exc.__class__.__name__,
|
2026-06-27 09:42:41 +08:00
|
|
|
|
}
|
2026-07-14 12:08:12 +08:00
|
|
|
|
cookie_read_succeeded = True
|
|
|
|
|
|
cookie_names = {
|
|
|
|
|
|
cookie.get("name")
|
|
|
|
|
|
for cookie in cookies
|
|
|
|
|
|
if "shopee" in (cookie.get("domain") or "")
|
|
|
|
|
|
}
|
|
|
|
|
|
if "SPC_ST" in cookie_names or "SPC_U" in cookie_names:
|
2026-06-27 09:42:41 +08:00
|
|
|
|
break
|
2026-07-14 12:08:12 +08:00
|
|
|
|
remaining = deadline - time.monotonic()
|
|
|
|
|
|
if remaining <= 0:
|
|
|
|
|
|
break
|
|
|
|
|
|
time.sleep(min(0.5, remaining))
|
|
|
|
|
|
return {
|
|
|
|
|
|
"url": last_url,
|
|
|
|
|
|
"cookie_names": cookie_names,
|
|
|
|
|
|
"cookie_read_succeeded": cookie_read_succeeded,
|
|
|
|
|
|
"target_available": cookie_read_succeeded,
|
|
|
|
|
|
"probe_error": probe_error,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _login_target_priority(target):
|
|
|
|
|
|
url = str((target or {}).get("url") or "").lower()
|
|
|
|
|
|
if _is_login_url(url):
|
|
|
|
|
|
return 3
|
|
|
|
|
|
if "/portal/product/" in url:
|
|
|
|
|
|
return 2
|
|
|
|
|
|
if "/portal/" in url:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _login_target_key(target):
|
|
|
|
|
|
target = target or {}
|
|
|
|
|
|
return str(target.get("webSocketDebuggerUrl") or target.get("id") or target.get("url") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _shopee_page_targets(host):
|
|
|
|
|
|
pages = [
|
|
|
|
|
|
target
|
|
|
|
|
|
for target in http_get("/json", host=host)
|
|
|
|
|
|
if target.get("type") == "page"
|
|
|
|
|
|
and "shopee" in str(target.get("url") or "").lower()
|
|
|
|
|
|
and target.get("webSocketDebuggerUrl")
|
|
|
|
|
|
]
|
|
|
|
|
|
return sorted(pages, key=_login_target_priority)
|
2026-06-27 09:42:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def login_status(account, timeout=8) -> dict:
|
|
|
|
|
|
"""Return detailed Shopee login status for an account's CDP session."""
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
host = _cdp_host(account)
|
2026-07-14 12:08:12 +08:00
|
|
|
|
deadline = time.monotonic() + max(0.0, float(timeout))
|
|
|
|
|
|
attempted_targets = set()
|
|
|
|
|
|
opened_home = False
|
|
|
|
|
|
probe_attempts = 0
|
|
|
|
|
|
last_probe = {
|
|
|
|
|
|
"url": "",
|
|
|
|
|
|
"cookie_names": set(),
|
|
|
|
|
|
"cookie_read_succeeded": False,
|
|
|
|
|
|
"target_available": False,
|
|
|
|
|
|
"probe_error": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
while True:
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
target
|
|
|
|
|
|
for target in _shopee_page_targets(host)
|
|
|
|
|
|
if _login_target_key(target) not in attempted_targets
|
|
|
|
|
|
]
|
|
|
|
|
|
if not candidates and not opened_home:
|
|
|
|
|
|
home_url = _seller_home_url(account)
|
|
|
|
|
|
ws = create_tab(home_url, host=host)
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "page",
|
|
|
|
|
|
"url": home_url,
|
|
|
|
|
|
"webSocketDebuggerUrl": ws,
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
opened_home = True
|
|
|
|
|
|
if not candidates:
|
|
|
|
|
|
break
|
|
|
|
|
|
for target in candidates:
|
|
|
|
|
|
key = _login_target_key(target)
|
|
|
|
|
|
if key:
|
|
|
|
|
|
attempted_targets.add(key)
|
|
|
|
|
|
probe_attempts += 1
|
|
|
|
|
|
initial_url = str(target.get("url") or "")
|
|
|
|
|
|
if _is_login_url(initial_url):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"logged_in": False,
|
|
|
|
|
|
"reason": "LOGIN_PAGE",
|
|
|
|
|
|
"url": initial_url,
|
|
|
|
|
|
"host": host,
|
|
|
|
|
|
"cookie_names": [],
|
|
|
|
|
|
"cookie_read_succeeded": False,
|
|
|
|
|
|
"probe_error": None,
|
|
|
|
|
|
"probe_attempts": probe_attempts,
|
|
|
|
|
|
}
|
|
|
|
|
|
cdp = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
cdp = CDP(target["webSocketDebuggerUrl"])
|
|
|
|
|
|
_ensure_page_domains(cdp)
|
|
|
|
|
|
remaining = max(0.0, deadline - time.monotonic())
|
|
|
|
|
|
probe = _wait_for_login_probe(
|
|
|
|
|
|
cdp,
|
|
|
|
|
|
timeout=remaining,
|
|
|
|
|
|
initial_url=initial_url,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
probe = {
|
|
|
|
|
|
"url": initial_url,
|
|
|
|
|
|
"cookie_names": set(),
|
|
|
|
|
|
"cookie_read_succeeded": False,
|
|
|
|
|
|
"target_available": False,
|
|
|
|
|
|
"probe_error": exc.__class__.__name__,
|
|
|
|
|
|
}
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if cdp is not None:
|
|
|
|
|
|
cdp.close()
|
|
|
|
|
|
last_probe = probe
|
|
|
|
|
|
url = str(probe.get("url") or initial_url)
|
|
|
|
|
|
names = set(probe.get("cookie_names") or [])
|
|
|
|
|
|
if _is_login_url(url):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"logged_in": False,
|
|
|
|
|
|
"reason": "LOGIN_PAGE",
|
|
|
|
|
|
"url": url,
|
|
|
|
|
|
"host": host,
|
|
|
|
|
|
"cookie_names": sorted(name for name in names if name),
|
|
|
|
|
|
"cookie_read_succeeded": bool(probe.get("cookie_read_succeeded")),
|
|
|
|
|
|
"probe_error": probe.get("probe_error"),
|
|
|
|
|
|
"probe_attempts": probe_attempts,
|
|
|
|
|
|
}
|
|
|
|
|
|
if not probe.get("cookie_read_succeeded") or not probe.get(
|
|
|
|
|
|
"target_available"
|
|
|
|
|
|
):
|
|
|
|
|
|
continue
|
|
|
|
|
|
logged_in = "SPC_ST" in names or "SPC_U" in names
|
2026-06-27 09:42:41 +08:00
|
|
|
|
return {
|
2026-07-14 12:08:12 +08:00
|
|
|
|
"logged_in": logged_in,
|
|
|
|
|
|
"reason": None if logged_in else "NO_SESSION_COOKIE",
|
|
|
|
|
|
"url": url,
|
2026-06-27 09:42:41 +08:00
|
|
|
|
"host": host,
|
2026-07-14 12:08:12 +08:00
|
|
|
|
"cookie_names": sorted(name for name in names if name),
|
|
|
|
|
|
"cookie_read_succeeded": True,
|
|
|
|
|
|
"probe_error": probe.get("probe_error"),
|
|
|
|
|
|
"probe_attempts": probe_attempts,
|
2026-06-27 09:42:41 +08:00
|
|
|
|
}
|
2026-07-14 12:08:12 +08:00
|
|
|
|
if time.monotonic() >= deadline:
|
|
|
|
|
|
break
|
|
|
|
|
|
return {
|
|
|
|
|
|
"logged_in": False,
|
|
|
|
|
|
"reason": "LOGIN_CHECK_TARGET_UNAVAILABLE",
|
|
|
|
|
|
"url": str(last_probe.get("url") or ""),
|
|
|
|
|
|
"host": host,
|
|
|
|
|
|
"cookie_names": sorted(
|
|
|
|
|
|
name for name in (last_probe.get("cookie_names") or []) if name
|
|
|
|
|
|
),
|
|
|
|
|
|
"cookie_read_succeeded": False,
|
|
|
|
|
|
"probe_error": last_probe.get("probe_error"),
|
|
|
|
|
|
"probe_attempts": probe_attempts,
|
|
|
|
|
|
}
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 09:42:41 +08:00
|
|
|
|
def is_logged_in(account) -> bool:
|
|
|
|
|
|
"""Return whether the account's current Shopee session appears logged in."""
|
|
|
|
|
|
|
|
|
|
|
|
return bool(login_status(account).get("logged_in"))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 12:08:12 +08:00
|
|
|
|
def _close_open_product_failure(cdp, confirm_target_closed=False):
|
2026-07-02 21:46:32 +08:00
|
|
|
|
target_id = getattr(cdp, "target_id", None)
|
|
|
|
|
|
created_by_app = bool(getattr(cdp, "created_by_app", False))
|
|
|
|
|
|
host = getattr(cdp, "cdp_host", None)
|
|
|
|
|
|
try:
|
|
|
|
|
|
cdp.close()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if created_by_app and target_id:
|
|
|
|
|
|
try:
|
2026-07-14 12:08:12 +08:00
|
|
|
|
if confirm_target_closed:
|
|
|
|
|
|
close_tab_and_wait(target_id, host=host, timeout=2.0)
|
|
|
|
|
|
else:
|
|
|
|
|
|
close_tab(target_id, host=host)
|
2026-07-02 21:46:32 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 18:03:59 +08:00
|
|
|
|
def open_product(account, item_id, on_step=None, bring_to_front=True) -> CDP:
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"""Open or reuse a product edit tab, navigate to a clean edit URL, and wait ready."""
|
|
|
|
|
|
|
2026-06-29 17:49:45 +08:00
|
|
|
|
_notify_collect_step(on_step, "open_product")
|
2026-06-26 18:02:57 +08:00
|
|
|
|
host = _cdp_host(account)
|
|
|
|
|
|
item_id = str(item_id)
|
|
|
|
|
|
url = _product_url(account, item_id)
|
|
|
|
|
|
tab = find_product_tab(item_id, host=host)
|
2026-06-27 16:39:12 +08:00
|
|
|
|
created_by_app = tab is None
|
|
|
|
|
|
if tab is None:
|
2026-07-08 18:03:59 +08:00
|
|
|
|
tab = create_tab_info(url, host=host, background=not bring_to_front)
|
2026-06-27 16:39:12 +08:00
|
|
|
|
ws = tab["webSocketDebuggerUrl"]
|
2026-06-26 18:02:57 +08:00
|
|
|
|
cdp = CDP(ws)
|
2026-06-27 16:39:12 +08:00
|
|
|
|
cdp.target_id = tab.get("id")
|
|
|
|
|
|
cdp.created_by_app = created_by_app
|
|
|
|
|
|
cdp.cdp_host = host
|
2026-06-26 18:02:57 +08:00
|
|
|
|
try:
|
2026-07-02 21:46:32 +08:00
|
|
|
|
_ensure_page_domains(cdp)
|
|
|
|
|
|
install_toast_observer(cdp)
|
2026-07-08 18:03:59 +08:00
|
|
|
|
if bring_to_front:
|
|
|
|
|
|
try:
|
|
|
|
|
|
cdp.send("Page.bringToFront")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-07-02 21:46:32 +08:00
|
|
|
|
cdp.send("Page.navigate", {"url": url})
|
|
|
|
|
|
install_toast_observer(cdp)
|
|
|
|
|
|
_notify_collect_step(on_step, "wait_ready")
|
|
|
|
|
|
_wait_ready(cdp)
|
|
|
|
|
|
return cdp
|
2026-06-26 18:02:57 +08:00
|
|
|
|
except Exception:
|
2026-07-14 12:08:12 +08:00
|
|
|
|
_close_open_product_failure(cdp, confirm_target_closed=not bring_to_front)
|
2026-07-02 21:46:32 +08:00
|
|
|
|
raise
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_title(cdp) -> str:
|
|
|
|
|
|
"""Read the current Shopee title input value."""
|
|
|
|
|
|
|
|
|
|
|
|
state = _title_state(cdp)
|
|
|
|
|
|
return state.get("value") or state.get("modelvalue") or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_cover_src(cdp) -> str:
|
|
|
|
|
|
"""Read the first product image URL, which is the current cover."""
|
|
|
|
|
|
|
|
|
|
|
|
return cdp.val(JS_FIRST_COVER) or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 12:24:22 +08:00
|
|
|
|
def read_product_image_urls(cdp):
|
|
|
|
|
|
"""Read all current Shopee product image URLs in page order."""
|
|
|
|
|
|
|
|
|
|
|
|
images = []
|
|
|
|
|
|
for position, rect in enumerate(_image_rects(cdp), start=1):
|
|
|
|
|
|
src = str((rect or {}).get("src") or "").strip()
|
|
|
|
|
|
if not src:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
index = int((rect or {}).get("i", position - 1)) + 1
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
index = position
|
|
|
|
|
|
images.append({"index": index, "src": src})
|
|
|
|
|
|
return images
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
def download_cover(src, out_path) -> str:
|
|
|
|
|
|
"""Download a cover image to a local path and return the absolute path."""
|
|
|
|
|
|
|
|
|
|
|
|
if not src:
|
|
|
|
|
|
raise EditorError("旧封面链接为空,无法下载")
|
|
|
|
|
|
import requests
|
|
|
|
|
|
|
|
|
|
|
|
out_path = os.path.abspath(str(out_path))
|
|
|
|
|
|
root, ext = os.path.splitext(out_path)
|
|
|
|
|
|
if not ext:
|
|
|
|
|
|
path = urlparse(src).path
|
|
|
|
|
|
_, guessed = os.path.splitext(path)
|
|
|
|
|
|
out_path = root + (guessed if guessed and len(guessed) <= 5 else ".jpg")
|
|
|
|
|
|
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
|
|
|
|
session = requests.Session()
|
|
|
|
|
|
session.trust_env = False
|
|
|
|
|
|
resp = session.get(src, timeout=30)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
with open(out_path, "wb") as fh:
|
|
|
|
|
|
fh.write(resp.content)
|
|
|
|
|
|
return out_path
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 17:49:45 +08:00
|
|
|
|
def collect(account, task, on_step=None) -> dict:
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"""Collect current title and cover snapshot before any edits."""
|
|
|
|
|
|
|
|
|
|
|
|
item_id = _item_id(task)
|
2026-07-08 18:03:59 +08:00
|
|
|
|
cdp = open_product(account, item_id, on_step=on_step, bring_to_front=False)
|
2026-07-14 12:08:12 +08:00
|
|
|
|
result = None
|
2026-06-26 18:02:57 +08:00
|
|
|
|
try:
|
2026-07-18 16:34:37 +08:00
|
|
|
|
_notify_collect_step(on_step, "read_product_status")
|
|
|
|
|
|
status_snapshot = read_product_status(cdp)
|
2026-07-18 16:46:59 +08:00
|
|
|
|
collect_scope = product_status.normalize_collect_scope(
|
|
|
|
|
|
_get(task, "collection_scope")
|
|
|
|
|
|
)
|
|
|
|
|
|
if not product_status.should_collect_content(
|
|
|
|
|
|
status_snapshot.get("product_status"),
|
|
|
|
|
|
collect_scope,
|
|
|
|
|
|
):
|
|
|
|
|
|
result = {
|
|
|
|
|
|
**status_snapshot,
|
|
|
|
|
|
"collection_skipped": True,
|
|
|
|
|
|
"collection_skip_reason": product_status.collect_skip_reason(
|
|
|
|
|
|
status_snapshot.get("product_status")
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
else:
|
|
|
|
|
|
_notify_collect_step(on_step, "read_title")
|
|
|
|
|
|
old_title = read_title(cdp)
|
|
|
|
|
|
_notify_collect_step(on_step, "read_cover")
|
|
|
|
|
|
old_cover_src = read_cover_src(cdp)
|
|
|
|
|
|
out_path = _get(task, "old_cover_path")
|
|
|
|
|
|
if not out_path:
|
|
|
|
|
|
out_path = image_paths.task_image_path(
|
|
|
|
|
|
appconfig.image_dir(),
|
|
|
|
|
|
task,
|
|
|
|
|
|
account,
|
|
|
|
|
|
"old",
|
|
|
|
|
|
)
|
|
|
|
|
|
_notify_collect_step(on_step, "download_cover")
|
|
|
|
|
|
old_cover_path = download_cover(old_cover_src, out_path)
|
|
|
|
|
|
result = {
|
|
|
|
|
|
"old_title": old_title,
|
|
|
|
|
|
"old_cover_src": old_cover_src,
|
|
|
|
|
|
"old_cover_path": old_cover_path,
|
|
|
|
|
|
**status_snapshot,
|
|
|
|
|
|
}
|
2026-06-26 18:02:57 +08:00
|
|
|
|
finally:
|
2026-07-14 12:08:12 +08:00
|
|
|
|
close_target_confirmed = _close_collected_product(cdp)
|
|
|
|
|
|
result["close_target_confirmed"] = close_target_confirmed
|
|
|
|
|
|
return result
|
2026-06-27 16:39:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 18:09:07 +08:00
|
|
|
|
def recheck_product_status(account, task, on_step=None) -> dict:
|
|
|
|
|
|
"""Read one product status snapshot without changing collected content."""
|
|
|
|
|
|
|
|
|
|
|
|
item_id = _item_id(task)
|
|
|
|
|
|
cdp = open_product(account, item_id, on_step=on_step, bring_to_front=False)
|
|
|
|
|
|
result = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
_notify_collect_step(on_step, "read_product_status")
|
|
|
|
|
|
result = read_product_status(cdp)
|
|
|
|
|
|
if result.get("product_status_error"):
|
|
|
|
|
|
raise EditorError(
|
|
|
|
|
|
"商品状态读取失败:{error}".format(
|
|
|
|
|
|
error=result["product_status_error"]
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
close_target_confirmed = _close_collected_product(cdp)
|
|
|
|
|
|
result["close_target_confirmed"] = close_target_confirmed
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 16:34:37 +08:00
|
|
|
|
def read_product_status(cdp) -> dict:
|
|
|
|
|
|
"""Read the current product's warning state without retaining page HTML."""
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
alerts = _json_value(cdp, JS_PRODUCT_STATUS_ALERTS, default=None)
|
|
|
|
|
|
if not isinstance(alerts, list):
|
|
|
|
|
|
raise ValueError("商品状态提示读取结果无效")
|
|
|
|
|
|
snapshot = product_status.classify_alerts(alerts)
|
|
|
|
|
|
snapshot["product_status_error"] = None
|
|
|
|
|
|
return snapshot
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
snapshot = product_status.classify_alerts(None)
|
|
|
|
|
|
snapshot["product_status_error"] = str(exc) or exc.__class__.__name__
|
|
|
|
|
|
return snapshot
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 17:49:45 +08:00
|
|
|
|
|
|
|
|
|
|
def _notify_collect_step(callback, step):
|
|
|
|
|
|
if callback is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
callback(step)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-07-01 15:32:27 +08:00
|
|
|
|
|
|
|
|
|
|
def _notify_apply_step(callback, step, result="start", detail=None):
|
|
|
|
|
|
if callback is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
payload = {"step": step, "result": result}
|
|
|
|
|
|
if detail:
|
|
|
|
|
|
payload["detail"] = str(detail)
|
|
|
|
|
|
try:
|
|
|
|
|
|
callback(payload)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 16:39:12 +08:00
|
|
|
|
def _close_collected_product(cdp):
|
|
|
|
|
|
target_id = getattr(cdp, "target_id", None)
|
|
|
|
|
|
created_by_app = bool(getattr(cdp, "created_by_app", False))
|
|
|
|
|
|
host = getattr(cdp, "cdp_host", None)
|
2026-07-14 12:08:12 +08:00
|
|
|
|
close_target_confirmed = None
|
2026-06-27 16:39:12 +08:00
|
|
|
|
try:
|
2026-06-26 18:02:57 +08:00
|
|
|
|
cdp.close()
|
2026-06-27 16:39:12 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
if created_by_app and target_id:
|
|
|
|
|
|
try:
|
2026-07-14 12:08:12 +08:00
|
|
|
|
close_target_confirmed = close_tab_and_wait(
|
|
|
|
|
|
target_id,
|
|
|
|
|
|
host=host,
|
|
|
|
|
|
timeout=2.0,
|
|
|
|
|
|
)
|
2026-06-27 16:39:12 +08:00
|
|
|
|
except Exception:
|
2026-07-14 12:08:12 +08:00
|
|
|
|
close_target_confirmed = False
|
|
|
|
|
|
return close_target_confirmed
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 12:24:22 +08:00
|
|
|
|
def close_readonly_product(cdp):
|
|
|
|
|
|
"""Close a read-only product CDP session using the collection tab cleanup rules."""
|
|
|
|
|
|
|
2026-07-14 12:08:12 +08:00
|
|
|
|
return _close_collected_product(cdp)
|
2026-07-11 12:24:22 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 18:02:57 +08:00
|
|
|
|
def change_title(cdp, new_title) -> dict:
|
|
|
|
|
|
"""Write a new title with the verified native setter + input/change events."""
|
|
|
|
|
|
|
|
|
|
|
|
expr = (
|
|
|
|
|
|
"(function(){"
|
2026-07-14 16:31:35 +08:00
|
|
|
|
+ JS_EDITOR_FIELD_HELPERS
|
|
|
|
|
|
+ "var title=cmTitleCandidate();var el=title.node;"
|
|
|
|
|
|
"if(!el)return title.count>1?'AMBIGUOUS_INPUT':'NO_INPUT';"
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"var s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;"
|
|
|
|
|
|
f"el.focus();s.call(el,{json.dumps(str(new_title))});"
|
|
|
|
|
|
"el.dispatchEvent(new Event('input',{bubbles:true}));"
|
|
|
|
|
|
"el.dispatchEvent(new Event('change',{bubbles:true}));el.blur();return el.value;})()"
|
|
|
|
|
|
)
|
|
|
|
|
|
written = cdp.val(expr)
|
|
|
|
|
|
time.sleep(0.4)
|
|
|
|
|
|
state = _title_state(cdp)
|
|
|
|
|
|
value = state.get("value")
|
|
|
|
|
|
modelvalue = state.get("modelvalue")
|
|
|
|
|
|
ok = value == str(new_title) and modelvalue == str(new_title)
|
2026-07-14 16:31:35 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"ok": ok,
|
|
|
|
|
|
"written": written,
|
|
|
|
|
|
"value": value,
|
|
|
|
|
|
"modelvalue": modelvalue,
|
|
|
|
|
|
"candidate_count": state.get("candidate_count"),
|
|
|
|
|
|
"source": state.get("source"),
|
|
|
|
|
|
}
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 11:44:27 +08:00
|
|
|
|
def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict:
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"""Upload an image and drag it to the first position.
|
|
|
|
|
|
|
2026-07-01 08:35:46 +08:00
|
|
|
|
Cover replacement always deletes the current first Shopee image first, and
|
|
|
|
|
|
only proceeds when the old cover backup from the collect stage exists.
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
image_win_path = os.path.abspath(str(image_win_path))
|
|
|
|
|
|
if not os.path.exists(image_win_path):
|
|
|
|
|
|
raise FileNotFoundError(f"封面图片不存在: {image_win_path}")
|
|
|
|
|
|
|
2026-07-14 16:31:35 +08:00
|
|
|
|
cdp.val(JS_SCROLL_MAIN_IMAGE_MANAGER)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
time.sleep(0.5)
|
2026-06-30 11:44:27 +08:00
|
|
|
|
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)
|
2026-06-29 09:49:53 +08:00
|
|
|
|
count_before = len(before)
|
2026-07-01 08:35:46 +08:00
|
|
|
|
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)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
before_srcs = {r.get("src") for r in before}
|
2026-07-09 15:04:20 +08:00
|
|
|
|
before_src_list = _src_snapshot(before_srcs)
|
2026-07-01 08:35:46 +08:00
|
|
|
|
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)
|
2026-07-14 16:31:35 +08:00
|
|
|
|
oid = cdp.object_id(JS_MAIN_UPLOAD_INPUT)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
if not oid:
|
2026-06-30 11:44:27 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": "NO_UPLOAD_INPUT",
|
|
|
|
|
|
"count_before": count_before,
|
|
|
|
|
|
"delete": delete_result,
|
2026-07-01 08:35:46 +08:00
|
|
|
|
"upload_click": upload_click,
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"upload_state": _upload_state(cdp),
|
|
|
|
|
|
}
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [image_win_path]})
|
2026-07-14 16:31:35 +08:00
|
|
|
|
changed = cdp.val(JS_DISPATCH_MAIN_UPLOAD_INPUT_EVENTS)
|
2026-06-30 11:44:27 +08:00
|
|
|
|
time.sleep(2)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
new_src = None
|
2026-06-30 11:44:27 +08:00
|
|
|
|
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,
|
2026-07-01 08:35:46 +08:00
|
|
|
|
"upload_click": upload_click,
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"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
|
2026-06-26 18:02:57 +08:00
|
|
|
|
end = time.time() + timeout
|
|
|
|
|
|
while time.time() < end:
|
|
|
|
|
|
cur = _image_rects(cdp)
|
2026-06-30 11:44:27 +08:00
|
|
|
|
last_state = _upload_state(cdp)
|
|
|
|
|
|
blob_seen = blob_seen or any(str(r.get("src") or "").startswith("blob:") for r in cur)
|
2026-07-01 08:35:46 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-06-26 18:02:57 +08:00
|
|
|
|
ready = [
|
|
|
|
|
|
r for r in cur
|
|
|
|
|
|
if r.get("src") not in before_srcs
|
|
|
|
|
|
and r.get("src")
|
|
|
|
|
|
and "susercontent" in r.get("src")
|
|
|
|
|
|
and "blob:" not in r.get("src")
|
|
|
|
|
|
]
|
2026-06-30 11:44:27 +08:00
|
|
|
|
if ready and (len(cur) > len(before) or len(ready) == 1):
|
2026-06-26 18:02:57 +08:00
|
|
|
|
new_src = ready[-1]["src"]
|
|
|
|
|
|
break
|
2026-06-30 11:44:27 +08:00
|
|
|
|
if last_state.get("crop_modal"):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": "UPLOAD_CROP_REQUIRED",
|
|
|
|
|
|
"count_before": count_before,
|
|
|
|
|
|
"count_after": len(cur),
|
|
|
|
|
|
"delete": delete_result,
|
2026-07-01 08:35:46 +08:00
|
|
|
|
"upload_click": upload_click,
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"upload_state": last_state,
|
|
|
|
|
|
"file_size": file_size,
|
|
|
|
|
|
}
|
|
|
|
|
|
time.sleep(1.5)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
if not new_src:
|
2026-06-30 11:44:27 +08:00
|
|
|
|
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,
|
2026-07-01 08:35:46 +08:00
|
|
|
|
"upload_click": upload_click,
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"upload_state": last_state,
|
|
|
|
|
|
"blob_seen": blob_seen,
|
|
|
|
|
|
"file_size": file_size,
|
2026-07-09 15:04:20 +08:00
|
|
|
|
"before_srcs": before_src_list,
|
2026-06-30 11:44:27 +08:00
|
|
|
|
}
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
cur = _image_rects(cdp)
|
|
|
|
|
|
new_rect = next((r for r in cur if r.get("src") == new_src), None)
|
|
|
|
|
|
if not new_rect:
|
2026-07-09 15:04:20 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": "NEW_IMAGE_NOT_FOUND",
|
|
|
|
|
|
"new_src": new_src,
|
|
|
|
|
|
"delete": delete_result,
|
|
|
|
|
|
"count_after": len(cur),
|
|
|
|
|
|
}
|
2026-06-26 18:02:57 +08:00
|
|
|
|
first = cur[0]
|
|
|
|
|
|
cdp.drag(new_rect["x"], new_rect["y"], first["left"] - first["w"] * 0.30, first["y"])
|
|
|
|
|
|
time.sleep(1.2)
|
|
|
|
|
|
|
|
|
|
|
|
after = _image_rects(cdp)
|
|
|
|
|
|
cover_ok = bool(after and after[0].get("src") == new_src)
|
|
|
|
|
|
index = next((r["i"] for r in after if r.get("src") == new_src), None)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": cover_ok,
|
|
|
|
|
|
"reason": None if cover_ok else "DRAG_NOT_FIRST",
|
|
|
|
|
|
"new_src": new_src,
|
|
|
|
|
|
"index": index,
|
2026-06-29 09:49:53 +08:00
|
|
|
|
"count_before": count_before,
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"count_after": len(after),
|
2026-06-29 09:49:53 +08:00
|
|
|
|
"delete": delete_result,
|
2026-07-01 08:35:46 +08:00
|
|
|
|
"upload_click": upload_click,
|
2026-06-29 09:49:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 15:04:20 +08:00
|
|
|
|
def _src_snapshot(values):
|
|
|
|
|
|
return sorted(str(value) for value in values if value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _recover_cover_after_foreground(cdp, cover_result, timeout=COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS):
|
|
|
|
|
|
reason = str((cover_result or {}).get("reason") or "")
|
|
|
|
|
|
if reason in COVER_FOREGROUND_UPLOAD_RECOVERY_REASONS:
|
|
|
|
|
|
return _recover_cover_upload_after_foreground(cdp, cover_result, timeout=timeout)
|
|
|
|
|
|
if reason in COVER_FOREGROUND_DRAG_RECOVERY_REASONS:
|
|
|
|
|
|
return _recover_cover_drag_after_foreground(cdp, cover_result, timeout=timeout)
|
|
|
|
|
|
recovered = dict(cover_result or {})
|
|
|
|
|
|
recovered["foreground_recovery"] = {"attempted": False, "reason": "NOT_RECOVERABLE"}
|
|
|
|
|
|
return recovered
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _recover_cover_upload_after_foreground(cdp, cover_result, timeout=COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS):
|
|
|
|
|
|
before_srcs = set(str(src) for src in (cover_result or {}).get("before_srcs") or [] if src)
|
|
|
|
|
|
if not before_srcs:
|
|
|
|
|
|
recovered = dict(cover_result or {})
|
|
|
|
|
|
recovered["foreground_recovery"] = {
|
|
|
|
|
|
"attempted": True,
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": "MISSING_BEFORE_SRCS",
|
|
|
|
|
|
}
|
|
|
|
|
|
return recovered
|
|
|
|
|
|
|
|
|
|
|
|
original_reason = str((cover_result or {}).get("reason") or "UPLOAD_TIMEOUT")
|
|
|
|
|
|
end = time.time() + max(0.0, float(timeout))
|
|
|
|
|
|
last_state = (cover_result or {}).get("upload_state") or {}
|
|
|
|
|
|
blob_seen = bool((cover_result or {}).get("blob_seen"))
|
|
|
|
|
|
last_rects = []
|
|
|
|
|
|
while True:
|
|
|
|
|
|
cur = _image_rects(cdp)
|
|
|
|
|
|
last_rects = cur
|
|
|
|
|
|
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,
|
|
|
|
|
|
"original_reason": original_reason,
|
|
|
|
|
|
"foreground_recovery": {"attempted": True, "ok": False, "reason": reason},
|
|
|
|
|
|
"count_after": len(cur),
|
|
|
|
|
|
"upload_state": last_state,
|
|
|
|
|
|
"before_srcs": _src_snapshot(before_srcs),
|
|
|
|
|
|
"blob_seen": blob_seen,
|
|
|
|
|
|
}
|
|
|
|
|
|
ready = [
|
|
|
|
|
|
r for r in cur
|
|
|
|
|
|
if r.get("src") not in before_srcs
|
|
|
|
|
|
and r.get("src")
|
|
|
|
|
|
and "susercontent" in r.get("src")
|
|
|
|
|
|
and "blob:" not in r.get("src")
|
|
|
|
|
|
]
|
|
|
|
|
|
if ready:
|
|
|
|
|
|
return _drag_existing_cover_to_first(
|
|
|
|
|
|
cdp,
|
|
|
|
|
|
ready[-1]["src"],
|
|
|
|
|
|
original_reason=original_reason,
|
|
|
|
|
|
foreground_recovery_reason="UPLOAD_READY_AFTER_FOREGROUND",
|
|
|
|
|
|
timeout=min(max(0.0, float(timeout)), 30.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
if last_state.get("crop_modal"):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": "UPLOAD_CROP_REQUIRED",
|
|
|
|
|
|
"original_reason": original_reason,
|
|
|
|
|
|
"foreground_recovery": {"attempted": True, "ok": False, "reason": "UPLOAD_CROP_REQUIRED"},
|
|
|
|
|
|
"count_after": len(cur),
|
|
|
|
|
|
"upload_state": last_state,
|
|
|
|
|
|
"before_srcs": _src_snapshot(before_srcs),
|
|
|
|
|
|
"blob_seen": blob_seen,
|
|
|
|
|
|
}
|
|
|
|
|
|
if time.time() >= end:
|
|
|
|
|
|
break
|
|
|
|
|
|
time.sleep(1.5)
|
|
|
|
|
|
|
|
|
|
|
|
reason = "UPLOAD_STILL_PROCESSING" if blob_seen or (last_state or {}).get("busy_count") else "UPLOAD_TIMEOUT"
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": reason,
|
|
|
|
|
|
"original_reason": original_reason,
|
|
|
|
|
|
"foreground_recovery": {"attempted": True, "ok": False, "reason": "RECOVERY_TIMEOUT"},
|
|
|
|
|
|
"count_after": len(last_rects),
|
|
|
|
|
|
"upload_state": last_state,
|
|
|
|
|
|
"before_srcs": _src_snapshot(before_srcs),
|
|
|
|
|
|
"blob_seen": blob_seen,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _recover_cover_drag_after_foreground(cdp, cover_result, timeout=COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS):
|
|
|
|
|
|
new_src = str((cover_result or {}).get("new_src") or "")
|
|
|
|
|
|
original_reason = str((cover_result or {}).get("reason") or "DRAG_NOT_FIRST")
|
|
|
|
|
|
return _drag_existing_cover_to_first(
|
|
|
|
|
|
cdp,
|
|
|
|
|
|
new_src,
|
|
|
|
|
|
original_reason=original_reason,
|
|
|
|
|
|
foreground_recovery_reason="REDRAG_AFTER_FOREGROUND",
|
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _drag_existing_cover_to_first(
|
|
|
|
|
|
cdp,
|
|
|
|
|
|
new_src,
|
|
|
|
|
|
original_reason,
|
|
|
|
|
|
foreground_recovery_reason,
|
|
|
|
|
|
timeout=COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS,
|
|
|
|
|
|
):
|
|
|
|
|
|
if not new_src:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": original_reason,
|
|
|
|
|
|
"original_reason": original_reason,
|
|
|
|
|
|
"foreground_recovery": {
|
|
|
|
|
|
"attempted": True,
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": "NEW_SRC_MISSING",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
end = time.time() + max(0.0, float(timeout))
|
|
|
|
|
|
last_rects = []
|
|
|
|
|
|
while True:
|
|
|
|
|
|
cur = _image_rects(cdp)
|
|
|
|
|
|
last_rects = cur
|
|
|
|
|
|
new_rect = next((r for r in cur if r.get("src") == new_src), None)
|
|
|
|
|
|
if new_rect:
|
|
|
|
|
|
if not cur:
|
|
|
|
|
|
break
|
|
|
|
|
|
first = cur[0]
|
|
|
|
|
|
cdp.drag(new_rect["x"], new_rect["y"], first["left"] - first["w"] * 0.30, first["y"])
|
|
|
|
|
|
time.sleep(1.2)
|
|
|
|
|
|
after = _image_rects(cdp)
|
|
|
|
|
|
cover_ok = bool(after and after[0].get("src") == new_src)
|
|
|
|
|
|
index = next((r["i"] for r in after if r.get("src") == new_src), None)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": cover_ok,
|
|
|
|
|
|
"reason": None if cover_ok else "DRAG_NOT_FIRST",
|
|
|
|
|
|
"original_reason": original_reason,
|
|
|
|
|
|
"foreground_recovery": {
|
|
|
|
|
|
"attempted": True,
|
|
|
|
|
|
"ok": cover_ok,
|
|
|
|
|
|
"reason": foreground_recovery_reason,
|
|
|
|
|
|
},
|
|
|
|
|
|
"new_src": new_src,
|
|
|
|
|
|
"index": index,
|
|
|
|
|
|
"count_after": len(after),
|
|
|
|
|
|
}
|
|
|
|
|
|
if time.time() >= end:
|
|
|
|
|
|
break
|
|
|
|
|
|
time.sleep(1.5)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": "NEW_IMAGE_NOT_FOUND",
|
|
|
|
|
|
"original_reason": original_reason,
|
|
|
|
|
|
"foreground_recovery": {"attempted": True, "ok": False, "reason": "NEW_IMAGE_NOT_FOUND"},
|
|
|
|
|
|
"new_src": new_src,
|
|
|
|
|
|
"count_after": len(last_rects),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 09:49:53 +08:00
|
|
|
|
def _validated_old_cover_backup(old_cover_path):
|
|
|
|
|
|
if not old_cover_path:
|
|
|
|
|
|
return None
|
|
|
|
|
|
path = os.path.abspath(str(old_cover_path))
|
|
|
|
|
|
if not os.path.exists(path):
|
|
|
|
|
|
return None
|
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _delete_first_cover(cdp, before, timeout=15):
|
|
|
|
|
|
count_before = len(before)
|
|
|
|
|
|
if not before:
|
|
|
|
|
|
return {"ok": False, "reason": "NO_IMAGE", "count_before": 0}
|
|
|
|
|
|
first = before[0]
|
|
|
|
|
|
try:
|
|
|
|
|
|
cdp.send(
|
|
|
|
|
|
"Input.dispatchMouseEvent",
|
|
|
|
|
|
{"type": "mouseMoved", "x": first["x"], "y": first["y"]},
|
|
|
|
|
|
)
|
|
|
|
|
|
time.sleep(0.2)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
click_result = _json_value(
|
|
|
|
|
|
cdp,
|
|
|
|
|
|
JS_CLICK_FIRST_DELETE,
|
|
|
|
|
|
default={"clicked": False, "reason": "NO_DELETE_BUTTON"},
|
|
|
|
|
|
) or {}
|
|
|
|
|
|
if not click_result.get("clicked"):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": click_result.get("reason") or "NO_DELETE_BUTTON",
|
|
|
|
|
|
"count_before": count_before,
|
|
|
|
|
|
"click": click_result,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
confirm_result = None
|
|
|
|
|
|
end = time.time() + timeout
|
|
|
|
|
|
while time.time() < end:
|
|
|
|
|
|
cur = _image_rects(cdp)
|
|
|
|
|
|
if len(cur) < count_before:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": True,
|
|
|
|
|
|
"reason": None,
|
|
|
|
|
|
"count_before": count_before,
|
|
|
|
|
|
"count_after": len(cur),
|
|
|
|
|
|
"click": click_result,
|
|
|
|
|
|
"confirm": confirm_result,
|
|
|
|
|
|
}
|
|
|
|
|
|
confirm_attempt = _json_value(
|
|
|
|
|
|
cdp,
|
|
|
|
|
|
JS_CLICK_DELETE_CONFIRM,
|
|
|
|
|
|
default={"clicked": False, "reason": "NO_CONFIRM_BUTTON"},
|
|
|
|
|
|
) or {}
|
|
|
|
|
|
if confirm_attempt.get("clicked"):
|
|
|
|
|
|
confirm_result = confirm_attempt
|
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
|
|
|
|
|
|
cur = _image_rects(cdp)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": "DELETE_TIMEOUT",
|
|
|
|
|
|
"count_before": count_before,
|
|
|
|
|
|
"count_after": len(cur),
|
|
|
|
|
|
"click": click_result,
|
|
|
|
|
|
"confirm": confirm_result,
|
2026-06-26 18:02:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 11:44:27 +08:00
|
|
|
|
def click_update(cdp, confirm_timeout=3, post_timeout=8) -> dict:
|
|
|
|
|
|
"""Click the Shopee update button, confirm Shopee's final modal, and observe submit result."""
|
2026-06-26 18:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
info = _json_value(cdp, JS_FIND_UPDATE, default={"found": False})
|
|
|
|
|
|
if not info.get("found"):
|
|
|
|
|
|
return {"clicked": False, "reason": "NO_UPDATE_BUTTON", "toasts": []}
|
|
|
|
|
|
if info.get("disabled"):
|
|
|
|
|
|
return {"clicked": False, "reason": "UPDATE_DISABLED", "toasts": []}
|
|
|
|
|
|
result = cdp.val(JS_CLICK_UPDATE)
|
2026-06-30 11:44:27 +08:00
|
|
|
|
if result != "CLICKED":
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-06-26 18:02:57 +08:00
|
|
|
|
return {
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"clicked": True,
|
|
|
|
|
|
"reason": None,
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"toasts": toasts,
|
2026-06-30 11:44:27 +08:00
|
|
|
|
"confirm": confirm_result,
|
|
|
|
|
|
"post_update": post_update,
|
2026-06-26 18:02:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 11:44:27 +08:00
|
|
|
|
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"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 17:44:03 +08:00
|
|
|
|
def apply_task(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None) -> dict:
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"""Apply generated title/cover to Shopee.
|
|
|
|
|
|
|
|
|
|
|
|
The caller must perform the batch confirmation before calling this function.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
item_id = _item_id(task)
|
2026-07-01 15:32:27 +08:00
|
|
|
|
current_step = "open_product"
|
|
|
|
|
|
_notify_apply_step(on_step, current_step, "start")
|
2026-07-09 15:04:20 +08:00
|
|
|
|
cdp = open_product(account, item_id, bring_to_front=bring_to_front)
|
2026-07-01 15:32:27 +08:00
|
|
|
|
_notify_apply_step(on_step, current_step, "success")
|
2026-06-29 09:18:03 +08:00
|
|
|
|
committed = False
|
2026-06-26 18:02:57 +08:00
|
|
|
|
try:
|
|
|
|
|
|
title_result = None
|
|
|
|
|
|
cover_result = None
|
2026-07-09 17:44:03 +08:00
|
|
|
|
mode = appconfig.normalize_update_mode(
|
|
|
|
|
|
update_mode,
|
|
|
|
|
|
allow_cover_update=bool(_get(task, "new_cover_path")),
|
|
|
|
|
|
)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
new_title = _get(task, "new_title")
|
|
|
|
|
|
new_cover_path = _get(task, "new_cover_path")
|
2026-07-09 17:44:03 +08:00
|
|
|
|
if not appconfig.update_mode_includes_title(mode):
|
|
|
|
|
|
new_title = None
|
|
|
|
|
|
if not appconfig.update_mode_includes_cover(mode):
|
|
|
|
|
|
new_cover_path = None
|
2026-06-26 18:02:57 +08:00
|
|
|
|
if new_title:
|
2026-07-01 15:32:27 +08:00
|
|
|
|
current_step = "change_title"
|
|
|
|
|
|
_notify_apply_step(on_step, current_step, "start")
|
2026-06-26 18:02:57 +08:00
|
|
|
|
title_result = change_title(cdp, new_title)
|
|
|
|
|
|
if not title_result.get("ok"):
|
2026-07-01 15:32:27 +08:00
|
|
|
|
_notify_apply_step(on_step, current_step, "failed", "标题写入后 value/modelvalue 未同步")
|
2026-06-26 18:02:57 +08:00
|
|
|
|
return {"committed": False, "error": "标题写入后 value/modelvalue 未同步", "title": title_result}
|
2026-07-01 15:32:27 +08:00
|
|
|
|
_notify_apply_step(on_step, current_step, "success")
|
2026-06-26 18:02:57 +08:00
|
|
|
|
if new_cover_path:
|
2026-07-01 15:32:27 +08:00
|
|
|
|
current_step = "replace_cover"
|
|
|
|
|
|
_notify_apply_step(on_step, current_step, "start")
|
2026-06-29 09:49:53 +08:00
|
|
|
|
cover_result = replace_cover(
|
|
|
|
|
|
cdp,
|
|
|
|
|
|
new_cover_path,
|
|
|
|
|
|
old_cover_path=_get(task, "old_cover_path"),
|
|
|
|
|
|
)
|
2026-07-09 15:04:20 +08:00
|
|
|
|
if not cover_result.get("ok"):
|
|
|
|
|
|
recoverable_reasons = (
|
|
|
|
|
|
COVER_FOREGROUND_UPLOAD_RECOVERY_REASONS
|
|
|
|
|
|
| COVER_FOREGROUND_DRAG_RECOVERY_REASONS
|
|
|
|
|
|
)
|
|
|
|
|
|
if not bring_to_front and str(cover_result.get("reason") or "") in recoverable_reasons:
|
|
|
|
|
|
current_step = "cover_retry_foreground"
|
|
|
|
|
|
_notify_apply_step(
|
|
|
|
|
|
on_step,
|
|
|
|
|
|
current_step,
|
|
|
|
|
|
"start",
|
|
|
|
|
|
"后台上传/拖拽疑似受限,已提前台恢复一次",
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
cdp.send("Page.bringToFront")
|
|
|
|
|
|
cover_result = _recover_cover_after_foreground(cdp, cover_result)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
cover_result = dict(cover_result)
|
|
|
|
|
|
cover_result["foreground_recovery"] = {
|
|
|
|
|
|
"attempted": True,
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"reason": str(exc),
|
|
|
|
|
|
}
|
|
|
|
|
|
if cover_result.get("ok"):
|
|
|
|
|
|
_notify_apply_step(on_step, current_step, "success", "前台恢复成功")
|
|
|
|
|
|
current_step = "replace_cover"
|
|
|
|
|
|
_notify_apply_step(on_step, current_step, "success")
|
|
|
|
|
|
else:
|
|
|
|
|
|
detail = _cover_upload_error_message(cover_result)
|
|
|
|
|
|
_notify_apply_step(on_step, current_step, "failed", detail)
|
|
|
|
|
|
current_step = "replace_cover"
|
2026-06-26 18:02:57 +08:00
|
|
|
|
if not cover_result.get("ok"):
|
2026-07-01 15:32:27 +08:00
|
|
|
|
error = _cover_upload_error_message(cover_result)
|
|
|
|
|
|
_notify_apply_step(on_step, current_step, "failed", error)
|
|
|
|
|
|
return {"committed": False, "error": error, "cover": cover_result}
|
2026-07-09 15:04:20 +08:00
|
|
|
|
_notify_apply_step(on_step, "replace_cover", "success")
|
2026-07-01 15:32:27 +08:00
|
|
|
|
current_step = "click_update"
|
|
|
|
|
|
_notify_apply_step(on_step, current_step, "start")
|
2026-06-26 18:02:57 +08:00
|
|
|
|
update_result = click_update(cdp)
|
2026-06-29 09:18:03 +08:00
|
|
|
|
committed = bool(update_result.get("clicked", False))
|
2026-07-01 15:32:27 +08:00
|
|
|
|
_notify_apply_step(
|
|
|
|
|
|
on_step,
|
|
|
|
|
|
current_step,
|
|
|
|
|
|
"success" if committed else "failed",
|
|
|
|
|
|
update_result.get("reason"),
|
|
|
|
|
|
)
|
2026-06-26 18:02:57 +08:00
|
|
|
|
return {
|
2026-06-29 09:18:03 +08:00
|
|
|
|
"committed": committed,
|
2026-06-26 18:02:57 +08:00
|
|
|
|
"error": update_result.get("reason"),
|
|
|
|
|
|
"title": title_result,
|
|
|
|
|
|
"cover": cover_result,
|
|
|
|
|
|
"update": update_result,
|
|
|
|
|
|
}
|
|
|
|
|
|
except Exception as exc:
|
2026-07-01 15:32:27 +08:00
|
|
|
|
_notify_apply_step(on_step, current_step, "failed", str(exc))
|
2026-06-26 18:02:57 +08:00
|
|
|
|
return {"committed": False, "error": str(exc)}
|
|
|
|
|
|
finally:
|
2026-07-10 10:12:15 +08:00
|
|
|
|
_close_applied_product(cdp, committed=committed)
|
2026-06-29 09:18:03 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 10:12:15 +08:00
|
|
|
|
def _close_applied_product(cdp, committed=False):
|
2026-06-29 09:18:03 +08:00
|
|
|
|
target_id = getattr(cdp, "target_id", None)
|
|
|
|
|
|
created_by_app = bool(getattr(cdp, "created_by_app", False))
|
|
|
|
|
|
host = getattr(cdp, "cdp_host", None)
|
2026-07-10 10:12:15 +08:00
|
|
|
|
should_close_tab = bool(created_by_app and target_id)
|
|
|
|
|
|
should_wait_before_close = bool(committed)
|
2026-06-29 09:18:03 +08:00
|
|
|
|
try:
|
2026-06-26 18:02:57 +08:00
|
|
|
|
cdp.close()
|
2026-06-29 09:18:03 +08:00
|
|
|
|
finally:
|
2026-07-09 21:08:42 +08:00
|
|
|
|
if should_close_tab:
|
2026-06-29 09:18:03 +08:00
|
|
|
|
try:
|
2026-07-09 21:08:42 +08:00
|
|
|
|
if should_wait_before_close:
|
|
|
|
|
|
time.sleep(2)
|
2026-06-29 09:18:03 +08:00
|
|
|
|
close_tab(target_id, host=host)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|