docs: 初始化 cmshopee 文档、设计与项目骨架

- docs/ 完整 harness coding 文档集(愿景/需求/技术栈/架构/编码规则/任务/api/routes/current-state)
- 5 Tab 流水线设计 + UI 效果图 SVG(docs/ui/)
- cdp.py CDP 底座;prototypes/ 已验证原型脚本(待 editor.py 移植后清理)
- AGENTS.md/CLAUDE.md 入口、progress.md 执行流水、.gitignore(排除凭证/DB/图片)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-06-26 15:30:37 +08:00
co-authored by Claude Opus 4.8
commit 479d02a2b8
32 changed files with 3584 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# prototypes —— 已验证原型 / 探查脚本
这些是单账号阶段写的**已验证脚本**,逻辑与关键事实将被正式模块(尤其 `editor.py`,见任务 T-001)移植。
**待 `editor.py` 完成并实测通过后,本目录可清理删除。** 在此之前保留它们作为"唯一已验证参照"。
## 文件
| 文件 | 用途 | 移植去向 |
| --- | --- | --- |
| `demo.py` | 单账号端到端:改标题 + 换封面(UPDATE=1 提交) | `editor.py` 编排 |
| `set_title.py` | 改标题(原生 setter + 事件,value/modelvalue 验证) | `editor.change_title` |
| `set_cover.py` | 上传图片 + 拖到第一位设封面(含选择器、拖拽落点) | `editor.replace_cover` |
| `get_title.py` | 找/开商品 tab 并读取标题 | `editor.open_product` / `read_title` |
| `cookies.py` | 读 shopee.tw 标签页 Cookie/会话 | `editor.is_logged_in` 的依据 |
| `inspect_images.py` | 只读探查图片管理器 DOM(**Shopee 改版时重新探查可复用**) | 工具,保留参考 |
| `grab.py` | 抓商品页 HTML + 列表接口 JSON(早期探查) | 参考 |
| `1.py` | OpenAI SDK 最小调用测试 | `ai.py` 调用方式参考 |
## 运行注意
- **`cdp.py` 在项目根目录**(正式模块),不在本目录。
- 仅 `demo.py` `import cdp`;其余脚本各自内置了 CDP 类,可独立运行。
- 跑 `demo.py` 需让根目录的 `cdp.py` 可被导入(从项目根运行,或设 `PYTHONPATH=项目根`)。
- 这些脚本默认连开发期的 WSL 转发地址 `192.168.0.224:9333`;正式模块用 `127.0.0.1:9222`(可用 `CDP_HOST` 覆盖)。
- 已验证的关键事实(选择器、SPA 就绪判断、上传/拖拽方式)权威记录见 [`../docs/04-architecture.md`](../docs/04-architecture.md) 第七节。
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# cookies.py —— 连 CDP,遍历所有 tab,找到域名含 shopee.tw 的 tab,取 cookie 并打印。
#
# 依赖:pip install websocket-client requests
# 运行:python3 cookies.py
# 环境变量:CDP_HOST(默认 192.168.0.224:9333)、DOMAIN(默认 shopee.tw)
import os
# CDP 是局域网直连,绝不能走代理。WSL 里设了 *_proxy(指向 :1080),
# requests / websocket-client 都会读它导致超时。启动即清掉,最干净。
for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"):
os.environ.pop(_k, None)
import json
import requests
from websocket import create_connection
CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333")
BASE = f"http://{CDP_HOST}"
DOMAIN = os.environ.get("DOMAIN", "shopee.tw")
def cdp_call(ws_url, method, params=None):
# suppress_origin=True:不发 Origin 头,避免 Chrome 在未开 --remote-allow-origins 时返回 403。
ws = create_connection(ws_url, max_size=None, suppress_origin=True)
try:
ws.send(json.dumps({"id": 1, "method": method, "params": params or {}}))
while True:
msg = json.loads(ws.recv())
if msg.get("id") == 1:
if "error" in msg:
raise RuntimeError(msg["error"].get("message"))
return msg.get("result", {})
finally:
ws.close()
def main():
print(f"[*] 读取目标列表: {BASE}/json")
sess = requests.Session()
sess.trust_env = False # 双保险:忽略环境代理
targets = sess.get(f"{BASE}/json", timeout=10).json()
pages = [t for t in targets if t.get("type") == "page"]
print(f"[*] 共 {len(pages)} 个 page tab:")
for i, p in enumerate(pages):
print(f" [{i}] {p.get('url')}")
matches = [p for p in pages if DOMAIN in (p.get("url") or "")]
if not matches:
print(f"[!] 没有找到 URL 含 '{DOMAIN}' 的 tab。")
return
for p in matches:
print(f"\n[*] 命中 tab: {p['url']}")
result = cdp_call(p["webSocketDebuggerUrl"], "Network.getCookies")
cookies = result.get("cookies", [])
print(f"[*] 共 {len(cookies)} 个 cookie:\n")
for c in cookies:
print(f" {c['name']} = {c['value']}")
print(f" domain={c.get('domain')} path={c.get('path')} "
f"httpOnly={c.get('httpOnly')} secure={c.get('secure')}")
# 同时给一份可直接用于请求头的 Cookie 串
header = "; ".join(f"{c['name']}={c['value']}" for c in cookies)
print(f"\n[*] Cookie 请求头格式:\n{header}")
if __name__ == "__main__":
main()
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
# demo.py —— 演示编排:打开商品页 → 改标题 → 上传新封面并拖到首位。分步停顿、不保存。
#
# 给运营同事演示用。每次运行都先把页面导航到干净状态(未保存过,重载即回到原始标题/图片),
# 因此可反复演示。全程不点保存,绝不改动线上商品。
#
# 依赖:pip install websocket-client requests
# 运行:python3 demo.py (分步,按 Enter 推进——适合讲解)
# AUTO=1 python3 demo.py (一气呵成,自动推进)
# 环境变量:CDP_HOST、ITEM_ID(默认 51100639510)、IMG_WIN(封面图 Windows 路径)、NEW_TITLE
import os
import sys
import json
import time
# 确保能 import 同目录的 cdp.py(某些 Windows Python 启动方式不会自动把脚本目录加进 sys.path)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cdp import CDP, http_get, find_product_tab, create_tab
ITEM_ID = os.environ.get("ITEM_ID", "51100639510")
IMG_WIN = os.environ.get("IMG_WIN", r"D:\chengma\cmshopee\1_TY030.jpg")
PRODUCT_URL = f"https://seller.shopee.tw/portal/product/{ITEM_ID}?pageEntry=product_list&ignore-html-cache=1"
AUTO = os.environ.get("AUTO") == "1"
DO_UPDATE = os.environ.get("UPDATE") == "1" # 默认不点「更新」;UPDATE=1 才真实提交到线上
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']"
# ---------- 小工具 ----------
def banner(text):
print("\n" + "=" * 60 + f"\n {text}\n" + "=" * 60)
def pause(msg):
if AUTO:
print(f"\n>>> {msg}")
time.sleep(1.5)
else:
try:
input(f"\n>>> {msg} (按 Enter 继续)")
except EOFError:
time.sleep(1.5)
# ---------- 页面 JS ----------
def js_read_title():
return (
"(function(){"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
"XPathResult.FIRST_ORDERED_NODE_TYPE,null);var el=r.singleNodeValue;"
"return el?el.value:null;})()"
)
def js_read_modelvalue():
return (
"(function(){"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
"XPathResult.FIRST_ORDERED_NODE_TYPE,null);var el=r.singleNodeValue;"
"return el?el.getAttribute('modelvalue'):null;})()"
)
def js_write_title(t):
return (
"(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(t)});"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"el.dispatchEvent(new Event('change',{bubbles:true}));el.blur();return el.value;})()"
)
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_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);})()"
)
# 定位「更新」按钮:button.eds-button 中 span 文本为“更新”的可见按钮
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';})()"
)
# 提交后抓页面提示(toast/message),用于判断成功或失败
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));})()"
)
def rects(cdp):
return json.loads(cdp.val(JS_RECTS))
def short(src):
return src.rsplit("/", 1)[-1] if src else src
# ---------- 流程 ----------
def connect_clean_page():
"""定位或新建商品 tab,导航到干净状态并等编辑器就绪。"""
tab = find_product_tab(ITEM_ID)
if tab:
print(f"[*] 复用已打开的商品 tab")
ws = tab["webSocketDebuggerUrl"]
else:
print(f"[*] 未找到商品 tab,新建并打开商品页")
ws = create_tab(PRODUCT_URL)
cdp = CDP(ws)
cdp.send("Page.enable")
cdp.send("Runtime.enable")
cdp.send("DOM.enable")
# 把该 tab 切到前台:演示时同事看得见,也避免后台标签被 Chrome 限流导致迟迟不渲染
try:
cdp.send("Page.bringToFront")
except Exception:
pass
print(f"[*] 导航到干净状态:{PRODUCT_URL}")
cdp.send("Page.navigate", {"url": PRODUCT_URL})
print("[*] 等待编辑器渲染就绪 ...", end="", flush=True)
end = time.time() + 60
while time.time() < end:
time.sleep(1)
try:
if cdp.val(JS_READY):
print(" 就绪 ✅")
cdp.val("(function(){var m=document.querySelector('.shopee-image-manager');"
"if(m)m.scrollIntoView({block:'center'});return 1;})()")
time.sleep(0.5)
return cdp
except Exception:
pass
print(".", end="", flush=True)
raise TimeoutError("等待编辑器就绪超时")
def step_show_title(cdp):
banner("步骤 1 / 读取当前商品标题")
original = cdp.val(js_read_title())
print(f"当前标题:\n {original}")
return original
def step_change_title(cdp, original):
new_title = os.environ.get("NEW_TITLE") or original[:-2] # 默认去掉结尾两个字
banner("步骤 2 / 修改标题")
print(f"新标题(去掉结尾两字):\n {new_title}")
pause("开始写入新标题")
cdp.val(js_write_title(new_title))
time.sleep(0.4)
value = cdp.val(js_read_title())
model = cdp.val(js_read_modelvalue())
print(f" 写入后 value = {value}")
print(f" 写入后 modelvalue = {model}")
ok = value == new_title and model == new_title
print(" 结果:" + ("✅ 标题已更新(value 与 modelvalue 一致,Vue 模型已绑定)" if ok else "❌ 写入异常"))
return new_title
def step_upload_and_cover(cdp):
banner("步骤 3 / 上传新封面图并拖到首位")
before = rects(cdp)
before_srcs = {r["src"] for r in before}
print(f"上传前共 {len(before)} 张图:")
for r in before:
print(f" [{r['i']}] {short(r['src'])}")
pause(f"开始上传封面图:{IMG_WIN}")
oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')")
if not oid:
print("[!] 没找到上传输入框,终止。"); return
cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [IMG_WIN]})
print("[*] 等待上传完成(新图出现且为 CDN 链接)...")
new_src = None
end = time.time() + 90
while time.time() < end:
time.sleep(1.5)
cur = rects(cdp)
ready = [r for r in cur if r["src"] not in before_srcs
and r["src"] and "susercontent" in r["src"] and "blob:" not in r["src"]]
if len(cur) > len(before) and ready:
new_src = ready[-1]["src"]
print(f"[+] 上传成功:{short(new_src)}(当前 {len(cur)} 张)")
break
if not new_src:
print("[!] 上传未确认成功,请看浏览器。"); return
pause("开始把新图拖到第一位(封面)")
cur = rects(cdp)
new = next(r for r in cur if r["src"] == new_src)
first = cur[0]
tx = first["left"] - first["w"] * 0.30 # 落点在第一张左缘外侧,才会插到最前
ty = first["y"]
print(f"[*] 拖拽:新图[{new['i']}] -> 首位前")
cdp.drag(new["x"], new["y"], tx, ty)
time.sleep(1.2)
after = rects(cdp)
print(f"\n拖拽后顺序({len(after)} 张):")
for r in after:
print(f" [{r['i']}] {short(r['src'])}" + (" <== 新封面" if r["src"] == new_src else ""))
print("\n结果:" + ("✅ 新图已在第一位(封面)" if after and after[0]["src"] == new_src
else "❌ 新图未到第一位"))
def step_update(cdp):
banner("步骤 4 / 点击「更新」提交(⚠ 持久化到线上商品)")
info = json.loads(cdp.val(JS_FIND_UPDATE))
if not info.get("found"):
print("[!] 没找到「更新」按钮,跳过。")
return
if info.get("disabled"):
print("[!] 「更新」按钮当前为【禁用】状态——通常意味着有未通过的必填/校验项,不点击。")
print(" 请到浏览器查看是否有红字校验提示。")
return
print("[*] 找到可点击的「更新」按钮。")
pause("⚠ 即将点击「更新」,这会把标题+封面真实提交到线上商品")
r = cdp.val(JS_CLICK_UPDATE)
print(f"[*] 点击结果:{r}")
if r != "CLICKED":
return
time.sleep(3)
toasts = json.loads(cdp.val(JS_TOASTS))
if toasts:
print("[*] 页面提示:" + " | ".join(toasts))
print("[*] 已提交。请到浏览器确认是否出现「更新成功」之类提示。")
def main():
banner("Shopee 商品改标题 + 换封面 自动化演示" + ("(含更新提交)" if DO_UPDATE else "(不保存)"))
cdp = connect_clean_page()
try:
original = step_show_title(cdp)
pause("进入步骤 2:修改标题")
step_change_title(cdp, original)
pause("进入步骤 3:上传并替换封面")
step_upload_and_cover(cdp)
if DO_UPDATE:
pause("进入步骤 4:点击「更新」提交")
step_update(cdp)
banner("演示结束")
print("已点击「更新」,改动应已提交线上。请到浏览器核对提示。")
else:
banner("演示结束")
print("以上改动全部停留在页面内存,未保存、未提交线上。")
print("如需真正生效:UPDATE=1 python3 demo.py(脚本会自动点「更新」),")
print("或直接到浏览器点「更新」。刷新页面则一切还原。")
finally:
cdp.close()
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
# get_title.py —— 找到(或新建)商品编辑页 tab,读取标题输入框的当前内容。
#
# 逻辑:
# 1. 遍历所有 page tab,找 URL 同时含 ITEM_ID 和 shopee.tw 的编辑页;
# 2. 没有就用 Target.createTarget 新建 tab 打开 PRODUCT_URL;
# 3. 等页面渲染,轮询 XPath 取标题输入框,打印其内容。
#
# 依赖:pip install websocket-client requests
# 运行:python3 get_title.py
# 环境变量:CDP_HOST(默认 192.168.0.224:9333)、ITEM_ID(默认 51100639510)
import os
# CDP 局域网直连,绝不走代理(WSL 里设了 *_proxy 指向 :1080,会导致超时)。
for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"):
os.environ.pop(_k, None)
import json
import time
import threading
import requests
from websocket import create_connection
CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333")
BASE = f"http://{CDP_HOST}"
ITEM_ID = os.environ.get("ITEM_ID", "51100639510")
PRODUCT_URL = f"https://seller.shopee.tw/portal/product/{ITEM_ID}?pageEntry=product_list&ignore-html-cache=1"
# 标题输入框:eds 输入框且 modelvalue 长度 > 24(排除短输入框)
TITLE_XPATH = "//input[@class='eds-input__input' and string-length(@modelvalue)>24]"
def http_get(path):
s = requests.Session()
s.trust_env = False
return s.get(f"{BASE}{path}", timeout=10).json()
class CDP:
"""极简 CDP 客户端:命令同步、事件回调异步。"""
def __init__(self, ws_url):
# suppress_origin=True:不发 Origin 头,避免 Chrome 未开 --remote-allow-origins 时 403。
self.ws = create_connection(ws_url, max_size=None, suppress_origin=True)
self._id = 0
self._results = {}
self._cond = threading.Condition()
self._handlers = []
self._stop = False
threading.Thread(target=self._recv_loop, daemon=True).start()
def _recv_loop(self):
while not self._stop:
try:
raw = self.ws.recv()
except Exception:
break
if not raw:
continue
msg = json.loads(raw)
if "id" in msg:
with self._cond:
self._results[msg["id"]] = msg
self._cond.notify_all()
elif "method" in msg:
for h in self._handlers:
try:
h(msg)
except Exception:
pass
def on(self, fn):
self._handlers.append(fn)
def send(self, method, params=None, timeout=30):
self._id += 1
mid = self._id
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
deadline = time.time() + timeout
with self._cond:
while mid not in self._results:
left = deadline - time.time()
if left <= 0:
raise TimeoutError(f"CDP {method} 超时")
self._cond.wait(left)
msg = self._results.pop(mid)
if "error" in msg:
raise RuntimeError(f"{method} 失败: {msg['error'].get('message')}")
return msg.get("result", {})
def close(self):
self._stop = True
try:
self.ws.close()
except Exception:
pass
def find_edit_tab():
pages = [t for t in http_get("/json") if t.get("type") == "page"]
print(f"[*] 当前 {len(pages)} 个 page tab:")
for i, p in enumerate(pages):
print(f" [{i}] {p.get('url')}")
for p in pages:
url = p.get("url") or ""
if ITEM_ID in url and "shopee.tw" in url:
return p
return None
def open_edit_tab():
"""用 browser 级 Target.createTarget 新建 tab,返回其 targetId。"""
ver = http_get("/json/version")
browser_ws = ver["webSocketDebuggerUrl"]
bcdp = CDP(browser_ws)
try:
res = bcdp.send("Target.createTarget", {"url": PRODUCT_URL})
return res["targetId"]
finally:
bcdp.close()
def wait_page_ws(target_id, timeout=15):
"""轮询 /json 等到新 tab 的 page websocket 出现。"""
deadline = time.time() + timeout
while time.time() < deadline:
for t in http_get("/json"):
if t.get("id") == target_id and t.get("webSocketDebuggerUrl"):
return t["webSocketDebuggerUrl"]
time.sleep(0.5)
raise TimeoutError("等待新 tab 的 websocket 超时")
def read_title(cdp, timeout=40):
"""轮询 XPath,取标题输入框内容(value 优先,回退 modelvalue 属性)。"""
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 null;"
"return JSON.stringify({value:el.value,modelvalue:el.getAttribute('modelvalue')});"
"})()"
)
deadline = time.time() + timeout
while time.time() < deadline:
r = cdp.send("Runtime.evaluate", {"expression": expr, "returnByValue": True})
val = r.get("result", {}).get("value")
if val:
return json.loads(val)
time.sleep(1)
return None
def main():
tab = find_edit_tab()
if tab:
print(f"\n[*] 已存在编辑页 tab: {tab['url']}")
page_ws = tab["webSocketDebuggerUrl"]
else:
print(f"\n[*] 未找到编辑页 tab,新建并打开:\n {PRODUCT_URL}")
tid = open_edit_tab()
page_ws = wait_page_ws(tid)
print(f"[*] 新 tab 已就绪 (targetId={tid})")
cdp = CDP(page_ws)
try:
cdp.send("Page.enable")
cdp.send("Runtime.enable")
print("[*] 等待标题输入框渲染...")
title = read_title(cdp)
finally:
cdp.close()
if not title:
print("[!] 未匹配到标题输入框。页面可能没加载完,或 XPath 需要调整。")
return
content = title.get("value") or title.get("modelvalue") or ""
print("\n========== 商品标题 ==========")
print(content)
print("==============================")
print(f"\n(value={title.get('value')!r} modelvalue={title.get('modelvalue')!r})")
if __name__ == "__main__":
main()
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
# grab.py —— 通过 CDP 连接已登录的 Chrome,保存“我的商品”第一页渲染后的 HTML,
# 并拦截列表接口的 JSON 响应。
#
# 前置:Windows 上 Chrome 已带 --remote-debugging-port 启动,且 portproxy 把
# 0.0.0.0:9333 -> 127.0.0.1:9222(见 chrome-remote-debug-lan.md)。
# 依赖:pip install websocket-client requests
# 运行:python3 grab.py
#
# 环境变量(可选):
# CDP_HOST CDP 地址,默认 192.168.0.224:9333
# OUT_DIR 输出目录,默认 ./out
import os
import re
import json
import time
import base64
import pathlib
import datetime
import threading
import requests
from websocket import create_connection
CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333")
BASE = f"http://{CDP_HOST}"
OUT_DIR = pathlib.Path(os.environ.get("OUT_DIR", pathlib.Path(__file__).parent / "out"))
# 命中这些关键词的 JSON 响应,视为“商品列表接口”重点保存。
# 不同站点/版本端点名不一样,先广撒网,跑完看 _all_xhr.log 里的真实端点再收窄。
LIST_HINTS = [
"get_product_list",
"search_product",
"product/list",
"mpsku/list",
"search_item",
"product_list",
"get_item_list",
]
# CDP 是局域网直连,绝不能走代理。requests 默认会读 *_proxy 环境变量,这里强制不用代理。
NO_PROXY = {"http": None, "https": None}
def ts():
return datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
def safe(s):
return re.sub(r"[^a-z0-9._-]+", "_", s, flags=re.I)[:120]
class CDP:
"""极简 CDP 客户端:单个 page target 的 WebSocket,命令同步、事件回调异步。"""
def __init__(self, ws_url):
self.ws = create_connection(ws_url, max_size=None)
self._id = 0
self._results = {}
self._cond = threading.Condition()
self._handlers = []
self._stop = False
self._t = threading.Thread(target=self._recv_loop, daemon=True)
self._t.start()
def _recv_loop(self):
while not self._stop:
try:
raw = self.ws.recv()
except Exception:
break
if not raw:
continue
msg = json.loads(raw)
if "id" in msg:
with self._cond:
self._results[msg["id"]] = msg
self._cond.notify_all()
elif "method" in msg:
for h in self._handlers:
try:
h(msg)
except Exception as e:
print(f"[!] handler 出错: {e}")
def on(self, fn):
self._handlers.append(fn)
def send(self, method, params=None, timeout=30):
self._id += 1
mid = self._id
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
deadline = time.time() + timeout
with self._cond:
while mid not in self._results:
remaining = deadline - time.time()
if remaining <= 0:
raise TimeoutError(f"CDP {method} 超时")
self._cond.wait(remaining)
msg = self._results.pop(mid)
if "error" in msg:
raise RuntimeError(f"{method} 失败: {msg['error'].get('message')}")
return msg.get("result", {})
def close(self):
self._stop = True
try:
self.ws.close()
except Exception:
pass
def pick_target(pages):
# 选中卖家中心“我的商品”那一页
for p in pages:
if re.search(r"seller\..*shopee", p["url"], re.I) and re.search(r"product|portal", p["url"], re.I):
return p
for p in pages:
if re.search(r"seller\..*shopee", p["url"], re.I):
return p
return pages[0] if pages else None
def main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
print(f"[*] 读取目标列表: {BASE}/json")
targets = requests.get(f"{BASE}/json", proxies=NO_PROXY, timeout=10).json()
pages = [t for t in targets if t.get("type") == "page"]
print(f"[*] 共 {len(pages)} 个 page 标签页:")
for i, p in enumerate(pages):
print(f" [{i}] {p['url']}")
target = pick_target(pages)
if not target:
raise SystemExit("没找到任何 page 标签页,Chrome 是否带调试参数启动?")
print(f"[*] 使用标签页: {target['url']}")
cdp = CDP(target["webSocketDebuggerUrl"])
cdp.send("Page.enable")
cdp.send("Network.enable")
xhr_lines = []
tracked = {} # requestId -> url(疑似列表接口,待取 body)
state = {"loaded": False, "hits": 0}
def on_event(msg):
method = msg["method"]
if method == "Page.loadEventFired":
state["loaded"] = True
return
if method == "Network.responseReceived":
p = msg["params"]
resp, rtype = p["response"], p.get("type")
if rtype not in ("XHR", "Fetch"):
return
mime = (resp.get("mimeType") or "").lower()
if "json" not in mime:
return
url = resp["url"]
xhr_lines.append(f"{resp.get('status')} {url}")
if any(h in url.lower() for h in LIST_HINTS):
tracked[p["requestId"]] = url
return
if method == "Network.loadingFinished":
rid = msg["params"]["requestId"]
url = tracked.pop(rid, None)
if url is None:
return
try:
r = cdp.send("Network.getResponseBody", {"requestId": rid})
body = base64.b64decode(r["body"]).decode("utf-8", "replace") if r.get("base64Encoded") else r["body"]
state["hits"] += 1
fname = f"list_{state['hits']}_{safe(url.split('?')[0].rsplit('/', 1)[-1] or 'list')}.json"
(OUT_DIR / fname).write_text(body, encoding="utf-8")
print(f"[+] 命中列表接口 -> out/{fname}")
except Exception as e:
print(f"[!] 取 body 失败 ({url}): {e}")
cdp.on(on_event)
print("[*] 重新加载页面以触发接口请求...")
cdp.send("Page.reload", {"ignoreCache": False})
# 等 load 事件 + 额外等晚到的 XHR
for _ in range(30):
if state["loaded"]:
break
time.sleep(0.5)
time.sleep(4)
# 保存渲染后的整页 HTML
r = cdp.send("Runtime.evaluate", {
"expression": "document.documentElement.outerHTML",
"returnByValue": True,
})
html = "<!DOCTYPE html>\n" + (r.get("result", {}).get("value") or "")
html_path = OUT_DIR / f"my-products-page1_{ts()}.html"
html_path.write_text(html, encoding="utf-8")
print(f"[+] 已保存 HTML -> {html_path}")
# 落盘全部 XHR 端点清单
log_path = OUT_DIR / "_all_xhr.log"
log_path.write_text("\n".join(xhr_lines) + "\n", encoding="utf-8")
print(f"[*] 全部 JSON XHR 端点 -> {log_path}")
if state["hits"] == 0:
print("[!] 未命中列表接口。打开 out/_all_xhr.log 找真实端点,把关键词加进 grab.py 的 LIST_HINTS。")
else:
print(f"[*] 共抓到 {state['hits']} 个列表接口响应。")
cdp.close() # 仅断开 CDP,不会关你的 Chrome
print("[*] 完成。")
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# inspect_images.py —— 只读探查商品图片管理器的 DOM 结构,为实现换封面提供依据。
import os
for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"):
os.environ.pop(_k, None)
import json
import time
import threading
import requests
from websocket import create_connection
CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333")
BASE = f"http://{CDP_HOST}"
ITEM_ID = os.environ.get("ITEM_ID", "51100639510")
def http_get(path):
s = requests.Session(); s.trust_env = False
return s.get(f"{BASE}{path}", timeout=10).json()
class CDP:
def __init__(self, ws):
self.ws = create_connection(ws, max_size=None, suppress_origin=True)
self._id = 0; self._res = {}; self._c = threading.Condition(); self._stop = False
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self):
while not self._stop:
try: raw = self.ws.recv()
except Exception: break
if not raw: continue
m = json.loads(raw)
if "id" in m:
with self._c: self._res[m["id"]] = m; self._c.notify_all()
def send(self, method, params=None, timeout=30):
self._id += 1; mid = self._id
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
end = time.time() + timeout
with self._c:
while mid not in self._res:
left = end - time.time()
if left <= 0: raise TimeoutError(method)
self._c.wait(left)
m = self._res.pop(mid)
if "error" in m: raise RuntimeError(m["error"].get("message"))
return m.get("result", {})
def eval(self, expr):
r = self.send("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def close(self):
self._stop = True
try: self.ws.close()
except Exception: pass
def main():
pages = [t for t in http_get("/json") if t.get("type") == "page"]
tab = next((p for p in pages if ITEM_ID in (p.get("url") or "") and "shopee.tw" in (p.get("url") or "")), None)
if not tab:
print("[!] 没找到编辑页 tab"); return
cdp = CDP(tab["webSocketDebuggerUrl"])
cdp.send("Runtime.enable")
expr = r"""
(function(){
var xp="//div[@class='container']/div[@class='can-drag shopee-image-manager__itembox' and @data-draggable='true']";
var snap=document.evaluate(xp,document,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);
var n=snap.snapshotLength;
var first=n>0?snap.snapshotItem(0):null;
var fileInputs=[].slice.call(document.querySelectorAll('input[type=file]')).map(function(f){
return {accept:f.accept,multiple:f.multiple,name:f.name,cls:f.className,hidden:(f.offsetParent===null)};
});
// 在图片管理器范围内找“设为封面/删除”等小按钮
var mgr=document.querySelector('.shopee-image-manager')||document.body;
var btns=[].slice.call(mgr.querySelectorAll('*')).filter(function(e){
var t=(e.innerText||'').trim();
return t && t.length<12 && /(封面|設為|设为|cover|刪除|删除|delete|主圖|主图)/i.test(t);
}).slice(0,12).map(function(e){return {tag:e.tagName,cls:String(e.className).slice(0,80),text:(e.innerText||'').trim()};});
// 第一个 itembox 的结构(截断),看 hover 操作层/删除按钮
var firstHTML=first?first.outerHTML.replace(/\s+/g,' ').slice(0,2500):null;
// 拖拽机制线索:draggable 属性 / 事件库特征
var dragInfo=null;
if(first){
dragInfo={
htmlDraggable:first.getAttribute('draggable'),
dataDraggable:first.getAttribute('data-draggable'),
cls:first.className
};
}
// 上传按钮(“新增/上傳/+”)
var addBtn=[].slice.call(mgr.querySelectorAll('*')).filter(function(e){
var t=(e.innerText||'').trim();
return t && t.length<12 && /(新增|上傳|上传|添加|\+)/.test(t);
}).slice(0,6).map(function(e){return {tag:e.tagName,cls:String(e.className).slice(0,80),text:(e.innerText||'').trim()};});
return JSON.stringify({count:n,fileInputs:fileInputs,actionBtns:btns,addBtns:addBtn,dragInfo:dragInfo,firstHTML:firstHTML},null,2);
})()
"""
out = cdp.eval(expr)
print(out)
cdp.close()
if __name__ == "__main__":
main()
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
# set_cover.py —— 上传一张图片到商品图片管理器,等上传成功后拖到第一位(封面)。不保存。
#
# 依赖:pip install websocket-client requests
# 运行:python3 set_cover.py
# 环境变量:CDP_HOST(默认 192.168.0.224:9333)、ITEM_ID(默认 51100639510)、
# IMG_WIN(Chrome 所在 Windows 上的图片路径,默认 D:\chengma\cmshopee\1_TY030.jpg)
import os
for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"):
os.environ.pop(_k, None)
import json
import time
import threading
import requests
from websocket import create_connection
CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333")
BASE = f"http://{CDP_HOST}"
ITEM_ID = os.environ.get("ITEM_ID", "51100639510")
IMG_WIN = os.environ.get("IMG_WIN", r"D:\chengma\cmshopee\1_TY030.jpg")
XPATH = "//div[@class='container']/div[@class='can-drag shopee-image-manager__itembox' and @data-draggable='true']"
def http_get(path):
s = requests.Session(); s.trust_env = False
return s.get(f"{BASE}{path}", timeout=10).json()
class CDP:
def __init__(self, ws):
self.ws = create_connection(ws, max_size=None, suppress_origin=True)
self._id = 0; self._res = {}; self._c = threading.Condition(); self._stop = False
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self):
while not self._stop:
try: raw = self.ws.recv()
except Exception: break
if not raw: continue
m = json.loads(raw)
if "id" in m:
with self._c: self._res[m["id"]] = m; self._c.notify_all()
def send(self, method, params=None, timeout=30):
self._id += 1; mid = self._id
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
end = time.time() + timeout
with self._c:
while mid not in self._res:
left = end - time.time()
if left <= 0: raise TimeoutError(method)
self._c.wait(left)
m = self._res.pop(mid)
if "error" in m: raise RuntimeError(f"{method}: {m['error'].get('message')}")
return m.get("result", {})
def ev(self, expr, by_value=True):
r = self.send("Runtime.evaluate", {"expression": expr, "returnByValue": by_value})
if "exceptionDetails" in r:
raise RuntimeError("JS 异常: " + r["exceptionDetails"].get("text", ""))
return r.get("result", {})
def val(self, expr):
return self.ev(expr).get("value")
def close(self):
self._stop = True
try: self.ws.close()
except Exception: pass
# 返回各 itembox 的 [{i,x,y,left,top,w,h,src}]
JS_RECTS = (
"(function(){"
f"var s=document.evaluate({json.dumps(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);})()"
)
def rects(cdp):
return json.loads(cdp.val(JS_RECTS))
def drag(cdp, x0, y0, x1, y1, steps=34):
me = "Input.dispatchMouseEvent"
cdp.send(me, {"type": "mouseMoved", "x": x0, "y": y0})
cdp.send(me, {"type": "mousePressed", "x": x0, "y": y0, "button": "left", "buttons": 1, "clickCount": 1})
# 先小幅抖动,越过拖拽启动阈值
for dx in (3, 6, 10):
cdp.send(me, {"type": "mouseMoved", "x": x0 + dx, "y": y0, "button": "left", "buttons": 1})
time.sleep(0.03)
for i in range(1, steps + 1):
x = x0 + (x1 - x0) * i / steps
y = y0 + (y1 - y0) * i / steps
cdp.send(me, {"type": "mouseMoved", "x": x, "y": y, "button": "left", "buttons": 1})
time.sleep(0.025)
for _ in range(10): # 在目标位置停留,让排序库吸附
cdp.send(me, {"type": "mouseMoved", "x": x1, "y": y1, "button": "left", "buttons": 1})
time.sleep(0.05)
cdp.send(me, {"type": "mouseReleased", "x": x1, "y": y1, "button": "left", "buttons": 0, "clickCount": 1})
def main():
pages = [t for t in http_get("/json") if t.get("type") == "page"]
tab = next((p for p in pages if ITEM_ID in (p.get("url") or "") and "shopee.tw" in (p.get("url") or "")), None)
if not tab:
print("[!] 没找到编辑页 tab"); return
cdp = CDP(tab["webSocketDebuggerUrl"])
cdp.send("DOM.enable"); cdp.send("Runtime.enable")
# 滚动到图片管理器,确保坐标在可视区
cdp.val("(function(){var m=document.querySelector('.shopee-image-manager');"
"if(m)m.scrollIntoView({block:'center'});return !!m;})()")
time.sleep(0.5)
before = rects(cdp)
before_srcs = [r["src"] for r in before]
print(f"[*] 上传前 {len(before)} 张图:")
for r in before:
print(f" [{r['i']}] {r['src']}")
# ---- 注入文件到商品图 file input ----
print(f"\n[*] 上传文件: {IMG_WIN}")
# 正确的上传输入框:在“新增圖片”按钮区 .shopee-image-manager__upload 内
obj = cdp.ev(
"document.querySelector('.shopee-image-manager__upload input[type=file]')",
by_value=False,
)
oid = obj.get("objectId")
if not oid:
print("[!] 没找到商品图 file input,停止。"); cdp.close(); return
cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [IMG_WIN]})
# ---- 等上传成功:张数增加,且新图 src 是 susercontent CDN 链接 ----
print("[*] 等待上传完成(轮询新图出现且为 CDN 链接)...")
new_src = None
deadline = time.time() + 90
while time.time() < deadline:
time.sleep(1.5)
cur = rects(cdp)
if len(cur) > len(before):
# 找出新增的、且 src 已是正式 CDN 链接的那张
news = [r for r in cur if r["src"] not in before_srcs]
ready = [r for r in news if r["src"] and "susercontent" in r["src"] and "blob:" not in r["src"]]
if ready:
new_src = ready[-1]["src"]
print(f"[+] 上传成功,新图:{new_src} 当前 {len(cur)} 张")
break
else:
print(f" ...已出现新图但还在处理中({len(cur)} 张)")
if not new_src:
print("[!] 等待上传超时/未确认成功。可能弹了裁剪框或需要确认,请看浏览器。")
cdp.close(); return
# ---- 拖拽:把新图(最后一张)拖到第一位 ----
time.sleep(1)
cur = rects(cdp)
last = cur[-1]
first = cur[0]
# 目标落点:第一张的左缘【外侧】,越过其左半区,排序库才会插到最前
tx = first["left"] - first["w"] * 0.30
ty = first["y"]
print(f"\n[*] 拖拽:从 last[{last['i']}]({last['x']:.0f},{last['y']:.0f}) -> 第一位前({tx:.0f},{ty:.0f})")
drag(cdp, last["x"], last["y"], tx, ty)
time.sleep(1.2)
# ---- 核对:第一张是不是刚上传的新图 ----
after = rects(cdp)
print(f"\n[*] 拖拽后顺序({len(after)} 张):")
for r in after:
flag = " <== 新图" if r["src"] == new_src else ""
print(f" [{r['i']}] {r['src']}{flag}")
if after and after[0]["src"] == new_src:
print("\n[*] ✅ 新图已在第一位(封面)。未保存——请到浏览器核对,确认后再走保存。")
else:
idx = next((r["i"] for r in after if r["src"] == new_src), None)
print(f"\n[!] 新图当前在第 {idx} 位,未到第一位。拖拽可能需要微调(步数/落点/事件类型)。未保存。")
cdp.close()
if __name__ == "__main__":
main()
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
# set_title.py —— 在商品编辑页写入新标题(让 Vue/eds 组件感知),可选点击保存。
#
# 三种模式:
# 1) 默认(自检):临时改成 原标题+(測試)→ 验证写入生效 → 还原原标题。不点保存,零持久化。
# 2) 设值不保存: NEW_TITLE="新标题" python3 set_title.py —— 写入但不保存,你手动检查/保存。
# 3) 设值并保存: NEW_TITLE="新标题" SAVE=1 python3 set_title.py —— 写入并点击保存按钮(真改!)。
#
# 依赖:pip install websocket-client requests
# 环境变量:CDP_HOST(默认 192.168.0.224:9333)、ITEM_ID(默认 51100639510)、NEW_TITLE、SAVE
import os
for _k in ("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"):
os.environ.pop(_k, None)
import json
import time
import threading
import requests
from websocket import create_connection
CDP_HOST = os.environ.get("CDP_HOST", "192.168.0.224:9333")
BASE = f"http://{CDP_HOST}"
ITEM_ID = os.environ.get("ITEM_ID", "51100639510")
PRODUCT_URL = f"https://seller.shopee.tw/portal/product/{ITEM_ID}?pageEntry=product_list&ignore-html-cache=1"
NEW_TITLE = os.environ.get("NEW_TITLE")
DO_SAVE = os.environ.get("SAVE") == "1"
TITLE_XPATH = "//input[@class='eds-input__input' and string-length(@modelvalue)>24]"
def http_get(path):
s = requests.Session()
s.trust_env = False
return s.get(f"{BASE}{path}", timeout=10).json()
class CDP:
def __init__(self, ws_url):
self.ws = create_connection(ws_url, max_size=None, suppress_origin=True)
self._id = 0
self._results = {}
self._cond = threading.Condition()
self._stop = False
threading.Thread(target=self._recv_loop, daemon=True).start()
def _recv_loop(self):
while not self._stop:
try:
raw = self.ws.recv()
except Exception:
break
if not raw:
continue
msg = json.loads(raw)
if "id" in msg:
with self._cond:
self._results[msg["id"]] = msg
self._cond.notify_all()
def send(self, method, params=None, timeout=30):
self._id += 1
mid = self._id
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
deadline = time.time() + timeout
with self._cond:
while mid not in self._results:
left = deadline - time.time()
if left <= 0:
raise TimeoutError(f"CDP {method} 超时")
self._cond.wait(left)
msg = self._results.pop(mid)
if "error" in msg:
raise RuntimeError(f"{method} 失败: {msg['error'].get('message')}")
return msg.get("result", {})
def eval(self, expr):
r = self.send("Runtime.evaluate", {"expression": expr, "returnByValue": True})
if "exceptionDetails" in r:
raise RuntimeError(f"页面 JS 异常: {r['exceptionDetails'].get('text')}")
return r.get("result", {}).get("value")
def close(self):
self._stop = True
try:
self.ws.close()
except Exception:
pass
def find_edit_tab():
pages = [t for t in http_get("/json") if t.get("type") == "page"]
for p in pages:
url = p.get("url") or ""
if ITEM_ID in url and "shopee.tw" in url:
return p
return None
def open_edit_tab():
ver = http_get("/json/version")
bcdp = CDP(ver["webSocketDebuggerUrl"])
try:
tid = bcdp.send("Target.createTarget", {"url": PRODUCT_URL})["targetId"]
finally:
bcdp.close()
deadline = time.time() + 15
while time.time() < deadline:
for t in http_get("/json"):
if t.get("id") == tid and t.get("webSocketDebuggerUrl"):
return t["webSocketDebuggerUrl"]
time.sleep(0.5)
raise TimeoutError("等待新 tab websocket 超时")
# ---- 读取当前标题 ----
def js_read():
return (
"(function(){"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
"XPathResult.FIRST_ORDERED_NODE_TYPE,null);"
"var el=r.singleNodeValue;"
"return el?el.value:null;})()"
)
# ---- 写入新标题:原生 setter + 派发事件,让 Vue v-model 感知 ----
def js_write(new_title):
return (
"(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 setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;"
"el.focus();"
f"setter.call(el,{json.dumps(new_title)});"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"el.dispatchEvent(new Event('change',{bubbles:true}));"
"el.blur();"
"return el.value;})()"
)
def wait_title(cdp, timeout=40):
deadline = time.time() + timeout
while time.time() < deadline:
v = cdp.eval(js_read())
if v:
return v
time.sleep(1)
return None
def click_save(cdp):
# 按钮文字常见为 儲存/保存/Save;找可见且可点击的那个。
expr = (
"(function(){"
"var btns=[].slice.call(document.querySelectorAll('button'));"
"var t=btns.filter(function(b){var s=(b.innerText||'').trim();"
"return /儲存|保存|Save/i.test(s) && b.offsetParent!==null && !b.disabled;});"
"if(!t.length)return 'NO_SAVE_BTN';"
"t[0].click();return 'CLICKED:'+(t[0].innerText||'').trim();})()"
)
return cdp.eval(expr)
def main():
tab = find_edit_tab()
if tab:
print(f"[*] 复用编辑页 tab: {tab['url']}")
page_ws = tab["webSocketDebuggerUrl"]
else:
print(f"[*] 新建 tab 打开: {PRODUCT_URL}")
page_ws = open_edit_tab()
cdp = CDP(page_ws)
try:
cdp.send("Runtime.enable")
print("[*] 等待标题输入框渲染...")
original = wait_title(cdp)
if not original:
print("[!] 没读到标题输入框,退出。")
return
print(f"[*] 当前标题: {original}")
if NEW_TITLE is None:
# ---- 自检模式:临时改 → 验证 → 还原,全程不保存 ----
test = (original + "(測試)")[:255]
print(f"\n[自检] 临时写入: {test}")
cdp.eval(js_write(test))
time.sleep(0.5)
after = cdp.eval(js_read())
print(f"[自检] 读回: {after}")
ok = after == test
print(f"[自检] 写入生效: {'✅ 是' if ok else '❌ 否'}")
print(f"\n[自检] 还原原标题: {original}")
cdp.eval(js_write(original))
time.sleep(0.5)
restored = cdp.eval(js_read())
print(f"[自检] 读回: {restored}")
print(f"[自检] 已还原: {'✅ 是' if restored == original else '❌ 否(请手动检查)'}")
print("\n[自检] 全程未点保存,页面未持久化任何改动。")
print(" 真改请用: NEW_TITLE=\"新标题\" SAVE=1 python3 set_title.py")
return
# ---- 真实写入 ----
print(f"\n[*] 写入新标题: {NEW_TITLE}")
cdp.eval(js_write(NEW_TITLE))
time.sleep(0.5)
after = cdp.eval(js_read())
# modelvalue 是 Vue 模型反向绑定到 DOM 的属性,等于新值才说明 v-model 真的更新了
model = cdp.eval(
"(function(){"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
"XPathResult.FIRST_ORDERED_NODE_TYPE,null);"
"var el=r.singleNodeValue;return el?el.getAttribute('modelvalue'):null;})()"
)
print(f"[*] 读回 value: {after}")
print(f"[*] 读回 modelvalue: {model}")
if after != NEW_TITLE:
print("[!] value 与新标题不一致,停止(不保存)。")
return
if model != NEW_TITLE:
print("[!] modelvalue 未跟随更新,Vue 模型可能没绑定,保存会提交旧值,停止(不保存)。")
return
print("[*] ✅ value 与 modelvalue 均=新标题,Vue 模型已更新。")
if not DO_SAVE:
print("\n[*] 已写入但未保存(SAVE!=1)。请到浏览器里核对后手动保存,或加 SAVE=1 重跑。")
return
print("[*] 点击保存按钮...")
r = click_save(cdp)
print(f"[*] 保存结果: {r}")
if r.startswith("CLICKED"):
print("[*] 已触发保存。请到浏览器确认是否有校验弹窗/成功提示。")
finally:
cdp.close()
if __name__ == "__main__":
main()