feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,316 @@
# Matrix 养号系统 — 全面架构文档 v2.2
> **最后更新**:2026-05-10
> **作者**:Claw
> **核心原则**:原子操作 × 状态机 × 唯一特征码 × 蓝图链
---
## 0. 总架构哲学
每个操作都是一条因果链:
```
[唯一前置状态] → [原子操作] → [唯一后置状态]
↑ ↓
上一个操作的后置 下一个操作的前置
```
**关键规则**:
1. 每个状态必须有**唯一的特征码组合**(URL + DOM锚点 + video数量 + 其他)
2. 每个操作执行前**必须先验证前置状态**
3. 每个操作执行后**必须验证后置状态**
4. 验证失败 → 不执行操作,先恢复到一个已知状态
5. 所有状态可查询、可追踪、可恢复
---
## 1. 完整状态机
### 1.1 状态定义与特征码
| 状态ID | 特征码 | 描述 |
|--------|--------|------|
| `INIT` | 浏览器刚启动,无页面 | 初始态 |
| `LOGIN` | URL=`douyin.com` + sessionid Cookie 存在 + `[data-e2e="user-avatar"]` 可见 | 已登录首页 |
| `HOME_GRID` | URL=`douyin.com/jingxuan` + `[data-e2e="alink-item"]` >= 3 + video=1(背景) | 精选页卡片列表 |
| `HOME_RECOMMEND` | URL=`douyin.com/?recommend=1` + `[data-e2e="alink-item"]` >= 3 | 推荐页卡片列表 |
| `VIDEO_PLAYER` | URL 含 `/video/` 或 `modal_id` + video>=2 + `[data-e2e="video-player-digg"]` 存在 | 视频播放页 |
| `COMMENT_PANEL` | `VIDEO_PLAYER` + `[data-e2e="comment-list"]` 可见 + `.public-DraftEditor-content` 存在 | 评论区已打开 |
| `INPUT_FOCUSED` | `COMMENT_PANEL` + `document.activeElement` 是 `.public-DraftEditor-content` + 光标闪烁 | 输入框已聚焦 |
| `TEXT_ENTERED` | `INPUT_FOCUSED` + `.public-DraftEditor-content` 的 `textContent.length > 0` | 文字已输入 |
| `COMMENT_SENT` | 评论已发送 + `[data-e2e="comment-list"]` 内含刚发的文本 | 评论已发送 |
| `SEARCH` | URL 含 `/search/` + `[data-e2e="searchbar-input"]` | 搜索结果页 |
| `VERIFY_CODE` | `.second-verify-panel` 或 `input[placeholder*="验证码"]` 可见 | 验证码弹窗 |
| `UNKNOWN` | 无法匹配任何已知特征码 | 未知状态 |
### 1.2 状态迁移图
```
INIT → LOGIN → HOME_GRID → VIDEO_PLAYER → COMMENT_PANEL → INPUT_FOCUSED → TEXT_ENTERED → COMMENT_SENT
↓ ↓ ↓ ↓ ↓ ↓
HOME_GRID SEARCH ←回首页→ VERIFY_CODE ←聚焦失败→ 发送失败→
↓ ↑
VIDEO_PLAYER ←—— 点卡片/导航视频URL ————————————————————————————————————————
```
### 1.3 状态检测函数
核心检测函数 `detect_state(page)` 返回当前状态ID:
```python
async def detect_state(page) -> str:
"""返回当前状态ID(唯一可识别)"""
# 检测优先级:从最具体到最通用
# 1. 验证码弹窗
if has_verify_popup: return "VERIFY_CODE"
# 2. 评论发送成功
if has_comment_list_with_text: return "COMMENT_SENT"
# 3. 文字已输入
if editor_has_text: return "TEXT_ENTERED"
# 4. 输入框聚焦
if editor_is_focused: return "INPUT_FOCUSED"
# 5. 评论区打开
if comment_panel_visible: return "COMMENT_PANEL"
# 6. 视频播放
if video_player_active: return "VIDEO_PLAYER"
# 7. 搜索结果
if search_page: return "SEARCH"
# 8. 首页/推荐
if home_page: return "HOME_GRID" / "HOME_RECOMMEND"
# 9. 已登录
if logged_in: return "LOGIN"
return "UNKNOWN"
```
---
## 2. 原子操作完整清单
每个原子操作 = (名称, 前置状态, 操作, 后置状态, 超时, 重试策略)
### 2.1 登录段
| 原子操作 | 前置 | 操作 | 后置 | 特征码变化 |
|---------|------|------|------|-----------|
| `navigate_login` | `INIT` | goto douyin.com → 等待页面加载 | `LOGIN` | URL→douyin + sessionid存在 |
### 2.2 首页段
| 原子操作 | 前置 | 操作 | 后置 | 特征码变化 |
|---------|------|------|------|-----------|
| `goto_home` | `LOGIN`/任意 | 导航到 douyin.com/?recommend=1 | `HOME_GRID` | URL→jingxuan + alink-item≥3 |
| `click_video_card` | `HOME_GRID` | 双击 `.discover-video-card-item` 或 `[data-e2e="alink-item"]` | `VIDEO_PLAYER` | video≥2 + `/video/` 或 player UI |
| `search_keyword` | `HOME_GRID` | 输入关键词+Enter | `SEARCH` | URL→/search/ |
| `click_search_result` | `SEARCH` | 点击第一个结果卡片 | `VIDEO_PLAYER` | video≥2 |
| `scroll_feed` | `HOME_GRID` | 鼠标滚轮/键盘下滑 | `HOME_GRID` | URL不变,卡片刷新 |
### 2.3 视频播放段
| 原子操作 | 前置 | 操作 | 后置 | 特征码变化 |
|---------|------|------|------|-----------|
| `wait_watch` | `VIDEO_PLAYER` | 等待N秒 | `VIDEO_PLAYER` | video 内容变化(可选) |
| `like_video` | `VIDEO_PLAYER` | 按Z/点digg按钮 | `VIDEO_PLAYER` | digg_state→digged |
| `collect_video` | `VIDEO_PLAYER` | 点收藏按钮 | `VIDEO_PLAYER` | 收藏状态变化 |
| `follow_author` | `VIDEO_PLAYER` | 按G/点关注 | `VIDEO_PLAYER` | 关注按钮变化 |
| `next_video` | `VIDEO_PLAYER` | 键盘ArrowDown / 滚轮 | `VIDEO_PLAYER` | video.src变化 |
| `open_comments` | `VIDEO_PLAYER` | 按X / 点评论图标 | `COMMENT_PANEL` | comment-list可见 + .public-DraftEditor-content存在 |
### 2.4 评论段
| 原子操作 | 前置 | 操作 | 后置 | 特征码变化 |
|---------|------|------|------|-----------|
| `focus_editor` | `COMMENT_PANEL` | Playwright locator双击 `.public-DraftEditor-content` | `INPUT_FOCUSED` | activeElement=编辑器 |
| `type_comment` | `INPUT_FOCUSED` | `page.keyboard.type()` 逐字输入 | `TEXT_ENTERED` | editor textContent>0 |
| `send_comment` | `TEXT_ENTERED` | 点击发送按钮 / Ctrl+Enter / Alt+Enter | `COMMENT_SENT` / `VERIFY_CODE` | comment-list含文本 |
| `close_comments` | `COMMENT_PANEL` | 按X / Escape | `VIDEO_PLAYER` | comment-list消失 |
### 2.5 恢复段
| 原子操作 | 前置 | 操作 | 后置 | 特征码变化 |
|---------|------|------|------|-----------|
| `recover_home` | 任意 | 导航到 douyin.com → 等待卡片加载 | `HOME_GRID` | URL+卡片 |
| `recover_video` | `HOME_GRID`/任意 | 点卡片进入视频 | `VIDEO_PLAYER` | video≥2 |
| `recover_comments` | `VIDEO_PLAYER` | 开评论(键盘+DOM双策略) | `COMMENT_PANEL` | comment-list可见 |
---
## 3. 完整蓝图链:评论流程
```
┌─────────────────────────────────────────────────────────┐
│ 蓝图:douyin_comment_send │
├─────────────────────────────────────────────────────────┤
│ Step 1: goto_home │
│ pre: INIT or any post: HOME_GRID │
│ anchor: URL=jingxuan + alink-item≥3 │
│ fail: 重试3次,截图 │
├─────────────────────────────────────────────────────────┤
│ Step 2: click_video_card │
│ pre: HOME_GRID post: VIDEO_PLAYER │
│ anchor: video≥2 + player UI │
│ 操作: 双击 .discover-video-card-item 或 alink-item │
│ fail: goto_home → 重试 │
├─────────────────────────────────────────────────────────┤
│ Step 3: open_comments │
│ pre: VIDEO_PLAYER post: COMMENT_PANEL │
│ anchor: comment-list可见 + Draft-editor存在 │
│ 操作: 键盘X / DOM点评论图标 / 键盘x │
│ fail: 恢复VIDEO_PLAYER → 重试 │
├─────────────────────────────────────────────────────────┤
│ Step 4: focus_editor │
│ pre: COMMENT_PANEL post: INPUT_FOCUSED │
│ anchor: activeElement = .public-DraftEditor-content │
│ 操作: Playwright locator 双击编辑器 │
│ 兜底: DOM focus + click / 坐标(479,687)双击 │
│ fail: 恢复COMMENT_PANEL → 重试 │
├─────────────────────────────────────────────────────────┤
│ Step 5: type_comment │
│ pre: INPUT_FOCUSED post: TEXT_ENTERED │
│ anchor: editor.textContent.length > 0 │
│ 操作: page.keyboard.type(text, delay=40) │
│ 兜底: execCommand('insertText')逐字符注入 │
│ 等待: 1.5s 让 React 处理(发送按钮出现) │
├─────────────────────────────────────────────────────────┤
│ Step 6: send_comment │
│ pre: TEXT_ENTERED post: COMMENT_SENT │
│ anchor: comment-list含刚发的文本 │
│ 操作: 找发送按钮点击 / Ctrl+Enter / Alt+Enter │
│ 兜底: 3次尝试找按钮 → 键盘兜底 │
│ 验证: 检查 [data-e2e="comment-list"] 内文本 │
│ fail: 检测 VERIFY_CODE │
├─────────────────────────────────────────────────────────┤
│ Step 7: goto_home (下一轮) │
│ pre: COMMENT_SENT post: HOME_GRID │
│ 操作: 导航到 douyin.com │
└─────────────────────────────────────────────────────────┘
```
---
## 4. 数据表结构
### `atomic_ops` — 原子操作注册表
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER PK | |
| op_name | TEXT UNIQUE | 操作名 |
| description | TEXT | 描述 |
| action_type | TEXT | click/nav/key/eval/wait |
| target | TEXT | 操作目标(选择器/键名/URL) |
| pre_state | TEXT | 前置状态ID |
| post_state | TEXT | 后置状态ID |
| timeout | INTEGER | 超时秒数 |
| retry | INTEGER | 重试次数 |
| verify_rule | TEXT | 后置验证规则(JSON) |
| created_at | DATETIME | |
### `anchor_rules` — 状态特征码规则
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER PK | |
| state_name | TEXT | 状态ID |
| field | TEXT | 检测字段(url/videoCount/selector等) |
| operator | TEXT | eq/neq/gt/gte/lt/lte/includes |
| value | TEXT | 期望值 |
| logic_group | INTEGER | 同一组AND,不同组OR |
| active | BOOLEAN | |
| created_at | DATETIME | |
### `state_transitions` — 合法状态迁移
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER PK | |
| from_state | TEXT | 起始状态 |
| to_state | TEXT | 目标状态 |
| via_op | TEXT | 通过哪个操作 |
| weight | INTEGER | 权重(优先级) |
### `action_logs` — 操作执行日志
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER PK | |
| execution_id | TEXT | 执行批次 |
| identity | TEXT | 账号ID |
| op_name | TEXT | 操作名 |
| pre_state | TEXT | 执行前状态 |
| post_state | TEXT | 执行后状态 |
| success | BOOLEAN | |
| duration_ms | INTEGER | |
| error | TEXT | |
| created_at | DATETIME | |
### `blueprints` — 蓝图定义
| 字段 | 类型 | 说明 |
|------|------|------|
| id | TEXT PK | 蓝图ID |
| name | TEXT | 名称 |
| version | TEXT | 版本 |
| steps | JSON | 步骤序列 |
| created_at | DATETIME | |
---
## 5. 关键选择器清单
### 5.1 页面检测
| 检测项 | 选择器/代码 | 说明 |
|--------|-------------|------|
| 视频播放页 | `document.querySelectorAll('video').length >= 2` + `[data-e2e="video-player-digg"]` | video≥2 + player按钮 |
| 评论区打开 | `[data-e2e="comment-list"]` + `.public-DraftEditor-content` | 列表+编辑器都存在 |
| 输入框聚焦 | `document.activeElement === document.querySelector('.public-DraftEditor-content')` | activeElement是编辑器 |
| 输入框有内容 | `.public-DraftEditor-content.textContent.length > 0` | 编辑器内有文字 |
| 验证码弹窗 | `.second-verify-panel` / `input[placeholder*="验证码"]` | |
| 首页卡片 | `[data-e2e="alink-item"]` / `.discover-video-card-item` | |
### 5.2 操作目标
| 操作 | 方式 | 目标 |
|------|------|------|
| 进入视频 | Playwright locator双击 | `.discover-video-card-item` 或 `[data-e2e="alink-item"]` |
| 打开评论 | 键盘x / DOM 点 | `[data-e2e="video-comment-count"]` 或 `[data-e2e="feed-comment-icon"]` |
| 聚焦编辑器 | Playwright locator双击 | `.public-DraftEditor-content` |
| 输入文字 | `page.keyboard.type(text)` | 已聚焦的activeElement |
| 发送 | DOM找按钮点击 | 按钮含"发送"/上箭头 / Ctrl+Enter / Alt+Enter |
---
## 6. 已校准配置
| 参数 | 窗口 | 值 | 校准日期 |
|------|------|-----|---------|
| 输入框中心X | 702×783 | 479 | 2026-05-10 (实测算) |
| 输入框中心Y | 702×783 | 687 | 2026-05-10 (实测算) |
| 离右边缘 | 702 | 223px | 2026-05-10 |
| 离底边缘 | 783 | 96px | 2026-05-10 |
| 编辑器选择器 | - | `.public-DraftEditor-content` | 2026-05-10 |
---
## 7. 已知问题与修复记录
| 问题 | 状态 | 根因 | 修复 |
|------|------|------|------|
| `a[href*="/video/"]` 不存在 | ✅ 已修 | 抖音用JS点击事件,不用a标签 | 改用 `[data-e2e="alink-item"]` |
| `_detect_page_state` 太严格 | ✅ 已修 | 要求video≥2+playerUI才判player | 简化:有video就算player |
| `KeyX` 不生效 | ✅ 已修 | 没先点视频区域获取焦点 | 加 `_ensure_video_focused()` 再按x |
| 焦点检测在两快键之间 | ✅ 已修 | `page.evaluate` 干扰焦点 | 删除中间的detect调用 |
| 评论验证假阳性 | ✅ 已修 | `body.innerText.includes` 命中input内容 | 改查 `[data-e2e="comment-list"]` |
| execCommand不触发React | ⚠️ 待测 | Draft.js拦截DOM修改 | 改用 `page.keyboard.type()` |
| Camoufox profile lock | ⚠️ 待修 | 强杀不释放 .parentlock | 每次启动前检测+清理 |
---
## 8. 后续开发规范
1. **任何修改前先确认状态** — 读当前状态ID,只执行当前状态允许的操作
2. **一次只改一个原子操作** — 不改整条链,改完就测
3. **改完更新文档** — 状态机、蓝图、选择器、配置同步更新
4. **所有坐标必须从配置读** — 不硬编码,`ui_layout.py` 统一管理
5. **验证防假阳性** — 检查特定元素,不用 `body.innerText.includes()`
6. **双击之间不插操作** — 不调用 `page.evaluate` 或其他可能干扰焦点的操作