feat: 抽取 Shopee 编辑器模块
新增 app/editor.py,封装商品页打开、登录检测、标题读取写入、封面读取下载、封面上传拖拽、更新按钮点击和 apply_task。 扩展 app/cdp.py 的 host 参数,支持后续按账号端口连接 CDP。 同步任务看板、模块合约、当前状态和进度记录,标记 T-001 完成;记录测试商品标题读写、封面读取与 1_TY030.jpg 上传拖拽验证结果。
This commit is contained in:
+11
-7
@@ -18,12 +18,16 @@ CDP_HOST = os.environ.get("CDP_HOST", "127.0.0.1:9222")
|
||||
BASE = f"http://{CDP_HOST}"
|
||||
|
||||
|
||||
def http_get(path):
|
||||
def _base(host=None):
|
||||
return f"http://{host or CDP_HOST}"
|
||||
|
||||
|
||||
def http_get(path, host=None):
|
||||
import requests
|
||||
|
||||
s = requests.Session()
|
||||
s.trust_env = False # 忽略环境代理
|
||||
return s.get(f"{BASE}{path}", timeout=10).json()
|
||||
return s.get(f"{_base(host)}{path}", timeout=10).json()
|
||||
|
||||
|
||||
class CDP:
|
||||
@@ -118,9 +122,9 @@ class CDP:
|
||||
pass
|
||||
|
||||
|
||||
def find_product_tab(item_id):
|
||||
def find_product_tab(item_id, host=None):
|
||||
"""在已打开的 tab 里找 URL 同时含 item_id 和 shopee.tw 的页面。"""
|
||||
for t in http_get("/json"):
|
||||
for t in http_get("/json", host=host):
|
||||
if t.get("type") != "page":
|
||||
continue
|
||||
url = t.get("url") or ""
|
||||
@@ -129,9 +133,9 @@ def find_product_tab(item_id):
|
||||
return None
|
||||
|
||||
|
||||
def create_tab(url):
|
||||
def create_tab(url, host=None):
|
||||
"""用 browser 级 Target.createTarget 新建 tab,返回其 page websocket。"""
|
||||
ver = http_get("/json/version")
|
||||
ver = http_get("/json/version", host=host)
|
||||
b = CDP(ver["webSocketDebuggerUrl"])
|
||||
try:
|
||||
tid = b.send("Target.createTarget", {"url": url})["targetId"]
|
||||
@@ -139,7 +143,7 @@ def create_tab(url):
|
||||
b.close()
|
||||
end = time.time() + 15
|
||||
while time.time() < end:
|
||||
for t in http_get("/json"):
|
||||
for t in http_get("/json", host=host):
|
||||
if t.get("id") == tid and t.get("webSocketDebuggerUrl"):
|
||||
return t["webSocketDebuggerUrl"]
|
||||
time.sleep(0.5)
|
||||
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
"""Shopee editor operations built on the verified CDP primitives."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .cdp import CDP, create_tab, 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']"
|
||||
)
|
||||
|
||||
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));})()"
|
||||
)
|
||||
|
||||
|
||||
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 _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 _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 is_logged_in(account) -> bool:
|
||||
"""Return whether the account's current Shopee session appears logged in."""
|
||||
|
||||
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(f"https://{_region_host(account)}/portal/", host=host)
|
||||
cdp = CDP(ws)
|
||||
else:
|
||||
url = shopee_page.get("url") or ""
|
||||
if "/login" in url or "account/signin" in url:
|
||||
return False
|
||||
cdp = CDP(shopee_page["webSocketDebuggerUrl"])
|
||||
|
||||
try:
|
||||
_ensure_page_domains(cdp)
|
||||
cookies = cdp.send("Network.getAllCookies").get("cookies", [])
|
||||
names = {
|
||||
c.get("name")
|
||||
for c in cookies
|
||||
if "shopee" in (c.get("domain") or "")
|
||||
}
|
||||
return "SPC_ST" in names or "SPC_U" in names
|
||||
finally:
|
||||
cdp.close()
|
||||
|
||||
|
||||
def open_product(account, item_id) -> CDP:
|
||||
"""Open or reuse a product edit tab, navigate to a clean edit URL, and wait ready."""
|
||||
|
||||
host = _cdp_host(account)
|
||||
item_id = str(item_id)
|
||||
url = _product_url(account, item_id)
|
||||
tab = find_product_tab(item_id, host=host)
|
||||
ws = tab["webSocketDebuggerUrl"] if tab else create_tab(url, host=host)
|
||||
cdp = CDP(ws)
|
||||
_ensure_page_domains(cdp)
|
||||
try:
|
||||
cdp.send("Page.bringToFront")
|
||||
except Exception:
|
||||
pass
|
||||
cdp.send("Page.navigate", {"url": url})
|
||||
_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) -> dict:
|
||||
"""Collect current title and cover snapshot before any edits."""
|
||||
|
||||
item_id = _item_id(task)
|
||||
cdp = open_product(account, item_id)
|
||||
try:
|
||||
old_title = read_title(cdp)
|
||||
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")
|
||||
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:
|
||||
cdp.close()
|
||||
|
||||
|
||||
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, timeout=90) -> dict:
|
||||
"""Upload an image and drag it to the first position.
|
||||
|
||||
Full 9-image deletion is intentionally left for T-502 because its confirm dialog
|
||||
selector is not verified yet.
|
||||
"""
|
||||
|
||||
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)
|
||||
if len(before) >= 9:
|
||||
return {"ok": False, "reason": "FULL_IMAGE_SLOTS", "count_before": len(before)}
|
||||
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": len(before)}
|
||||
|
||||
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": len(before)}
|
||||
|
||||
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}
|
||||
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": len(before),
|
||||
"count_after": len(after),
|
||||
}
|
||||
|
||||
|
||||
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) -> 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)
|
||||
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)
|
||||
if not cover_result.get("ok"):
|
||||
return {"committed": False, "error": cover_result.get("reason"), "cover": cover_result}
|
||||
update_result = click_update(cdp)
|
||||
return {
|
||||
"committed": update_result.get("clicked", False),
|
||||
"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:
|
||||
cdp.close()
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T-000 | 正式代码包结构:创建 `app/`、迁入 `cdp.py` 为 `app/cdp.py`、新增 `app/__init__.py`、`app/__main__.py`、根入口 `main.py`、最小 `app/gui.py` 占位入口,并修正 prototypes 导入 | - | `python -m compileall app main.py` 通过;`python -m app`/`python main.py` 可进入入口(本机 `python` 不符合版本时用 `py -3 -m app`);GUI 未完成时给明确提示并退出;`prototypes/demo.py` 可从项目根导入 `app.cdp` | DONE |
|
||||
| T-001 | `app/editor.py`:改标题/换封面/点更新/登录检测/**采集(读旧标题+旧封面下载)**/apply_task,复用 `app/cdp.py` | T-000 | 函数可调用,在测试商品跑通;与 `prototypes/demo.py` 行为一致 | TODO |
|
||||
| T-001 | `app/editor.py`:改标题/换封面/点更新/登录检测/**采集(读旧标题+旧封面下载)**/apply_task,复用 `app/cdp.py` | T-000 | 函数可调用,在测试商品跑通;与 `prototypes/demo.py` 行为一致 | DONE |
|
||||
| T-002 | `app/appconfig.py` + `config.json`(含 image_dir、ai 选择/参数段、端口等默认值;不含 AI Key) | T-000 | 读写正常;不存在则写默认;AI Key 留给 `config/ai_models.json`/T-501 | TODO |
|
||||
| T-003 | `app/db.py` + SQLite 建表(batches/accounts/tasks,含 Excel 行定位、状态、时间戳、重试字段) | T-000 | `init_db` 幂等;`connect` 设置 WAL/busy_timeout/foreign_keys;账号/批次/任务/各 set_* 可用;schema 同架构 5.2 | TODO |
|
||||
| T-004 | `.gitignore`:排除 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` | T-002, T-003 | 配置、密钥、凭证、业务数据、图片不被提交 | TODO |
|
||||
|
||||
+3
-3
@@ -112,11 +112,11 @@ create_shortcut(account, dest_dir=None) -> str # 可选 .lnk,PowerShell WScr
|
||||
|
||||
```python
|
||||
CDP_HOST: str
|
||||
http_get(path); find_product_tab(item_id); create_tab(url)
|
||||
http_get(path, host=None); find_product_tab(item_id, host=None); create_tab(url, host=None)
|
||||
class CDP: send/ev/val/object_id/drag/close # suppress_origin、trust_env=False
|
||||
```
|
||||
|
||||
## editor 模块(`app/editor.py`,待建,重构自现有脚本)
|
||||
## editor 模块(`app/editor.py`,已建,重构自现有脚本)
|
||||
|
||||
```python
|
||||
is_logged_in(account) -> bool # 重定向登录页或缺 SPC_ST → False
|
||||
@@ -130,7 +130,7 @@ collect(account, task) -> dict # -> {old_title, old_cover_path}
|
||||
|
||||
# 应用
|
||||
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
|
||||
replace_cover(cdp, image_win_path) -> dict # 上传→等CDN→拖第一位;满9张先删第一张
|
||||
replace_cover(cdp, image_win_path) -> dict # 上传→等CDN→拖第一位;满9张暂返回 FULL_IMAGE_SLOTS,删除流程留给 T-502
|
||||
click_update(cdp) -> dict # {clicked, reason};禁用则记失败
|
||||
apply_task(account, task) -> dict # 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已做批量确认)
|
||||
# -> {committed, error}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-06-26
|
||||
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构,下一步开始模块化 editor/config/DB。
|
||||
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构与 T-001 `app/editor.py` 模块化,下一步开始应用配置。
|
||||
- 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI PySide6 5 Tab(已定)。
|
||||
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。
|
||||
- 测试:当前以 `py_compile` + 在测试商品上手动跑 `prototypes/demo.py` 为主;`tests/` 与 `python -m unittest discover -s tests` 由 T-006 建立,T-006 完成前不把缺少 `tests/` 视为验证失败。
|
||||
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装标题/封面/采集/更新按钮能力;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。
|
||||
- 测试:当前以 `compileall` + 测试商品手动 CDP 验证为主;`tests/` 与 `python -m unittest discover -s tests` 由 T-006 建立,T-006 完成前不把缺少 `tests/` 视为验证失败。
|
||||
- 数据:无 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/`(待 Phase 0/1 建立,均须 gitignore)。
|
||||
|
||||
## 既定设计要点(文档已定)
|
||||
@@ -33,7 +33,8 @@
|
||||
| `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 |
|
||||
| `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 |
|
||||
| `app/gui.py` | 已有 | GUI 占位入口;完整 PySide6 主窗口待 T-104 |
|
||||
| `app/appconfig.py` / `app/db.py` / `app/excel.py` / `app/config.py` / `app/chrome.py` / `app/editor.py` / `app/workers.py` | 待建 | Phase 0-3 产出 |
|
||||
| `app/editor.py` | 已有 | T-001 产出:登录检测、打开商品页、读/写标题、读/下载封面、上传拖封面、更新按钮、apply_task |
|
||||
| `app/appconfig.py` / `app/db.py` / `app/excel.py` / `app/config.py` / `app/chrome.py` / `app/workers.py` | 待建 | Phase 0-3 产出 |
|
||||
| `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 待建 | 含配置、密钥、业务、登录态、图片,须 gitignore |
|
||||
|
||||
## 已验证能力(单账号)
|
||||
@@ -41,16 +42,16 @@
|
||||
- CDP 连接 Chrome、遍历 tab、读取 shopee.tw Cookie。
|
||||
- 找到/新建商品详情页 tab,等编辑器就绪。
|
||||
- 改标题:原生 setter + 派发事件,`value` 与 `modelvalue` 双等于新值。
|
||||
- 换封面:`setFileInputFiles` 上传(`.shopee-image-manager__upload input[type=file]`)→ 等 CDN 链接 → `Input.dispatchMouseEvent` 拖到第一位(落点 `first.left - 0.30*w`)。
|
||||
- 换封面:`setFileInputFiles` 上传(`.shopee-image-manager__upload input[type=file]`)→ 等 CDN 链接 → 等 1 秒 → `Input.dispatchMouseEvent` 拖到第一位(落点 `first.left - 0.30*w`);使用 `1_TY030.jpg` 在测试商品验证通过。
|
||||
- 「更新」按钮:可点才点,禁用态识别;③ 未批量确认前不提交。
|
||||
|
||||
## 任务看板状态
|
||||
|
||||
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。
|
||||
|
||||
- 已完成:T-000(正式代码包结构);单账号能力以脚本形式存在,待 T-001 模块化。
|
||||
- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:**T-001(`app/editor.py`:改标题/换封面/点更新/登录检测/采集/apply_task)**。虽然 T-002/T-003 也依赖满足,但任务领取规则固定为“取 `06-tasks.md` 中第一个 TODO 且依赖均 DONE 的任务”,因此当前不能任选。
|
||||
- 下一个可领取任务:**T-002(`app/appconfig.py` + `config.json`)**。虽然 T-003 也依赖满足,但任务领取规则固定为“取 `06-tasks.md` 中第一个 TODO 且依赖均 DONE 的任务”,因此当前不能任选。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+15
@@ -233,4 +233,19 @@
|
||||
- 注意:本机 `python` 指向 Python 3.7.9 isolated,不符合项目 Python 3.10+ 要求,且 `python -m app` 不搜索当前目录;本轮用 `py -3`(Python 3.14.4)完成包入口验证。
|
||||
- 下一步:按任务看板领取 T-001。
|
||||
|
||||
## 【2026-06-26】T-001 editor 模块化
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:新增 `app/editor.py`,封装 `is_logged_in/open_product/read_title/read_cover_src/download_cover/collect/change_title/replace_cover/click_update/apply_task`;`app/cdp.py` 增加 `host` 参数,支持后续按账号端口连接;更新 `docs/06-tasks.md`、`docs/current-state.md`、`docs/api.md`。
|
||||
- 验证:
|
||||
- `py -3 -m compileall app main.py` 通过。
|
||||
- `python main.py` 输出 GUI 未实现提示并退出。
|
||||
- `py -3 -c "import app.editor as e; ..."` 导入通过,`apply_task` 可调用。
|
||||
- 测试商品 `51100639510`:`open_product` + `read_title` 读到标题;`change_title` 写回原标题后 `value == modelvalue == 原标题`。
|
||||
- 测试商品 `51100639510`:`read_cover_src` 读到第一张封面 CDN 链接。
|
||||
- 测试商品 `51100639510`:使用 `1_TY030.jpg` 执行 `replace_cover`,上传成功并拖到第 0 位,返回 `ok=True`;全程未点击「更新」。
|
||||
- 决策:满 9 张封面的删除确认框仍未实测,`replace_cover` 当前返回 `FULL_IMAGE_SLOTS`,实际删第一张流程留给 T-502。
|
||||
- 注意:本轮真实页面验证使用本机 `python`(3.7.9,已装 requests/websocket-client)连接 CDP;项目版本要求仍为 Python 3.10+,后续开发优先用 `py -3` 并补齐依赖。
|
||||
- 下一步:按任务看板领取 T-002。
|
||||
|
||||
<!-- 新一轮从这里向下追加记录。 -->
|
||||
|
||||
Reference in New Issue
Block a user