Files
cmshoppe/app/editor.py
T

669 lines
23 KiB
Python

"""Shopee editor operations built on the verified CDP primitives."""
import json
import os
import time
from urllib.parse import urlparse
from .cdp import CDP, close_tab, create_tab, create_tab_info, find_product_tab, http_get
DEFAULT_REGION_HOST = "seller.shopee.tw"
TITLE_XPATH = "//input[@class='eds-input__input' and string-length(@modelvalue)>24]"
ITEMBOX_XPATH = (
"//div[@class='container']/div[@class='can-drag shopee-image-manager__itembox' "
"and @data-draggable='true']"
)
LOGIN_PATH_MARKERS = (
"/login",
"account/signin",
"seller/login",
"seller/accounts/signin",
)
JS_READY = (
"(function(){"
f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null,"
"XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
"XPathResult.FIRST_ORDERED_NODE_TYPE,null);"
"var up=document.querySelector('.shopee-image-manager__upload input[type=file]');"
"return (s.snapshotLength>0 && !!r.singleNodeValue && !!up);})()"
)
JS_RECTS = (
"(function(){"
f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null,"
"XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);"
"var a=[];for(var i=0;i<s.snapshotLength;i++){"
"var el=s.snapshotItem(i);var r=el.getBoundingClientRect();var im=el.querySelector('img');"
"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);})()"
)
JS_TITLE_STATE = (
"(function(){"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
"XPathResult.FIRST_ORDERED_NODE_TYPE,null);"
"var el=r.singleNodeValue;if(!el)return null;"
"return JSON.stringify({value:el.value,modelvalue:el.getAttribute('modelvalue')});})()"
)
JS_FIRST_COVER = (
"(function(){"
f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null,"
"XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);"
"if(!s.snapshotLength)return null;"
"var img=s.snapshotItem(0).querySelector('img');return img?img.src:null;})()"
)
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';})()"
)
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));})()"
)
JS_CLICK_FIRST_DELETE = (
"(function(){"
f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null,"
"XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);"
"if(!s.snapshotLength)return JSON.stringify({clicked:false,reason:'NO_IMAGE'});"
"var item=s.snapshotItem(0);"
"['mouseover','mouseenter','mousemove'].forEach(function(n){"
"item.dispatchEvent(new MouseEvent(n,{bubbles:true,view:window}));});"
"var selectors=['.shopee-image-manager__icon--delete','[class*=\"icon--delete\"]','[class*=\"delete\"]'];"
"var btn=null;"
"for(var i=0;i<selectors.length&&!btn;i++){btn=item.querySelector(selectors[i]);}"
"if(!btn){var all=[].slice.call(item.querySelectorAll('*'));"
"btn=all.find(function(e){var t=[e.className,e.innerText,e.title,e.getAttribute('aria-label')].join(' ');"
"return /(delete|删除|刪除)/i.test(t);});}"
"if(!btn)return JSON.stringify({clicked:false,reason:'NO_DELETE_BUTTON'});"
"if(btn.scrollIntoView)btn.scrollIntoView({block:'center'});"
"btn.click();return JSON.stringify({clicked:true,reason:null});})()"
)
JS_CLICK_DELETE_CONFIRM = (
"(function(){"
"function visible(e){var r=e.getBoundingClientRect();var s=getComputedStyle(e);"
"return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}"
"var rootSelectors='[role=dialog],[class*=dialog],[class*=Dialog],[class*=modal],"
"[class*=Modal],[class*=popover],[class*=Popover],[class*=popup],[class*=Popup]';"
"var roots=[].slice.call(document.querySelectorAll(rootSelectors)).filter(visible);"
"var yes=/(删除|刪除|確認|确认|確定|确定|OK|Yes)/i;"
"var no=/(取消|cancel|否|No)/i;"
"var seen=[];var candidates=[];"
"roots.forEach(function(root){[].slice.call(root.querySelectorAll('button,[role=button]')).forEach(function(b){"
"var text=((b.innerText||b.textContent||b.getAttribute('aria-label')||'')+'').trim();"
"if(!text||!visible(b))return;seen.push(text);"
"if(b.disabled||/disabled/i.test(b.className)||no.test(text)||!yes.test(text))return;"
"candidates.push({button:b,text:text,score:/(删除|刪除)/i.test(text)?0:1});});});"
"if(!candidates.length)return JSON.stringify({clicked:false,reason:'NO_CONFIRM_BUTTON',buttons:seen.slice(0,8)});"
"candidates.sort(function(a,b){return a.score-b.score;});"
"candidates[0].button.click();"
"return JSON.stringify({clicked:true,reason:null,text:candidates[0].text});})()"
)
class EditorError(RuntimeError):
"""Raised for expected editor automation failures with user-readable messages."""
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
def _seller_home_url(account):
return f"https://{_region_host(account)}/"
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")
def _is_login_url(url):
value = (url or "").lower()
return any(marker in value for marker in LOGIN_PATH_MARKERS)
def _json_value(cdp, expr, default=None):
raw = cdp.val(expr)
if not raw:
return default
return json.loads(raw)
def _title_state(cdp):
return _json_value(cdp, JS_TITLE_STATE, default={}) or {}
def _image_rects(cdp):
return _json_value(cdp, JS_RECTS, default=[]) or []
def _wait_ready(cdp, timeout=60):
end = time.time() + timeout
while time.time() < end:
try:
if cdp.val(JS_READY):
cdp.val(
"(function(){var m=document.querySelector('.shopee-image-manager');"
"if(m)m.scrollIntoView({block:'center'});return 1;})()"
)
time.sleep(0.5)
return True
except Exception:
pass
time.sleep(1)
raise TimeoutError("等待 Shopee 商品编辑器就绪超时")
def _ensure_page_domains(cdp):
for domain in ("Page", "Runtime", "DOM", "Network"):
try:
cdp.send(f"{domain}.enable")
except Exception:
pass
def _current_url(cdp):
try:
return cdp.val("location.href") or ""
except Exception:
return ""
def _wait_for_login_probe(cdp, timeout=8):
end = time.time() + timeout
last_url = ""
cookie_names = set()
while time.time() < end:
last_url = _current_url(cdp) or last_url
try:
cookies = cdp.send("Network.getAllCookies").get("cookies", [])
cookie_names = {
c.get("name")
for c in cookies
if "shopee" in (c.get("domain") or "")
}
except Exception:
cookie_names = set()
if _is_login_url(last_url) or "SPC_ST" in cookie_names or "SPC_U" in cookie_names:
break
time.sleep(0.5)
return last_url, cookie_names
def login_status(account, timeout=8) -> dict:
"""Return detailed Shopee login status for an account's CDP session."""
host = _cdp_host(account)
pages = [t for t in http_get("/json", host=host) if t.get("type") == "page"]
shopee_page = next((p for p in pages if "shopee" in (p.get("url") or "")), None)
if not shopee_page:
ws = create_tab(_seller_home_url(account), host=host)
initial_url = _seller_home_url(account)
cdp = CDP(ws)
else:
initial_url = shopee_page.get("url") or ""
if _is_login_url(initial_url):
return {
"logged_in": False,
"reason": "LOGIN_PAGE",
"url": initial_url,
"host": host,
"cookie_names": [],
}
cdp = CDP(shopee_page["webSocketDebuggerUrl"])
try:
_ensure_page_domains(cdp)
url, names = _wait_for_login_probe(cdp, timeout=timeout)
url = url or initial_url
if _is_login_url(url):
reason = "LOGIN_PAGE"
logged_in = False
elif "SPC_ST" in names or "SPC_U" in names:
reason = None
logged_in = True
else:
reason = "NO_SESSION_COOKIE"
logged_in = False
return {
"logged_in": logged_in,
"reason": reason,
"url": url,
"host": host,
"cookie_names": sorted(name for name in names if name),
}
finally:
cdp.close()
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"))
def open_product(account, item_id, on_step=None) -> CDP:
"""Open or reuse a product edit tab, navigate to a clean edit URL, and wait ready."""
_notify_collect_step(on_step, "open_product")
host = _cdp_host(account)
item_id = str(item_id)
url = _product_url(account, item_id)
tab = find_product_tab(item_id, host=host)
created_by_app = tab is None
if tab is None:
tab = create_tab_info(url, host=host)
ws = tab["webSocketDebuggerUrl"]
cdp = CDP(ws)
cdp.target_id = tab.get("id")
cdp.created_by_app = created_by_app
cdp.cdp_host = host
_ensure_page_domains(cdp)
try:
cdp.send("Page.bringToFront")
except Exception:
pass
cdp.send("Page.navigate", {"url": url})
_notify_collect_step(on_step, "wait_ready")
_wait_ready(cdp)
return cdp
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 ""
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
def collect(account, task, on_step=None) -> dict:
"""Collect current title and cover snapshot before any edits."""
item_id = _item_id(task)
cdp = open_product(account, item_id, on_step=on_step)
try:
_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 = os.path.join(_image_root(account), f"{item_id}_old.jpg")
_notify_collect_step(on_step, "download_cover")
old_cover_path = download_cover(old_cover_src, out_path)
return {
"old_title": old_title,
"old_cover_src": old_cover_src,
"old_cover_path": old_cover_path,
}
finally:
_close_collected_product(cdp)
def _notify_collect_step(callback, step):
if callback is None:
return
try:
callback(step)
except Exception:
pass
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)
try:
cdp.close()
finally:
if created_by_app and target_id:
try:
close_tab(target_id, host=host)
except Exception:
pass
def change_title(cdp, new_title) -> dict:
"""Write a new title with the verified native setter + input/change events."""
expr = (
"(function(){"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
"XPathResult.FIRST_ORDERED_NODE_TYPE,null);var el=r.singleNodeValue;"
"if(!el)return 'NO_INPUT';"
"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)
return {"ok": ok, "written": written, "value": value, "modelvalue": modelvalue}
def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=90) -> dict:
"""Upload an image and drag it to the first position.
When the image manager is full, only delete the current first image if the
old cover backup from the collect stage exists locally.
"""
image_win_path = os.path.abspath(str(image_win_path))
if not os.path.exists(image_win_path):
raise FileNotFoundError(f"封面图片不存在: {image_win_path}")
cdp.val(
"(function(){var m=document.querySelector('.shopee-image-manager');"
"if(m)m.scrollIntoView({block:'center'});return !!m;})()"
)
time.sleep(0.5)
before = _image_rects(cdp)
count_before = len(before)
delete_result = None
if len(before) >= 9:
backup_path = _validated_old_cover_backup(old_cover_path)
if not backup_path:
return {
"ok": False,
"reason": "OLD_COVER_BACKUP_MISSING",
"count_before": count_before,
"old_cover_path": old_cover_path,
}
delete_result = _delete_first_cover(cdp, before)
if not delete_result.get("ok"):
return {
"ok": False,
"reason": delete_result.get("reason"),
"count_before": count_before,
"delete": delete_result,
}
before = _image_rects(cdp)
before_srcs = {r.get("src") for r in before}
oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')")
if not oid:
return {"ok": False, "reason": "NO_UPLOAD_INPUT", "count_before": count_before, "delete": delete_result}
cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [image_win_path]})
cdp.val(
"(function(){var up=document.querySelector('.shopee-image-manager__upload input[type=file]');"
"if(!up)return false;"
"up.dispatchEvent(new Event('input',{bubbles:true}));"
"up.dispatchEvent(new Event('change',{bubbles:true}));return true;})()"
)
new_src = None
end = time.time() + timeout
while time.time() < end:
time.sleep(1.5)
cur = _image_rects(cdp)
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 len(cur) > len(before) and ready:
new_src = ready[-1]["src"]
break
if not new_src:
return {"ok": False, "reason": "UPLOAD_TIMEOUT", "count_before": count_before, "delete": delete_result}
time.sleep(1)
cur = _image_rects(cdp)
new_rect = next((r for r in cur if r.get("src") == new_src), None)
if not new_rect:
return {"ok": False, "reason": "NEW_IMAGE_NOT_FOUND", "new_src": new_src, "delete": delete_result}
first = cur[0]
cdp.drag(new_rect["x"], new_rect["y"], first["left"] - first["w"] * 0.30, first["y"])
time.sleep(1.2)
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,
"count_before": count_before,
"count_after": len(after),
"delete": delete_result,
}
def _validated_old_cover_backup(old_cover_path):
if not old_cover_path:
return None
path = os.path.abspath(str(old_cover_path))
if not os.path.exists(path):
return None
return path
def _delete_first_cover(cdp, before, timeout=15):
count_before = len(before)
if not before:
return {"ok": False, "reason": "NO_IMAGE", "count_before": 0}
first = before[0]
try:
cdp.send(
"Input.dispatchMouseEvent",
{"type": "mouseMoved", "x": first["x"], "y": first["y"]},
)
time.sleep(0.2)
except Exception:
pass
click_result = _json_value(
cdp,
JS_CLICK_FIRST_DELETE,
default={"clicked": False, "reason": "NO_DELETE_BUTTON"},
) or {}
if not click_result.get("clicked"):
return {
"ok": False,
"reason": click_result.get("reason") or "NO_DELETE_BUTTON",
"count_before": count_before,
"click": click_result,
}
confirm_result = None
end = time.time() + timeout
while time.time() < end:
cur = _image_rects(cdp)
if len(cur) < count_before:
return {
"ok": True,
"reason": None,
"count_before": count_before,
"count_after": len(cur),
"click": click_result,
"confirm": confirm_result,
}
confirm_attempt = _json_value(
cdp,
JS_CLICK_DELETE_CONFIRM,
default={"clicked": False, "reason": "NO_CONFIRM_BUTTON"},
) or {}
if confirm_attempt.get("clicked"):
confirm_result = confirm_attempt
time.sleep(0.5)
cur = _image_rects(cdp)
return {
"ok": False,
"reason": "DELETE_TIMEOUT",
"count_before": count_before,
"count_after": len(cur),
"click": click_result,
"confirm": confirm_result,
}
def click_update(cdp) -> dict:
"""Click the Shopee update button if it is present and enabled."""
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)
time.sleep(3 if result == "CLICKED" else 0)
toasts = _json_value(cdp, JS_TOASTS, default=[]) or []
return {
"clicked": result == "CLICKED",
"reason": None if result == "CLICKED" else result,
"toasts": toasts,
}
def apply_task(account, task, close_success_tab=False) -> dict:
"""Apply generated title/cover to Shopee.
The caller must perform the batch confirmation before calling this function.
"""
item_id = _item_id(task)
cdp = open_product(account, item_id)
committed = False
try:
title_result = None
cover_result = None
new_title = _get(task, "new_title")
new_cover_path = _get(task, "new_cover_path")
if new_title:
title_result = change_title(cdp, new_title)
if not title_result.get("ok"):
return {"committed": False, "error": "标题写入后 value/modelvalue 未同步", "title": title_result}
if new_cover_path:
cover_result = replace_cover(
cdp,
new_cover_path,
old_cover_path=_get(task, "old_cover_path"),
)
if not cover_result.get("ok"):
return {"committed": False, "error": cover_result.get("reason"), "cover": cover_result}
update_result = click_update(cdp)
committed = bool(update_result.get("clicked", False))
return {
"committed": committed,
"error": update_result.get("reason"),
"title": title_result,
"cover": cover_result,
"update": update_result,
}
except Exception as exc:
return {"committed": False, "error": str(exc)}
finally:
_close_applied_product(cdp, close_success_tab=close_success_tab and committed)
def _close_applied_product(cdp, close_success_tab=False):
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 close_success_tab and created_by_app and target_id:
try:
close_tab(target_id, host=host)
except Exception:
pass