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,82 @@
|
||||
"""routes/ave_docs.py — 📚 API 文档中心
|
||||
|
||||
把本地存档的各平台官方 API 文档(`09_ave/docs/api/`)暴露给看板:
|
||||
· GET /api/ave/docs/index 文档索引(各平台 + 页面清单)
|
||||
· GET /api/ave/docs/page?file=raw/... 读取单个文档正文(Markdown)
|
||||
· GET /api/ave/docs/refresh 触发重新抓取(后台线程,只读网络)
|
||||
|
||||
数据来源:`09_ave/docs/api/index.json`(由 scripts/tools/fetch_api_docs.py 生成)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
log = logging.getLogger("dashboard.ave_docs")
|
||||
|
||||
router = APIRouter(prefix="/api/ave/docs", tags=["ave-docs"])
|
||||
|
||||
# 与其它 ave 路由一致的路径解析方式(支持 AGENT_SYNC 覆盖)
|
||||
_AGENT_SYNC = Path(os.environ.get("AGENT_SYNC",
|
||||
str(Path(__file__).resolve().parents[3])))
|
||||
AVE_ROOT = _AGENT_SYNC / "05_tools" / "09_ave"
|
||||
DOCS_DIR = AVE_ROOT / "docs" / "api"
|
||||
INDEX = DOCS_DIR / "index.json"
|
||||
PY = os.environ.get("AGENTOS_PYTHON") or sys.executable
|
||||
|
||||
|
||||
def _load_index() -> dict:
|
||||
if not INDEX.exists():
|
||||
return {"generated_at": None, "sources": [], "hint": "尚未抓取,点「🔄 更新文档」"}
|
||||
try:
|
||||
return json.loads(INDEX.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, detail=f"索引读取失败: {e}")
|
||||
|
||||
|
||||
@router.get("/index")
|
||||
def api_docs_index():
|
||||
"""文档索引:各平台 + 页面清单(含本地文件路径 / 原文 URL)。"""
|
||||
d = _load_index()
|
||||
d["docs_dir"] = str(DOCS_DIR)
|
||||
d["has_local"] = any((p.get("file") for s in d.get("sources", []) for p in s.get("pages", [])))
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/page")
|
||||
def api_docs_page(file: str):
|
||||
"""读取单个文档正文(限制在 docs/api 目录内,防目录穿越)。"""
|
||||
p = (DOCS_DIR / file).resolve()
|
||||
base = DOCS_DIR.resolve()
|
||||
if not str(p).startswith(str(base)):
|
||||
raise HTTPException(400, detail="非法路径")
|
||||
if not p.exists() or not p.is_file():
|
||||
raise HTTPException(404, detail=f"文档不存在: {file}")
|
||||
return {"file": file, "bytes": p.stat().st_size,
|
||||
"content": p.read_text(encoding="utf-8", errors="ignore")}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
def api_docs_refresh(force: bool = False):
|
||||
"""触发重新抓取(后台线程)。force=true 时全量重抓。"""
|
||||
script = AVE_ROOT / "scripts" / "tools" / "fetch_api_docs.py"
|
||||
if not script.exists():
|
||||
raise HTTPException(500, detail=f"抓取脚本不存在: {script}")
|
||||
|
||||
def _run():
|
||||
try:
|
||||
cmd = [PY, str(script)] + (["--force"] if force else [])
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=900)
|
||||
log.info("文档抓取完成 rc=%s\n%s", r.returncode, (r.stdout or "")[-800:])
|
||||
except Exception as e:
|
||||
log.warning("文档抓取失败: %s", e)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return {"status": "ok", "note": "抓取已在后台开始(完成刷新页面即可看到)"}
|
||||
Reference in New Issue
Block a user