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 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
# 内容抓取系统 — 全链路验证方案 v1
|
||||
|
||||
> 日期:2026-07-16 | 状态:验证计划
|
||||
> 覆盖:scrape_db.py → scrape_engine.py → routes/scrape.py → 前端 views/scrape.js
|
||||
|
||||
## 测试目标
|
||||
|
||||
确保内容抓取系统能端到端工作:从前端输入 sec_uid/URL → 后端适配器采集 → SQLite 存储 → 前端展示结果。
|
||||
|
||||
---
|
||||
|
||||
## Level 0:基础设施验证(3 项)
|
||||
|
||||
### L0-1 ✅ Python import 全链路
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/10_dashboard
|
||||
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/python3 -c "
|
||||
from services.scrape_db import ScrapeDB
|
||||
from services.scrape_engine import ScrapeEngine
|
||||
from services.adapters import ScrapeAdapter
|
||||
from services.adapters.douyin_scrape import DouyinScrapeAdapter
|
||||
from services.adapters.browser_helpers import page_evaluate
|
||||
from routes.scrape import router
|
||||
from plugins.crawl import CrawlDashboardPlugin
|
||||
print('✅ All imports OK')
|
||||
"
|
||||
```
|
||||
|
||||
预期:无 ModuleNotFoundError / ImportError
|
||||
|
||||
### L0-2 ✅ SQLite 数据库创建
|
||||
```bash
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/python3 -c "
|
||||
from services.scrape_db import ScrapeDB
|
||||
db = ScrapeDB()
|
||||
print(f'db_path: {db.db_path}')
|
||||
tables = db.list_tasks()
|
||||
print(f'Tables initialized OK, query returned {len(tables)} rows')
|
||||
"
|
||||
```
|
||||
预期:数据库文件 `agent-local/data/scrape.db` 创建成功,4 张表唯美创建
|
||||
|
||||
### L0-3 ✅ FastAPI 路由注册
|
||||
```bash
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/python3 -c "
|
||||
from routes.scrape import router
|
||||
routes = [r.path for r in router.routes]
|
||||
for r in routes: print(f' {r}')
|
||||
"
|
||||
```
|
||||
预期:看到 `/api/scrape/run`、`/api/scrape/resolve`、`/api/scrape/tasks`、`/api/scrape/items`、`/api/scrape/stats`、`/api/scrape/sources` 等端点
|
||||
|
||||
---
|
||||
|
||||
## Level 1:数据库层(3 项)
|
||||
|
||||
### L1-1 创建任务 + 查任务
|
||||
```python
|
||||
db = ScrapeDB()
|
||||
task_id = db.create_task(type='single', platform='douyin', target='test_url',
|
||||
depth='light', tool_level=2, machine='local', total_targets=1)
|
||||
task = db.get_task(task_id)
|
||||
assert task['status'] == 'pending'
|
||||
assert task['target'] == 'test_url'
|
||||
print(f'✅ Task #{task_id} created: {task}')
|
||||
```
|
||||
|
||||
### L1-2 写入内容 + 去重
|
||||
```python
|
||||
item_id = db.insert_item(task_id=task_id, platform='douyin', item_id='video_001',
|
||||
url='https://douyin.com/video/1', title='测试视频',
|
||||
author_name='测试作者', stats={'likes': 100, 'comments': 5})
|
||||
assert item_id > 0
|
||||
# 写入相同 item_id → IGNORE(去重)
|
||||
item_id2 = db.insert_item(task_id=task_id, platform='douyin', item_id='video_001',
|
||||
title='should be ignored')
|
||||
assert db.item_exists('douyin', 'video_001') == True
|
||||
print(f'✅ Item written, dedup works')
|
||||
```
|
||||
|
||||
### L1-3 评论入库 + 查评论
|
||||
```python
|
||||
comments = [{'author': '用户A', 'text': '好!', 'likes': 10}]
|
||||
db.insert_comments(item_db_id=item_id, comments=comments)
|
||||
got = db.get_comments(item_db_id=item_id)
|
||||
assert len(got) == 1
|
||||
assert got[0]['author_name'] == '用户A'
|
||||
print(f'✅ Comments work: {got}')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level 2:适配器层 — Mock 测试(3 项)
|
||||
|
||||
### L2-1 工具降级逻辑
|
||||
```python
|
||||
# Mock _try_tools: 模拟 OpenCLI 返回数据
|
||||
class MockAdapter(ScrapeAdapter):
|
||||
platform = 'test'
|
||||
adapter_name = 'test_scrape'
|
||||
|
||||
async def collect_user(self, uid, limit=20):
|
||||
ok, data, tool = await self._try_tools(tool_level=2, funcs=[
|
||||
('opencli', lambda: self._mock_success(uid)),
|
||||
('browser', lambda: self._mock_success(uid)),
|
||||
])
|
||||
return data if ok else None
|
||||
|
||||
async def _mock_success(self, uid):
|
||||
return [{'item_id': 'mock_1', 'title': f'Mock video for {uid}'}]
|
||||
adapter = MockAdapter()
|
||||
result = await adapter.collect_user('user_001')
|
||||
assert len(result) == 1
|
||||
assert result[0]['item_id'] == 'mock_1'
|
||||
```
|
||||
|
||||
### L2-2 工具降级:一级失败,二级成功
|
||||
```python
|
||||
class FallbackAdapter(ScrapeAdapter):
|
||||
platform = 'test'
|
||||
adapter_name = 'test_fallback'
|
||||
async def collect_user(self, uid, limit=20):
|
||||
ok, data, tool = await self._try_tools(tool_level=2, funcs=[
|
||||
('opencli', lambda: self._mock_fail('opencli died')),
|
||||
('browser', lambda: self._mock_success(uid)),
|
||||
])
|
||||
return (ok, data, tool)
|
||||
async def _mock_fail(self, msg):
|
||||
raise RuntimeError(msg)
|
||||
async def _mock_success(self, uid):
|
||||
return [{'item_id': 'fallback_1'}]
|
||||
adapter = FallbackAdapter()
|
||||
ok, data, tool = await adapter.collect_user('x')
|
||||
assert ok and tool == 'browser'
|
||||
```
|
||||
|
||||
### L2-3 统一 Schema 输出
|
||||
确保各适配器输出的 `collect_item()` 返回的 dict 包含以下字段(无论平台):
|
||||
```python
|
||||
required_keys = ['platform', 'item_id', 'url', 'title', 'author_name',
|
||||
'author_id', 'published_at', 'text_content', 'tags',
|
||||
'stats', 'extra', 'media', 'comments']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level 3:适配器层 — 真实 OpenCLI 验证(3 项)
|
||||
|
||||
> 需要 OpenCLI 已安装 + 抖音/小红书 Chrome 已登录
|
||||
|
||||
### L3-1 抖音用户视频采集
|
||||
```python
|
||||
from services.adapters.douyin_scrape import DouyinScrapeAdapter
|
||||
adapter = DouyinScrapeAdapter()
|
||||
items = await adapter.collect_user('r606391422378804368') # 或任意有公开视频的 sec_uid
|
||||
assert items, '没有采集到内容'
|
||||
assert len(items) >= 1
|
||||
print(f'🎵 抖音采集: {len(items)} 条')
|
||||
print(f' 第1条: {items[0].get("title","?")} (👍 {items[0].get("stats",{}).get("likes","?")})')
|
||||
```
|
||||
|
||||
### L3-2 小红书用户笔记采集
|
||||
```python
|
||||
from services.adapters.xhs_scrape import XhsScrapeAdapter
|
||||
adapter = XhsScrapeAdapter()
|
||||
items = await adapter.collect_user('xxx') # 替换为真实 sec_uid
|
||||
assert items, '没有采集到内容'
|
||||
print(f'📕 小红书采集: {len(items)} 条')
|
||||
```
|
||||
|
||||
### L3-3 视频/笔记详情采集
|
||||
```python
|
||||
item = await adapter.collect_item('https://www.douyin.com/video/xxx')
|
||||
assert item.get('title'), '详情采集失败'
|
||||
assert item.get('comments'), '评论采集失败'
|
||||
print(f'✅ 详情+评论: {len(item.get("comments",[]))} 条评论')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level 4:引擎层 — ScrapeEngine(3 项)
|
||||
|
||||
### L4-1 解析 URL
|
||||
```python
|
||||
engine = ScrapeEngine()
|
||||
results = await engine.resolve_targets([
|
||||
'https://www.douyin.com/video/7660182459061436145',
|
||||
'https://v.douyin.com/wTknxmL_tgc/',
|
||||
'r606391422378804368', # sec_uid
|
||||
])
|
||||
for r in results:
|
||||
print(f' {r.status}: {r.platform}/{r.type} → {r.target_id}')
|
||||
```
|
||||
|
||||
### L4-2 执行单次采集(完整链路)
|
||||
```python
|
||||
engine = ScrapeEngine()
|
||||
targets = [{'input': 'r606391422378804368', 'platform': 'douyin', 'type': 'user', 'target_id': 'r606391422378804368'}]
|
||||
task_id = engine._create_task('batch', targets, depth='light', tool_level=2)
|
||||
items = await engine._collect_batch(task_id, targets)
|
||||
print(f' {len(items)} 条采集结果')
|
||||
for item in items:
|
||||
print(f' ✅ {item.get("title","?")} (👍 {item.get("stats",{}).get("likes","?")})')
|
||||
```
|
||||
|
||||
### L4-3 异步任务轮询
|
||||
```python
|
||||
# 创建异步任务
|
||||
result = await engine.run(targets=['r606391422378804368'], async_mode=True)
|
||||
assert result['status'] == 'async'
|
||||
run_id = result['run_id']
|
||||
# 轮询
|
||||
import time
|
||||
for i in range(5):
|
||||
time.sleep(2)
|
||||
status = await engine.get_result(run_id)
|
||||
if status['status'] == 'completed':
|
||||
break
|
||||
assert status['status'] == 'completed'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level 5:API 层 — HTTP 端点(4 项)
|
||||
|
||||
> 需要 Dashboard 运行中(port 9988)
|
||||
|
||||
### L5-1 POST /api/scrape/resolve
|
||||
```bash
|
||||
curl -s -X POST http://localhost:9988/api/scrape/resolve \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"targets":["r606391422378804368","https://www.douyin.com/video/7660182459061436145"]}' \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
预期:返回目标列表,status 为 resolved
|
||||
|
||||
### L5-2 POST /api/scrape/run(同步)
|
||||
```bash
|
||||
curl -s -X POST http://localhost:9988/api/scrape/run \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"targets":["r606391422378804368"],"tool_level":1,"async_mode":false}' \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d.get(\"status\")}, items={len(d.get(\"data\",[]))}')"
|
||||
```
|
||||
预期:同步返回采集结果
|
||||
|
||||
### L5-3 GET /api/scrape/items
|
||||
```bash
|
||||
curl -s http://localhost:9988/api/scrape/items | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d.get(\"data\",[]))} items')"
|
||||
```
|
||||
预期:返回数据库中的采集内容列表
|
||||
|
||||
### L5-4 GET /api/scrape/stats
|
||||
```bash
|
||||
curl -s http://localhost:9988/api/scrape/stats | python3 -m json.tool
|
||||
```
|
||||
预期:{total: N, today: N, sources: N}
|
||||
|
||||
---
|
||||
|
||||
## Level 6:前端 — UI 验证(4 项)
|
||||
|
||||
> 需要打开浏览器访问 http://localhost:9988
|
||||
|
||||
### L6-1 导航栏显示
|
||||
- [ ] 侧边栏导航中看到「内容抓取」入口
|
||||
- [ ] 点击后跳转到抓取页面
|
||||
- [ ] 页面顶部显示三个 Tab:新建采集 / 采集源 / 历史
|
||||
|
||||
### L6-2 新建采集流程
|
||||
- [ ] 输入 sec_uid,点击执行
|
||||
- [ ] 日志区显示进度和结果
|
||||
- [ ] 结果区展示表格(# / 标题 / 作者 / 点赞 / 评论 / 发布时间)
|
||||
- [ ] 批量导入多个 URL 可以正常解析
|
||||
|
||||
### L6-3 采集源管理
|
||||
- [ ] 添加采集源成功
|
||||
- [ ] 采集源列表展示
|
||||
- [ ] 删除采集源成功
|
||||
|
||||
### L6-4 历史记录
|
||||
- [ ] 历史页面展示任务列表(时间 / 类型 / 目标 / 状态 / 结果数)
|
||||
- [ ] 点开后能看到详细结果
|
||||
|
||||
---
|
||||
|
||||
## Level 7:边界与异常(4 项)
|
||||
|
||||
### L7-1 OpenCLI 不可用
|
||||
当 OpenCLI 未安装或不可用时,引擎应该:
|
||||
- 抛出清晰的错误信息提示安装 OpenCLI
|
||||
- 如果 tool_level=2,自动降级到浏览器提取
|
||||
- 日志记录具体失败原因
|
||||
|
||||
### L7-2 不支持的平台
|
||||
```python
|
||||
engine = ScrapeEngine()
|
||||
result = await engine._collect_one({'input':'test','type':'user','target_id':'test'}, platform='unknown')
|
||||
assert result is None
|
||||
```
|
||||
|
||||
### L7-3 空目标列表
|
||||
```python
|
||||
result = await engine.run(targets=[])
|
||||
assert result['status'] == 'error'
|
||||
```
|
||||
|
||||
### L7-4 数据库写入失败
|
||||
当 SQLite 磁盘满或权限不足时,应返回错误而非崩溃
|
||||
```python
|
||||
# 用只读模式创建 DB
|
||||
import tempfile
|
||||
db = ScrapeDB('/dev/null') # 预期:报错而非崩溃
|
||||
```
|
||||
Reference in New Issue
Block a user