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,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}")
|
||||
Reference in New Issue
Block a user