--- 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); // 去掉