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,168 @@
# AgentOS 原子操作架构审计与重构方案
## 一、旧代码设计原则(提炼自旧版 runner.py / interact.py)
### 1.1 每步操作 = 状态机三要素
```
[前置状态检测] → [操作执行] → [后置状态验证]
↓ ↓ ↓
当前在哪个页面? 执行操作 操作是否真的生效了?
```
**旧代码实现**(`_ensure_player_state`、`CommentStateMachine`):
```python
# 前置检测
state = await _detect_page_state(page)
# 只有确认在播放页才执行操作
if state not in ("player", "search_player"):
recovered = await _ensure_player_state(...)
# 执行
await page.keyboard.press("KeyZ")
# 后置验证
assert await _check_anchor(page, 'video_page')
```
### 1.2 锚点系统(`_check_anchor`)
| 锚点 | 检测方式 | 作用 |
|:-----|:---------|:-----|
| `video_page` | `video` 元素 > 0 | 确认在视频播放页 |
| `home_page` | `.discover-video-card-item` > 0 | 确认在首页feed |
| `video_playing` | `!video.paused && readyState > 0` | 确认视频在播放 |
| `has_videos` | `a[href*="/video/"]` > 0 | 页面有视频链接 |
### 1.3 评论区状态机(`CommentStateMachine`)
```
closed → panel_open → input_focused → text_entered → sent
↓ ↓ ↓
键盘x打开 检测activeElement 检测textContent
验证has_panel 验证isEditable 验证has_text
```
**核心:每一步都调用 `detect()` 验证状态是否真的变化了。**
### 1.4 行为链(`CHAINS`)
旧代码用**行为链**而非固定步骤模板:
```python
CHAINS = [
[('open_comment', 'KeyX'),
('type_comment', 'input'), # 调用 _send_comment 状态机
('wait_10s', 'wait'),
('scroll_comment', 'scroll')],
]
```
每个链中的操作都是**键盘快捷键优先**(KeyZ/KeyX/KeyG),DOM 兜底。
---
## 二、当前新代码的问题审计
| 问题 | 表现 | 根因 |
|:-----|:-----|:------|
| **无前置状态检测** | open_video 在首页找不到卡片→硬等30s→失败 | 不先检测页面状态就直接找元素 |
| **无后置验证** | post_comment 返回"sent"但评论没发出去 | 不检查评论区是否出现了刚发的文字 |
| **无锚点系统** | 所有操作都用 CSS 类名硬匹配(CSS modules 不可靠) | 没有用 video/card 等特征判断 |
| **状态机缺失** | 点赞/收藏/评论等操作的执行结果和页面实际脱节 | 没有 `detect()` 验证状态变化 |
| **DOM 优先于键盘** | 所有操作先找 DOM 元素,找不到才 fallback | 旧代码用键盘快捷键为主,更可靠 |
| **无回退策略** | 一个方法失败就放弃或硬等30s | 旧代码有3层回退(键盘→DOM→坐标) |
---
## 三、重构方案
### 3.1 新原子操作模板
```python
async def atomic_like(page) -> OpResult:
"""点赞操作(前后状态检测模板)"""
# 1. 前置状态检测
state = await detect_page_state(page)
if state not in ('player', 'search_player'):
return OpResult('like', False, f'不在播放页(state={state})')
# 2. 操作执行(键盘优先)
await page.keyboard.press('z')
await asyncio.sleep(1)
# 3. 后置验证(检测按钮状态变化)
ok = await verify_like(page) # 检测 data-e2e-state 或 toast
if ok:
return OpResult('like', True, '👍')
# 4. 回退:DOM 点击
btn = page.locator('[data-e2e="video-player-digg"]')
if await btn.count() > 0:
await btn.click()
await asyncio.sleep(1)
ok = await verify_like(page)
return OpResult('like', ok, '👍' if ok else '-')
```
### 3.2 统一状态检测函数
```python
async def detect_page_state(page) -> str:
"""检测当前页面状态"""
url = page.url
video_count = await page.evaluate("document.querySelectorAll('video').length")
cards = await page.evaluate(
"document.querySelectorAll('.discover-video-card-item, [data-e2e=\"alink-item\"]').length"
)
if video_count > 0 and '/video/' in url:
return 'player_full' # B模式:独立播放页
if video_count > 0 and 'modal_id' in url:
return 'player_modal' # A模式:弹窗播放
if video_count > 0:
return 'player_unknown' # 有视频,模式待定
if '/search/' in url:
return 'search'
if '/user/' in url:
return 'profile'
if cards > 0:
return 'grid' # 首页feed
return 'unknown'
```
### 3.3 每个操作绑定状态依赖
| 操作 | 前置状态要求 | 后置验证方式 |
|:-----|:------------|:-------------|
| `goto_home` | (任意) | state → 'grid' |
| `open_video` | 'grid' | state → 'player_*' |
| `like` | 'player_*' | 按钮state变化或toast出现 |
| `collect` | 'player_*' | 按钮state变化或toast出现 |
| `follow` | 'player_*' | 按钮文字从"关注"变"已关注" |
| `open_comments` | 'player_*' | state → 'comment_panel_open' |
| `post_comment` | 'comment_panel_open' | comment_list中出现刚发的文字 |
| `close_comments` | 'comment_panel_open' | state回到'player_*' |
| `next_video` | 'player_*' | modal_id或video内容变化 |
| `search` | (任意) | state → 'search' |
| `go_back` | (任意) | URL或state变化 |
---
## 四、实施计划
### Phase 1:基础设施(1-2小时)
1. 实现 `detect_page_state()` — 统一状态检测
2. 实现 `verify_*()` 系列 — 每个操作的后置验证函数
3. 实现 `ensure_page_state(target)` — 如果不在目标状态则导航过去
### Phase 2:重写原子操作(2-3小时)
4. 按模板重写 `douyin_ops.py` 中所有操作
5. 按模板重写 `ops/xhs_ops.py` 中所有操作
6. 移除所有无验证的假成功返回
### Phase 3:测试验证(1-2小时)
7. 逐步测试每个操作(用户交互模式)
8. 修复各平台的特定选择器/DOM差异
---
以上是完整的审计和方案。你要我按这个方向执行吗?