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,366 @@
# 抖音 Web 端任务接管 — 完整方案文档
> **版本**: 3.1 | **日期**: 2026-04-27 | **平台**: 抖音网页版
> **前置条件**: Chrome + CDP 端口 9222 + Patchright
> **阶段A状态**: ✅ 完成(原子操作库V2 + 账号切换器 + 指纹注入 + 双账号并行)
---
## 一、架构总览
```
自然语言指令 / 蓝图 JSON
↓
蓝图引擎 (task_engine.py)
↓
原子操作库 (douyin_ops.py)
↓
账号切换器 (switch_account.py) ← V3 新增
├── 方案A: Chrome Profile 切换 + 指纹注入
└── 方案B: Cookie 注入(实验性)
↓
CDP 连接器 (cdp_connector.py)
↓
Chrome (端口 9222/9223/...) → 抖音网页
↓
代理 IP (静态住宅 IP) ← V3 新增
```
### 核心设计原则
1. **原子化**:每个操作是最小不可分割单元,含前置检查+执行+后置验证
2. **固定起点**:所有蓝图从推荐页 (`goto_home`) 出发,确保状态一致
3. **选择器优先**:`data-e2e` > XPath > 键盘 > 鼠标坐标
4. **频率安全**:内置操作频率上限(点赞<20/h,评论<5/h)
5. **验证码降级**:评论触发验证码 → 取消 → 记录 → 继续
6. **账号隔离**:每个账号独立 Profile + 独立端口 + 独立 IP(V3 新增)
7. **指纹稳定**:同一账号永远同一指纹模板,不跨进程漂移(V3 新增)
---
## 二、原子操作清单
| 原子操作 | 方法名 | 选择器 | 键盘备选 | 后置验证 |
|---------|--------|--------|---------|---------|
| 回推荐页 | `goto_home()` | `[data-e2e="douyin-navigation"] a:has-text("推荐")` | - | video.duration > 0 |
| 导航到URL | `goto_url(url)` | - | - | URL 变化 |
| 后退 | `go_back()` | - | - | URL 变化 |
| 点赞 | `like()` | `[data-e2e="video-player-digg"]` | Z | data-e2e-state 变化 |
| 收藏 | `collect()` | `[data-e2e="video-player-collect"]` | - | - |
| 关注 | `follow()` | `[data-e2e="feed-follow-icon"]` | F | - |
| 下翻视频 | `next_video()` | `[data-e2e="video-switch-next-arrow"]` | ↓ | modal_id 变化 |
| 上翻视频 | `prev_video()` | `[data-e2e="video-switch-prev-arrow"]` | ↑ | modal_id 变化 |
| 播放/暂停 | `toggle_play()` | - | 空格 | video.paused 变化 |
| 搜索 | `search(keyword)` | `[data-e2e="searchbar-input"]` | - | URL 含 search |
| 点击搜索结果 | `click_search_result(idx)` | `.search-result-card` | - | URL 含 modal_id |
| 打开评论 | `open_comments()` | `[data-e2e="feed-comment-icon"]` | X | comment_list 出现 |
| 关闭评论 | `close_comments()` | - | X | comment_list 消失 |
| 发评论 | `post_comment(text)` | `.public-DraftEditor-content` | Enter | verify_panel? |
| 取消验证码 | `cancel_verify()` | `.uc-ui-verify_sms-verify_button.second` | - | 面板消失 |
| 观看等待 | `wait_watch(secs)` | - | - | 时间到 |
---
## 三、键盘快捷键
| 键 | 功能 | 可靠性 | 建议 |
|----|------|-------|------|
| Z | 点赞 | ❌ 焦点问题 | 用点击选择器 |
| X | 评论 | ✅ 基本可靠 | 推荐 |
| F | 关注/进直播间 | ✅ | 推荐 |
| B | 弹幕开关 | ✅ | |
| 空格 | 播放/暂停 | ⚠️ 需焦点 | 点击视频先 |
| ↑ | 上一个视频 | ⚠️ 需焦点 | 用点击箭头 |
| ↓ | 下一个视频 | ⚠️ 需焦点 | 用点击箭头 |
| Enter | 搜索确认/评论发送 | ✅ | |
---
## 四、蓝图清单
| 蓝图ID | 名称 | 步骤数 | 状态 |
|--------|------|--------|------|
| `douyin_browse_v2` | 日常浏览V2 | 11 | ✅ active |
| `douyin_search_browse` | 搜索浏览 | 7 | ✅ active |
| `douyin_comment_interact` | 评论互动 | 8 | ✅ active |
| `douyin_browse` | 日常浏览V1 | 8 | ⛔ deprecated |
---
## 五、稳定链路设计
### 5.1 固定起点流程
```
所有蓝图 → 第1步 goto_home() → 等视频加载 → 后续操作
```
这确保每次执行从同一状态出发,不依赖上次操作的页面残留状态。
### 5.2 错误恢复策略
| 错误类型 | 恢复动作 |
|---------|---------|
| 元素未找到 | 等待1s重试 → 刷新页面重试 → 放弃当前步骤继续 |
| 验证码弹出 | cancel_verify() → 记录 → 继续 |
| 页面空白/加载超时 | goto_home() 重新开始 |
| CDP 断连 | 报错退出,调度器30分钟后重试 |
| 连续3步失败 | 熔断:暂停当前蓝图,跳到下一个 |
### 5.3 操作频率控制
```python
RATE_LIMITS = {
"likes_per_hour": 20,
"comments_per_hour": 5,
"follows_per_hour": 10,
"collects_per_hour": 15,
"searches_per_hour": 10,
}
```
超过频率的操作自动跳过,不报错。
---
## 六、选择器维护策略
### 6.1 三级定位
| 优先级 | 方式 | 覆盖率 | 失败处理 |
|--------|------|--------|---------|
| L1 | `data-e2e` 选择器 | 95% | 降级到 L2 |
| L2 | XPath / class 选择器 | 4% | 降级到 L3 |
| L3 | MLX 视觉定位 | 1% | 人工介入 |
### 6.2 变更检测
每次操作记录 `locator_used`,如果 L1 选择器连续失败 3 次:
1. 自动写入 `ui_changes` 表
2. 标记受影响蓝图状态为 `broken`
3. 通知人工扫描页面更新选择器
---
## 七、账号切换 + 反检测方案
### 7.1 当前两套逻辑
| 方案 | 逻辑 | 优点 | 缺点 |
|------|------|------|------|
| **A: Chrome Profile 切换** | 每个账号独立 `--user-data-dir`,切换 = 重启 Chrome | Cookie 自然持久化,最简单 | 切换慢(需关闭重开Chrome),同时间只能1个账号 |
| **B: Cookie 注入** | 1个浏览器实例,通过 CDP 注入不同 Cookie | 切换快(秒级),可同时多标签 | Cookie 格式复杂,部分平台有额外校验,反检测弱 |
### 7.2 推荐方案:A 为主,B 备用
**现阶段(2-5个账号)**:方案 A 足够
- 每个账号一个 Chrome Profile 目录
- 切换 = 关 Chrome → 重新 `launch_chrome.sh <账号ID> <端口>`
- 间隔 10-30 分钟自然切换
**扩展阶段(5+账号)**:方案 B 补充
- 提取 Cookie → JSON 存储 → 注入到新上下文
- 需要 `cdp_connector.py` 增加 `inject_cookies()` 方法
### 7.3 反检测清单
| 检测维度 | 当前措施 | 下一步 |
|---------|---------|--------|
| **浏览器指纹** | 真实 Chrome(指纹原生) | 阶段B:Camoufox/Patchright 容器 |
| **navigator.webdriver** | Patchright 自动处理 | ✅ 已覆盖 |
| **IP地址** | 同一IP(风险中等) | 静态住宅IP(每账号独立) |
| **行为模式** | 随机延迟+频率控制 | 增加鼠标轨迹仿真 |
| **Cookie/Session** | Chrome Profile 自然持久 | ✅ 已覆盖 |
| **操作时间** | 随机间隔 2-15s | 增加活跃时段控制(8:00-23:00) |
| **账号关联** | 同一设备不同Profile | 独立代理IP + 不同的浏览习惯 |
### 7.4 账号切换操作流程
```
1. 结束当前账号蓝图执行
2. 记录执行日志到 operation_logs
3. 关闭 Chrome(kill进程或关闭窗口)
4. 等待 30-120 秒(模拟人工间隔)
5. 启动新账号 Chrome:
bash ~/matrix/scripts/launch_chrome.sh <新账号ID> <新端口>
6. 等待 Chrome 就绪
7. 验证 Cookie 是否有效(check_login)
8. 开始新账号蓝图执行
```
### 7.5 下一步实施优先级
| 优先级 | 任务 | 预计时间 | 状态 |
|--------|------|---------|------|
| **P0** | 账号切换脚本 `switch_account.py` | 2h | ✅ 已完成 |
| **P0** | 指纹注入 + 双账号并行验证 | 2h | ✅ 已完成 |
| **P1** | 鼠标轨迹仿真(贝塞尔曲线) | 4h | 待开发 |
| **P1** | 语料库填充(评论/关键词) | 1h | 待开发 |
| **P2** | Cookie 注入方案 B 完善 | 4h | 骨架已有 |
| **P2** | 静态住宅IP接入 | 按服务商 | 待购买 |
| **P3** | 阶段B:Docker + Camoufox | 2-3天 | 待规划 |
---
## 八、账号切换系统(V3 新增)
### 8.1 两种切换方案
| 方案 | 机制 | 切换速度 | 可靠性 | 适用场景 |
|------|------|---------|--------|---------|
| **A: Profile 切换** | 关闭 Chrome → 启动新 Profile → 注入指纹 | ~15秒 | ⭐⭐⭐⭐⭐ | 日常养号 |
| **B: Cookie 注入** | 清除 Cookie → 注入新 Cookie → 刷新验证 | ~5秒 | ⭐⭐⭐ | 快速切换(实验性) |
### 8.2 switch_account.py 用法
```bash
# 列出所有账号
python scripts/switch_account.py --list
# 查看当前状态
python scripts/switch_account.py --status
# 方案A: 切换到 douyin_01
python scripts/switch_account.py --method profile --target douyin_01 --port 9222
# 方案B: Cookie 注入切换
python scripts/switch_account.py --method cookie --target douyin_02 --port 9222
# 导出当前 Cookie(给方案B备用)
python scripts/switch_account.py --target douyin_01 --export-cookies
```
### 8.3 切换流程(方案A)
```
1. 关闭当前 Chrome(SIGTERM → 等待 → SIGKILL)
2. 启动新 Chrome(目标 Profile + CDP 端口 + 代理)
3. 注入浏览器指纹:
- 视口覆盖(桌面端 702x783,保持 Cookie 兼容)
- 时区覆盖(Asia/Shanghai)
- 语言覆盖(zh-CN)
- WebGL Vendor/Renderer 注入(覆盖 GPU 指纹)
- navigator.webdriver 隐藏
- navigator.plugins/languages 伪装
- App 跳转拦截(CDP Fetch)
4. 验证登录状态
5. 更新数据库
```
### 8.4 指纹模板映射
每个账号 ID 稳定映射到一个指纹模板(同一账号永远同一指纹):
| 账号 | 指纹模板 | 视口 | 设备 |
|------|---------|------|------|
| douyin_01 | fp_iphone14pro | 393x852 | iPhone 14 Pro |
| douyin_02 | fp_samsung24 | 412x915 | Samsung S24 |
| xhs_01 | fp_iphone15pro | 393x852 | iPhone 15 Pro |
| zhihu_01 | fp_huawei_p60 | 393x873 | Huawei P60 |
> ⚠️ 当前养号场景使用桌面端视口(702x783),移动端视口会破坏 Cookie 登录态。
> 移动端伪装仅在特殊需要时通过 cdp_connector 单独开启。
### 8.5 双账号同时运行
每个账号使用不同的 CDP 端口和 Profile 目录:
```bash
# 终端1: 启动账号1(端口 9222)
python scripts/switch_account.py --method profile --target douyin_01 --port 9222
# 终端2: 启动账号2(端口 9223)
python scripts/switch_account.py --method profile --target douyin_02 --port 9223
```
---
## 九、反检测策略(V3 新增)
### 9.1 当前已实现的反检测
| 维度 | 措施 | 实现方式 | 状态 |
|------|------|---------|------|
| **navigator.webdriver** | 隐藏自动化标记 | init_script `undefined` | ✅ |
| **HeadlessChrome** | 移除 Headless 标记 | init_script | ✅ |
| **WebGL 指纹** | 覆盖 GPU 信息 | init_script | ✅ |
| **App 跳转** | 拦截 deeplink | CDP Fetch | ✅ |
| **时区/语言** | 统一为中国 | CDP Emulation | ✅ |
| **浏览器** | 真实 Chrome | 非 Headless | ✅ |
| **plugins** | 伪装非空 | init_script | ✅ |
| **Patchright** | 自动处理自动化检测 | 框架级 | ✅ |
### 9.2 待实现的反检测
| 维度 | 方案 | 优先级 |
|------|------|--------|
| **鼠标轨迹** | 贝塞尔曲线仿真(避免完美直线) | P1 |
| **输入节奏** | press_sequentially + 随机延迟 | P1(已部分实现) |
| **Canvas 指纹** | 噪声注入 | P2 |
| **AudioContext 指纹** | 噪声注入 | P2 |
| **字体指纹** | 覆盖字体列表 | P3 |
| **IP 地址** | 静态住宅代理 | P1(见 IP 方案) |
### 9.3 鼠标轨迹仿真(下一步)
当前点击是"瞬移"到目标位置,这不符合人类行为。下一步实现:
```python
# 贝塞尔曲线鼠标移动
async def human_move_mouse(page, target_x, target_y, steps=20):
"""模拟人类鼠标移动(贝塞尔曲线 + 加减速)"""
current = await page.evaluate('() => ({x: window._mx || 0, y: window._my || 0})')
start_x, start_y = current['x'], current['y']
# 随机控制点(贝塞尔曲线)
ctrl_x = start_x + (target_x - start_x) * 0.5 + random.uniform(-50, 50)
ctrl_y = start_y + (target_y - start_y) * 0.5 + random.uniform(-50, 50)
for i in range(steps):
t = i / steps
# ease-in-out 加速曲线
t = t * t * (3 - 2 * t)
x = (1-t)**2 * start_x + 2*(1-t)*t * ctrl_x + t**2 * target_x
y = (1-t)**2 * start_y + 2*(1-t)*t * ctrl_y + t**2 * target_y
await page.mouse.move(x, y)
await asyncio.sleep(random.uniform(0.01, 0.03))
```
---
## 十、IP 切换方案(V3 新增)
> 详见 `docs/IP_SWITCH_GUIDE.md`
### 10.1 核心结论
- **养号最佳选择**:静态住宅 IP(固定 IP + 真实住宅 + 无限流量)
- **推荐服务商**:巨量HTTP(¥3-6/个/月)、IPFoxy(¥5-8/个/月)
- **最低成本方案**:家里 WiFi + 手机热点(¥0,2-3 个账号)
- **不推荐**:数据中心 IP(易识别)、免费代理(不稳定)
### 10.2 推荐方案(5-10 个账号)
```
买 2-3 个中国静态住宅 IP → 每个 IP 绑 2-3 个账号
成本:¥30-50/月
```
### 10.3 技术集成
Chrome 启动时注入代理:
```bash
"$CHROME" \
--proxy-server="http://user:[email protected]:8080" \
--remote-debugging-port="$DEBUG_PORT" \
--user-data-dir="$PROFILE_DIR"
```
账号配置增加代理字段:
```yaml
accounts:
- id: douyin_01
proxy: "http://user:pass@cn-proxy1:8080"
- id: douyin_02
proxy: "socks5://user:pass@cn-proxy2:1080"
```