diff --git a/skills/cross-border-ecommerce/163-mail-browser-automation/SKILL.md b/skills/cross-border-ecommerce/163-mail-browser-automation/SKILL.md new file mode 100644 index 0000000..cd53a96 --- /dev/null +++ b/skills/cross-border-ecommerce/163-mail-browser-automation/SKILL.md @@ -0,0 +1,1167 @@ +--- +name: 163-mail-browser-automation +description: 通过 CDP 鼠标模拟在 Desktop Chrome 上操作163邮箱——点击邮件条目、标记全部已读、逐封读取内容、QP解码正文。也支持 Playwright Headless 作为 fallback。用于 Ozon 店铺邮件管理、验证码提取和订单通知处理。 +version: 2.0 +trigger: 读取163邮件, 查看网易邮箱, 163邮箱未读, 标记全部已读, 网易邮件操作 +--- + +# 163邮箱 Playwright 自动化 + +## 适用场景 +- Cloud Bridge 桌面不在线时,用服务器端 Playwright 替代 +- 需要自动化读取163邮箱中的验证码或通知 + +## 登录流程 + +### 1. 启动浏览器 +```python +browser = await p.chromium.launch( + headless=True, + args=["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"] +) +context = await browser.new_context( + viewport={"width": 1280, "height": 900}, + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" +) +``` + +### 2. 填写登录表单(URS iframe) +163登录表单在 URS iframe (`dl.reg.163.com`) 中,需要: +- 找到 URS frame: `for f in page.frames: if "dl.reg.163.com" in f.url` +- **React 兼容输入**:必须用 `nativeInputValueSetter` + `dispatchEvent` + +```javascript +// 在 URS frame 中执行 +() => { + document.querySelector('#pwdtext') && (document.querySelector('#pwdtext').style.display = 'none'); + const pwd = document.querySelector('input[name="password"]'); + if (pwd) pwd.style.display = ''; + const email = document.querySelector('input[name="email"]'); + if (email) { email.value = 'smthzqjone'; email.dispatchEvent(new Event('input', {bubbles: true})); } + if (pwd) { pwd.value = 'Bingo2025'; pwd.dispatchEvent(new Event('input', {bubbles: true})); } + setTimeout(() => document.querySelector('#dologin')?.click(), 600); +} +``` + +**关键坑**: +- `#pwdtext` 是密码遮罩层,需先隐藏才能操作 password input +- 直接 `.value = xxx` 不会触发 React onChange,必须 dispatchEvent +- 等待登录成功:轮询 URL 中 `sid=xxx`,最多等40秒 + +### 3. 登录成功标志 +URL 变为 `https://hw.mail.163.com/js6/main.jsp?sid=XXXX...`,提取 SID 用于 API 调用。 + +## 读取邮件列表(API 方式) + +```javascript +const r = await fetch('/js6/s?sid=SID&func=mbox:listMessages', { + method: 'POST', + body: JSON.stringify({"var":{"0":{"fid":"1","start":0,"limit":20}}}) +}); +return await r.text(); // 返回 XML +``` + +**返回格式为 XML**(非 JSON),用 `xml.etree.ElementTree` 解析。每条消息是 `` 元素,嵌套 `` 含已读状态。 + +## 读取邮件内容 + +### ❌ 不工作的方法 +1. `read:readMessage` API → 返回 `FR_INVALID_REQUEST` +2. 直接导航 `readmail.jsp?sid=...&mid=...` → 返回 500 +3. 点击收件箱中的邮件行 → 元素定位不稳定 + +### ✅ Hash 导航 + iframe 读取(唯一可靠方案) + +```javascript +// 步骤1: hash 导航 +location.hash = 'module=read.ReadModule|{"mid":"703:xtbCvxxBZWoMkfyc8QAA3h"}'; +``` + +```python +# 步骤2: 等待 ≥8秒(关键!少于6秒 iframe 未加载完) +await page.wait_for_timeout(8000) +``` + +```javascript +// 步骤3: 从 iframe contentDocument 读取 +() => { + const iframes = document.querySelectorAll('iframe'); + let bestText = ''; + for (const iframe of iframes) { + if (iframe.offsetWidth < 100 || iframe.offsetHeight < 50) continue; + try { + const doc = iframe.contentDocument; + if (doc && doc.body) { + const clone = doc.body.cloneNode(true); + clone.querySelectorAll('style, script, link, noscript').forEach(s => s.remove()); + const text = (clone.innerText || '').trim(); + if (text.length > bestText.length && text.length > 30) bestText = text; + } + } catch(e) {} + } + return bestText.substring(0, 5000); +} +``` + +### 关键陷阱 + +1. **等待时间 ≥8秒**:iframe 内容异步加载,6秒内读取会得到空内容 +2. **iframe 累积问题**:每次 hash 导航新增一个 `readhtml.jsp?mid=...` iframe,旧的不会清除。解决方案:按 iframe 尺寸取最大的,或通过 mid 匹配 src +3. **内容清理**:innerText 会混入 CSS 样式代码(零宽字符、`_viewport`、`@media` 等),需过滤 +4. **安全浏览模式**:163可能显示"显示内容"按钮,需额外点击才能加载 iframe + +## 内容清理模板 + +```python +def clean_email_text(txt): + lines = [l.strip() for l in txt.split('\n') if l.strip() and len(l.strip()) > 2] + kw = '订单商品验证密码登录通知提醒取消确认OzonУважаемыйЗдравствуйтеол' + seen = set() + clean = [] + for l in lines: + if any(l.startswith(x) for x in ['//','/*','*','var ','const ','let ','function ', + 'document.','window.','if(','for(','while(']): continue + if 'important;' in l or 'background-color' in l or 'color-scheme' in l: continue + if l.startswith('_viewport') or l.startswith('@media') or l.startswith(':root'): continue + if 'querySelector' in l or 'dispatchEvent' in l: continue + if l.startswith('{') and l.endswith('}'): continue + if l.startswith('.') and len(l.split()) < 3 and '{' in l: continue + if l in seen: continue + seen.add(l) + clean.append(l) + return clean +``` + +## 账号信息 +- smthzqjone@163.com / Bingo2025(Ozon Hzqjone 店铺) +- smthzqjtwo@163.com / Bingo2025(Ozon Hzqjtwo 店铺) + +### Cloud Bridge CDP 方式(Desktop 在线时的首选) + +完整的 CDP 交互跟踪见 `references/cdp-urs-login-trace-2026-06.md`(包含从 attach→navigate→iframe→login→fetch list 的完整序列)。 + +**逐行点击标记已读脚本**:`scripts/click_rows_mark_read.py` — 通过 cronjob no_agent 批量点击每封邮件条目打开后自动标记已读。找到163 tab → 进入收件箱 → 获取行坐标 → 逐封点击+返回列表。每封约0.6秒。 + +### 流程 +1. `GET /cloud-bridge/health` → 确认 `connected: true, cdpEnabled: true` +2. `POST /cdp/attach {}` → 获取 targetId +3. 导航用 `window.open()` 而非 `window.location.href`(避免 CDP 断连) +4. 输入框用 `nativeInputValueSetter` + `dispatchEvent(new Event('input'))` 设置值 + +### 163邮箱 CDP 邮件读取流程(推荐 API 方式,无需 UI 交互) + +**步骤1: 获取 SID** — 导航到 mail.163.com 后,从 URL 或 iframe src 中提取 `sid=xxx` + +**步骤2: 提取 Cookie** — 通过 CDP `Network.getAllCookies` 获取完整 cookie 字符串 + +```python +payload = json.dumps({"method": "Network.getAllCookies"}) +# 写入文件避免 shell 转义问题 +with open('/tmp/cdp_req.json', 'w') as f: + f.write(payload) +r = terminal(f"curl -s -X POST '{BASE}/cdp/send?key={KEY}&targetId={TID}' " + f"-H 'Content-Type: application/json' -d @/tmp/cdp_req.json") +cookies_data = json.loads(r['output']) +cookies = cookies_data['result']['cookies'] +cookie_str = '; '.join(f"{c['name']}={c['value']}" for c in cookies) +# 保存后续复用 +with open('/tmp/163_cookies.txt', 'w') as f: + f.write(cookie_str) +``` + +**步骤3: 直接用 curl + cookie 调用163 API(服务器端请求,避开浏览器内 CSRF)** + +```python +# 邮件列表(返回 XML) +SID = "从步骤1提取" +url = f"https://mail.163.com/js6/s?sid={SID}&func=mbox:listMessages" +list_payload = json.dumps({"var": {"0": {"fid": "1", "start": 0, "limit": 20}}}) +with open('/tmp/list_payload.json', 'w') as f: + f.write(list_payload) +r = terminal( + f'COOKIE="$(cat /tmp/163_cookies.txt)" && ' + f'curl -s -H "Cookie: $COOKIE" -H "Referer: https://mail.163.com/" ' + f'-H "Content-Type: application/json" -d @/tmp/list_payload.json "{url}"', + timeout=15 +) +# 用 xml.etree.ElementTree 解析返回的 XML + +# 邮件原始内容(返回 MIME 原文含 HTML) +mid = "753:xtbC8R6NK2oNAr4gjQAA3-" # 从列表 XML 中提取 +msg_url = f"https://mail.163.com/js6/s?sid={SID}&func=mbox:getMessageData&mid={mid_enc}&mode=html" +r = terminal( + f'COOKIE="$(cat /tmp/163_cookies.txt)" && ' + f'curl -s -H "Cookie: $COOKIE" -H "Referer: https://mail.163.com/" "{msg_url}"', + timeout=15 +) +``` + +**步骤4: 解析 MIME 原文提取 HTML body** + +```python +import quopri, base64, re + +parts = re.split(r'------=_Part_', raw_response) +for part in parts: + ctype = re.search(r'Content-Type:\s*([^;\r\n]+)', part) + if ctype and 'html' in ctype.group(1).lower(): + enc = re.search(r'Content-Transfer-Encoding:\s*(\S+)', part) + body_m = re.search(r'\r?\n\r?\n(.*)', part, re.DOTALL) + if body_m: + body = body_m.group(1).split('\r\n------=')[0] + if enc and 'quoted-printable' in enc.group(1).lower(): + html = quopri.decodestring(body.encode()).decode('utf-8', errors='replace') + elif enc and 'base64' in enc.group(1).lower(): + html = base64.b64decode(body.strip()).decode('utf-8', errors='replace') +``` + +### ⚠️ 关键陷阱:163 安全浏览模式阻断图片验证码 + +Ozon 验证/凭证确认邮件的核心内容(验证码数字、确认按钮)是通过 **图片渲染** 的。163 安全浏览模式会**清空所有 `` 的 `src` 和 `alt` 属性**,导致: + +- API 提取的 HTML 中 `` — 完全空白 +- 纯文本提取只能拿到导航菜单文字,拿不到验证码 +- `s.ozon.ru/r/` 短链接是营销推广链接,不是验证链接 + +**方案A(推荐):从 getMessageData 原始 MIME 中直接提取验证码** + +Ozon 验证码虽然是图片渲染,但图片的 `alt` 属性在 MIME 原文中有完整的 6 位数字! +关键是:必须在 `getMessageData` API 返回的 **原始 MIME 内容** 中搜索,而不是从浏览器渲染后的 DOM 中读取。 + +```python +# 验证码提取策略:搜索大字体数字(验证码通常用 24px+ 大字体渲染) +# 方法1: 正则匹配 kod: 后面的数字(俄文 "код" 的 QP 编码) +codeRe = /=D0=BA=D0=BE=D0=B4:[\s\S]{0,500}?\b(\d{6})\b/i + +# 方法2: 搜索大字体样式中紧跟的6位数字(排除CSS颜色值如 070707/000000/999999) +bigNumRe = /font-size:\s*\d{2,3}px[\s\S]{0,200}?\b(\d{6})\b/g +# 过滤:排除颜色代码 070707, 000000, 999999, 667585 等 CSS 色值 +# Ozon 邮件中 footer 的字体颜色 #587410 会反复出现,需要排除 +# Ozon 邮件底部公司地址含邮编(123112 莫斯科),也会被误判为验证码 +css_colors = {'070707','000000','999999','587410','667585','ffffff','f5f5f5', + 'fbe34c','1d2024','f5f7fa','e6e8ec','23262b','edbd0e','333333'} +address_codes = {'123112'} # Ozon 莫斯科办公室邮编 +valid_codes = [n for n in bigNums if n not in css_colors and n not in address_codes] + +# 方法3: 搜索 alt="<6位数字>" 的 img 标签 +altRe = /alt="(\d{6})"/g +``` + +**方案B:浏览器中点击"显示内容"/"完整信息"按钮** + +163 邮箱有两种安全模式UI: +- "显示内容"按钮 — 加载被屏蔽的图片/CSS +- "精简信息k" / "完整信息j" 开关 — 切换精简/完整模式 + +点击"完整信息":用 JS `.click()` 即可(无需 CDP 鼠标事件),即使元素不可见(display:none)也能触发: +```javascript +document.querySelector('[data-action="showFullInfo"]')?.click(); +// 或者遍历所有含"完整信息"文本的元素 +[...document.querySelectorAll('*')].find(el => el.innerText === '完整信息')?.click(); +``` + +点击后需等待 iframe 重新加载(约3-5秒),再用 `Page.createIsolatedWorld` 读取。 + +### ⚠️ 绝对禁止:同步 XMLHttpRequest 阻塞 JS 线程 + +**在浏览器 evaluate 中使用同步 XHR (`new XMLHttpRequest(); xhr.open(..., false)`) 调用163内部 API 会永久阻塞 Chrome 标签页的 JS 线程!** 导致: +- 所有后续 `cdp/evaluate` 调用超时 +- `cdp/navigate` 超时(需页面JS响应) +- 即使 `cdp/detach` + `cdp/attach` 也无法恢复 +- 唯一恢复方式:`Page.reload`(CDP 直接发送,不走 JS 线程) + +```python +# ❌ 永远不要这样做: +json={"expression": """ +(() => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/js6/s?sid=...&func=mbox:getMessageData', false); // 同步!阻塞! + xhr.send('...'); + return xhr.responseText; +})() +"""} + +# ✅ 正确方式:页面内异步 fetch + 全局变量存储结果 +json={"expression": """ +(async () => { + const r = await fetch('/js6/s?sid=...&func=mbox:listMessages', { + method: 'POST', + body: JSON.stringify({"var":{"0":{"fid":"1","start":0,"limit":20}}}) + }); + return await r.text(); +})() +"""} +# 注意:异步 evaluate 可能返回 Promise,CDP 会等待 resolve + +# ✅ 另一种正确方式:从页面提取 SID + Cookie,用 curl 在服务器端调 API +# 见上文"步骤3: 直接用 curl + cookie 调用163 API" +``` + +### Chrome JS 线程阻塞恢复方案 + +如果不慎用同步 XHR 阻塞了 Chrome: + +```python +# 1. CDP 直接命令不受 JS 线程影响 +r = requests.post(f'{BASE}/cdp/send?key={KEY}', + json={"method": "Page.reload"}, # ✅ 可以恢复! + timeout=30) + +# 2. detach + attach 不够——旧 JS 执行上下文仍在阻塞 +r = requests.post(f'{BASE}/cdp/detach?key={KEY}', json={}, timeout=10) +r = requests.post(f'{BASE}/cdp/attach?key={KEY}', json={}, timeout=15) +# ❌ 仍然超时——JS 线程没有被释放 + +# 3. Page.reload 后需等待 ~15秒 让163页面完全加载 +time.sleep(15) +r = requests.post(f'{BASE}/cdp/send', + json={'method': 'Runtime.evaluate', + 'params': {'expression': '1+1', 'returnByValue': True}, + 'sessionId': SID}, + timeout=20) +``` + +### Cross-Origin iframe 登录:Page.createIsolatedWorld 模式(CDP) + +163 登录表单在 URS iframe (`dl.reg.163.com`) 中,跨域限制导致 `contentDocument` 无法从主页面访问。`Page.createIsolatedWorld` 在 iframe 的 frameId 上创建隔离执行上下文,返回的 `executionContextId` 可直接用于 `Runtime.evaluate`。 + +⚠️ **Page domain 方法需要 sessionId**:`Page.getFrameTree` 和 `Page.createIsolatedWorld` 必须带 `sessionId`(从 `Target.attachToTarget` 获取),否则返回 `"'Page.getFrameTree' wasn't found"`。 + +```python +# 0. 先 attach 获取 sessionId +r = requests.post(f'{BASE}/cdp/send', + json={'method': 'Target.attachToTarget', + 'params': {'targetId': MAIN_TARGET_ID, 'flatten': True}}, timeout=10) +session_id = r.json()['result']['sessionId'] + +# 1. 获取 URS iframe 的 frameId(必须带 sessionId) +r = requests.post(f'{BASE}/cdp/send', + json={'method': 'Page.getFrameTree', 'sessionId': session_id}, timeout=10) +# 在 childFrames 中找到 url 含 "dl.reg.163.com" 的 frame.id + +# 2. 创建隔离执行上下文(必须带 sessionId) +r = requests.post(f'{BASE}/cdp/send', + json={'method': 'Page.createIsolatedWorld', + 'params': {'frameId': URS_FRAME_ID, 'worldName': 'hermes_urs_login'}, + 'sessionId': session_id}, timeout=10) +exec_ctx_id = r.json()['result']['executionContextId'] + +# 3. 用 exec_ctx_id 在 iframe 内执行 JS(必须带 sessionId) +login_js = ''' +(() => { + document.querySelector('#pwdtext').style.display = 'none'; + const pwd = document.querySelector('input[name="password"]'); + pwd.style.display = ''; + const ns = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + ns.call(document.querySelector('input[name="email"]'), 'smthzqjone@163.com'); + document.querySelector('input[name="email"]').dispatchEvent(new Event('input', {bubbles: true})); + ns.call(pwd, 'Bingo2025'); + pwd.dispatchEvent(new Event('input', {bubbles: true})); + document.querySelector('#dologin').click(); + return JSON.stringify({ok:true}); +})()''' +r = requests.post(f'{BASE}/cdp/send', + json={'method': 'Runtime.evaluate', + 'params': {'expression': login_js, 'contextId': exec_ctx_id, 'returnByValue': True}, + 'sessionId': session_id}, timeout=10) +``` + +⚠️ **不要用 `cdp/evaluate` 终点**(它会走默认执行上下文,停留在主页面)。必须用 `cdp/send` + `Runtime.evaluate` + `contextId`。 + +## CDP 可用命令清单(Desktop v4.0.7 实测,2026-06-19 更新) + +通过 `/cdp/send` + `sessionId`(从 `Target.attachToTarget` 获取)可用的 CDP 方法: + +| 域 | 方法 | 说明 | +|----|------|------| +| Runtime | `evaluate` | ✅ 支持 `awaitPromise:true` + `returnByValue:true`。注意:嵌入大 base64(>100K)会导致表达式过大而失败 | +| Page | `captureScreenshot` | ✅ 支持 clip + scale。JPEG+quality 可大幅减小体积 | +| Page | `navigate` | ✅ | +| Page | `reload` | ✅ | +| Target | `attachToTarget` | ✅ 获取 sessionId | +| Target | `createTarget` | ✅ 创建新标签页(不标记已读) | +| Target | `getTargets` | ✅ 枚举所有标签页 | +| Input | `dispatchMouseEvent` | ✅ mousePressed/mouseReleased/mouseMoved。**但 NEJ 框架不响应此事件** | +| Input | `dispatchKeyEvent` | ✅ Ctrl+A 等组合键。**但 NEJ 列表不响应 Ctrl+A 选择** | +| Accessibility | `getFullAXTree` | ✅ 始终在白名单内。可用于验证 UI 元素存在性 | +| Network | `getCookies` | ✅ | +| DOM | `getOuterHTML` | ✅ 始终在白名单内 | + +**⚠️ `/cdp/click` 端点不可靠**:可能返回 `"No Desktop App connected"` 即使 `/cdp/attach` 正常。优先用 `/cdp/send` + `Input.dispatchMouseEvent`。 + +### Async fetch 单步 awaitPromise 模式(CDP,推荐) + +CDP `Runtime.evaluate` 配合 `awaitPromise: true` 可单步完成异步 fetch 并正确序列化返回值。**这是读取 163 API 的首选方式**,比两步 window 变量模式更简洁: + +```python +list_script = f''' +(async () => {{ + const r = await fetch('/js6/s?sid={sid}&func=mbox:listMessages', {{ + method: 'POST', + headers: {{'Content-Type': 'application/json'}}, + body: JSON.stringify({{"var":{{"0":{{"fid":"1","start":0,"limit":20}}}}}}) + }}); + return await r.text(); +}})() +''' +r = requests.post(f'{base}/cdp/send', + json={{'method': 'Runtime.evaluate', + 'params': {{'expression': list_script, 'returnByValue': True, 'awaitPromise': True}}}}, + headers=headers, timeout=25) +value = r.json().get('result', {{}}).get('result', {{}}).get('value', '') +``` + +⚠️ **必须用 `/cdp/send` + `Runtime.evaluate`**,不能用 `/cdp/evaluate`(后者不传 `awaitPromise`,异步函数返回空 `{}`)。 + +### Async fetch 两步序列化模式(CDP,备用) + +CDP evaluate 无法直接序列化大体积异步 fetch 返回值(返回 `{}`)。**两步方案**: + +```python +# 步骤1: 触发 fetch,将结果存入 window 全局变量 +trigger_js = ''' +(async () => { + const r = await fetch('/js6/s?sid=' + sid + '&func=mbox:listMessages', { + method: 'POST', credentials: 'include', + body: JSON.stringify({"var":{"0":{"fid":"1","start":0,"limit":20}}}) + }); + window.__MAIL_LIST = await r.text(); // 存入全局变量 + window.__FETCH_OK = true; +})(); +'FETCHING...'; +''' + +# 步骤2: 等待3-5秒后,在独立 evaluate 中读取 +r = requests.post(f'{BASE}/cdp/evaluate', + json={'expression': 'window.__MAIL_LIST'}, timeout=10) +``` + +⚠️ **绝对禁止同步 XHR**:`new XMLHttpRequest(); xhr.open(..., false)` 在浏览器 evaluate 中会永久阻塞 Chrome JS 线程,需 `Page.reload` 才能恢复。 + +### 163 XML 邮件列表解析陷阱 + +163 `listMessages` 返回嵌套 XML:外层 `` 是邮件条目,**内层 ``** 和 **``** 是元数据子对象。用正则匹配时必须**过滤掉内层对象**: + +```python +# ❌ 错误:/]*>([\\s\\S]*?)<\\/object>/g 会匹配到 flags 子对象 +# ✅ 正确:split 后过滤 +blocks = xml.split('') +for block in blocks: + if 'name="flags"' in block or 'name="ctrls"' in block: + continue # 跳过子对象 +``` + +日期字段是 `` 格式(非 ``),需单独用 `MID` +- 用错格式会返回 `FA_SECURITY` 或空结果 + +**页面内 fetch 可能返回空值(CDP evaluate 序列化问题):** + +```python +# 异步 fetch 在 evaluate 中可能返回空值 +# 即使 await 了,CDP 可能无法正确序列化大响应 +# 解决:分段返回或先存入 window 变量再分段读取 +``` + +### CDP evaluate 返回值提取 + +三层嵌套:`resp['result']['result']['value']` + +```python +data = json.loads(r['output']) +value = data.get('result', {}).get('result', {}).get('value', None) +``` + +### 安全警报邮件关键信息提取模板 + +Ozon 安全警报(Оповещение системы безопасности)的关键字段在 MIME 原文中可以提取: + +```python +# 登录IP — 搜索非 185.73.x.x(Ozon邮件服务器)、非 0.x/10.x 的IP +ipRe = r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b' +ips = [ip for ip in re.findall(ipRe, raw_text) + if not ip.startswith('185.73.') and not ip.startswith('0.') and not ip.startswith('10.')] + +# 绑定手机号 +phoneRe = r'\+86\s*\d{3}[\s-]?\d{4}[\s-]?\d{4}' + +# 登录上下文解码 +loginRe = r'=D0=B2=D1=85=D0=BE=D0=B4[\s\S]{0,300}' # "вход" (login) QP-encoded +cleaned = re.sub(r'=\r?\n', '', qp_text) +decoded = quopri.decodestring(cleaned.encode()).decode('utf-8', errors='replace') +# 典型内容: "вход на новом устройстве. Это вы вошли в аккаунт с нового..." +``` + +### 订单邮件关键信息提取 + +```python +# 订单号格式:10位数字-4位数字(如 14040934-0808) +orderRe = r'\d{7,10}-\d{3,5}' + +# 中文摘要(安全浏览模式下,只有少量中文会作为 alt 文本出现) +# "该订单需立即处理" = 新订单 +# "无需发运该货件" = 买家取消 +# "您的商品已被归档" = 商品归档 +``` + +## Cloud Bridge CDP 逐封读取邮件(完整流程 v2.0) + +### 前置条件 +- Desktop 在线,用户已登录 163 邮箱 +- Bridge 已连接,同 Desktop slot + +### 完整流程 + +**Step 1: 定位 163 邮箱 tab** +```javascript +// 用 Target.getTargets 找到 163 tab +fetch("/cdp/send", {method:"POST", headers:{"X-Desktop-Id":SLOT}, + body:JSON.stringify({method:"Target.getTargets"})}) +// 在 targetInfos 中找 type="page" 且 url 含 "mail.163.com" 的 targetId +``` + +**Step 2: Attach 到 163 tab** +```javascript +// 用 Target.attachToTarget 获取 sessionId +fetch("/cdp/send", {method:"POST", headers:{"X-Desktop-Id":SLOT}, + body:JSON.stringify({method:"Target.attachToTarget", + params:{targetId:MAIL_TARGET_ID, flatten:true}})}) +// 返回 {result: {sessionId: "xxx"}} +``` + +**Step 3: 提取 SID** +```javascript +// 从 URL 中提取 +const sid = window.location.href.match(/sid=([^&]+)/)[1]; +``` + +**Step 4: 获取邮件列表** +```javascript +// 用 Runtime.evaluate + awaitPromise + returnByValue +// ⚠️ 必须用 /cdp/send + Runtime.evaluate(不是 /cdp/evaluate) +const r = await fetch(`/js6/s?sid=${sid}&func=mbox:listMessages`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({"var":{"0":{"fid":"1","start":0,"limit":20}}}) +}); +const xml = await r.text(); +// 解析: 用正则提取 id, subject, from, sentDate, read +``` + +**Step 5: 逐封读取内容(关键:QP 解码)** +```javascript +// 用 func=mbox:getMessageData&mid=MID&mode=raw +// mode=raw 返回完整 MIME 原文(含 QP 编码) +const r = await fetch(`/js6/s?sid=${sid}&func=mbox:getMessageData&mid=${mid}&mode=raw`); +const raw = await r.text(); + +// 跳过 header 到 body +const bodyStart = raw.indexOf('\r\n\r\n'); +let body = raw.substring(bodyStart + 4); + +// 去掉