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,213 @@
"""
AVE 成本追踪器
记录每次 API 调用的费用估算, 防止不知不觉扣费过多。
用法:
from lib.cost_tracker import CostTracker
tracker = CostTracker()
tracker.log("OmniHuman", duration=4.04, resolution="480P")
tracker.summary()
"""
import json
import os
from datetime import datetime
from pathlib import Path
# ── 已知价格 (元/秒) ───────────────────────────────────────
# 来源: 火山引擎即梦AI官方定价 (截至2026-05-09)
PRICING = {
# OmniHuman 1.5
"OmniHuman_480P": 0.5, # ¥0.5/秒
"OmniHuman_720P": 0.9, # ¥0.9/秒
# Wan2.2 (备用)
"Wan2.2_480P": 0.5,
"Wan2.2_720P": 0.9,
# DreamActor M1 (估算, 与OmniHuman同级)
"DreamActor_480P": 0.5,
"DreamActor_720P": 0.9,
# CosyVoice TTS (阿里云百炼, 按字符计费约 ¥0.002/字符)
"CosyVoice_per_char": 0.002,
# Pexels (免费)
"Pexels": 0,
# Kling LipSync (fal.ai, $0.014/5s ≈ ¥0.10/s)
"KlingLipSync_per_s": 0.10, # ¥0.10/秒 ≈ $0.014/5s
}
# ── 日志路径 ───────────────────────────────────────────────
LOG_DIR = Path(os.environ.get("AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local"))) / "tools" / "ave" / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / "cost_log.jsonl"
class CostTracker:
"""API 成本追踪器"""
def __init__(self):
self._session_log: list[dict] = []
def log(self, service: str, duration: float = 0, resolution: str = "480P",
chars: int = 0, status: str = "success", note: str = ""):
"""
记录一次 API 调用
参数:
service: API 服务名 (OmniHuman, DreamActor, CosyVoice, Pexels)
duration: 视频/音频时长(秒)
resolution: 分辨率 (480P/720P)
chars: CosyVoice 字符数
status: success/failed
note: 备注
"""
# 计算费用
if service == "CosyVoice":
unit_price = PRICING.get("CosyVoice_per_char", 0.002)
cost = round(chars * unit_price, 4)
elif service == "KlingLipSync":
unit_price = PRICING.get("KlingLipSync_per_s", 0.10)
cost = round(duration * unit_price, 4) if status == "success" else 0.0
elif service == "Pexels":
cost = 0.0
else:
key = f"{service}_{resolution}"
unit_price = PRICING.get(key, 0.5)
cost = round(duration * unit_price, 4) if status == "success" else 0.0
record = {
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"service": service,
"duration_s": duration,
"resolution": resolution,
"chars": chars,
"status": status,
"cost_yuan": cost,
"note": note,
}
self._session_log.append(record)
# 追加到日志文件
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
# 同步写入 Dashboard DB (如果当前有 production)
if _current_production_id > 0:
try:
from lib.dashboard import log_cost as _dash_log_cost
_dash_log_cost(
production_id=_current_production_id,
service=service,
step_name=note.split(" ")[0] if " " in note else "",
duration_s=duration,
resolution=resolution,
chars=chars,
cost_yuan=cost,
status=status,
note=note,
)
except ImportError:
pass # dashboard 模块不可用时不报错
# 实时输出
if cost > 0:
print(f" 💰 {service}: {duration:.1f}s × ¥{unit_price}/s = ¥{cost:.2f}")
elif status == "failed":
print(f" 💰 {service}: 失败, 不计费")
else:
print(f" 💰 {service}: 免费")
return cost
def summary(self) -> dict:
"""输出本次会话的费用汇总"""
total = sum(r["cost_yuan"] for r in self._session_log)
success_calls = sum(1 for r in self._session_log if r["status"] == "success")
failed_calls = sum(1 for r in self._session_log if r["status"] == "failed")
print("\n" + "=" * 40)
print("💰 本次会话费用汇总")
print("=" * 40)
print(f" 成功调用: {success_calls} 次")
print(f" 失败调用: {failed_calls} 次")
print(f" 总费用: ¥{total:.2f}")
print(f" 账户余额: 未知 (请登录火山引擎控制台查看)")
print("=" * 40)
# 分服务统计
by_service = {}
for r in self._session_log:
s = r["service"]
if s not in by_service:
by_service[s] = {"count": 0, "total_s": 0, "cost": 0.0}
by_service[s]["count"] += 1
by_service[s]["total_s"] += r["duration_s"] if r["status"] == "success" else 0
by_service[s]["cost"] += r["cost_yuan"]
if by_service:
print("\n 按服务:")
for s, info in sorted(by_service.items()):
print(f" {s:15s} {info['count']}次, {info['total_s']:.1f}s, ¥{info['cost']:.2f}")
return {"total": total, "calls": success_calls + failed_calls}
def get_session_log(self) -> list[dict]:
return self._session_log
def _total_cost(self) -> float:
"""当前会话累计费用"""
return sum(r["cost_yuan"] for r in self._session_log)
# ── Dashboard 集成 ─────────────────────────────────────────
# 当前 production_id, 由 video_factory / main.py 设置
# CostTracker.log() 检测到该值 > 0 时自动写 dashboard DB
_current_production_id: int = 0
def set_current_production_id(pid: int):
"""设置当前 production ID, 后续 cost log 自动关联"""
global _current_production_id
_current_production_id = pid
def get_current_production_id() -> int:
return _current_production_id
# ── 全局单例 (在 main.py 中使用) ────────────────────────────
_tracker: CostTracker | None = None
def get_tracker() -> CostTracker:
global _tracker
if _tracker is None:
_tracker = CostTracker()
return _tracker
def print_history(days: int = 7):
"""打印最近 N 天的费用历史"""
if not LOG_FILE.exists():
print("暂无费用记录")
return
from datetime import timedelta
cutoff = datetime.now() - timedelta(days=days)
total = 0.0
count = 0
with open(LOG_FILE, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
t = datetime.strptime(r["time"], "%Y-%m-%d %H:%M:%S")
if t >= cutoff:
total += r["cost_yuan"]
count += 1
print(f" [{r['time']}] {r['service']:15s} {r['duration_s']:5.1f}s ¥{r['cost_yuan']:.2f} {r['note']}")
except (json.JSONDecodeError, KeyError, ValueError):
pass
print(f"\n 近 {days} 天: {count} 次调用, 总费用 ¥{total:.2f}")