Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/routes/ave_docs.py
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

83 lines
3.1 KiB
Python

"""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": "抓取已在后台开始(完成刷新页面即可看到)"}