Files
MediaCrawler/mac-agent-os-main/archive_docs/matrix_docs/ARCHITECTURE_FULL.md
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

317 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 或其他可能干扰焦点的操作