移植自 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 带网关原话 / 正常返回)。
655 lines
23 KiB
Python
655 lines
23 KiB
Python
"""
|
||
routes/ops.py — 统一操作执行路由 v5
|
||
|
||
⚠️ 本文件是 Dashboard 通往执行层的唯一入口。
|
||
所有操作(养号/采集/登录/评论/点赞)都经过这里。
|
||
|
||
调用规范:
|
||
前端统一发 POST /api/ops/run {type, accounts, params}
|
||
→ CommandBus.dispatch() → CMD_REGISTRY 模板映射 → mc 引擎
|
||
|
||
禁止:
|
||
- 在前端直接调用 routes/matrix.py 的 POST 路由
|
||
- 在 routes/matrix.py 中新增 POST 写操作
|
||
- 绕过 CommandBus 直接 subprocess
|
||
|
||
操作类型(定义在 CMD_REGISTRY 中):
|
||
- nurture: 养号执行,走 nurture_runner.sh 包装器
|
||
- collect: 主页采集,auto_blueprint 自动按账号平台选蓝图
|
||
- login: 智能登录,走 mc smart-login
|
||
- logout: 登出
|
||
- comment: 定向评论
|
||
- like: 点赞
|
||
|
||
修复记录:
|
||
2026-06-28: /api/ops/queue 改为 ThreadPoolExecutor 并行查询,避免远程超时堵塞
|
||
"""
|
||
|
||
import logging
|
||
from fastapi import APIRouter, HTTPException
|
||
from services.command_bus import CommandBus, MachineSession
|
||
|
||
logger = logging.getLogger("dashboard.ops")
|
||
router = APIRouter(prefix="/api/ops", tags=["ops"])
|
||
|
||
|
||
@router.post("/run")
|
||
def api_ops_run(data: dict = {}):
|
||
"""
|
||
统一操作执行入口
|
||
|
||
请求体:
|
||
type: str — 操作类型 (nurture/collect/login/logout/comment/like/smart_comment)
|
||
accounts: [str] — 账号ID列表
|
||
params: dict — 操作特定参数(可选)
|
||
blueprint: str — 蓝图名(nurture 用,不传则自动检测)
|
||
rounds: int — 轮数(nurture 用,默认 10)
|
||
phone: str — 手机号(collect 用)
|
||
url: str — 视频链接(comment/like 用)
|
||
urls: [str] — 视频链接列表(smart_comment 用)
|
||
direction: str — 评论方向(praise/question/empathy)
|
||
machine: str — 强制指定目标机器
|
||
dry_run: bool — 仅预览不执行
|
||
"""
|
||
try:
|
||
op_type = data.get("type", "")
|
||
accounts = data.get("accounts", [])
|
||
params = data.get("params", {})
|
||
|
||
if not op_type:
|
||
return {"status": "error", "message": "type 必填 (nurture/collect/login/logout/comment/like/smart_comment)"}
|
||
if not accounts:
|
||
return {"status": "error", "message": "accounts 必填"}
|
||
|
||
wait = data.get("wait", False)
|
||
if data.get("dry_run") or params.get("dry_run"):
|
||
params["dry_run"] = True
|
||
|
||
result = CommandBus.dispatch(op_type, accounts, params, wait=wait)
|
||
|
||
# wait=True 时响应可能包含执行结果摘要
|
||
if wait and result.get("status") == "completed":
|
||
summary = result.get("per_machine", {})
|
||
s = f"完成: "
|
||
for m, info in summary.items():
|
||
s += f"{m}: {info.get('success',0)}成功/{info.get('failed',0)}失败 "
|
||
logger.info(f"批量执行完毕 — {s}")
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.exception("ops/run 失败")
|
||
raise HTTPException(500, detail=str(e))
|
||
|
||
|
||
@router.get("/status")
|
||
def api_ops_status(machine: str = None, account: str = None):
|
||
"""查询所有命令状态"""
|
||
return {"commands": CommandBus.get_status(machine=machine, account=account)}
|
||
|
||
|
||
@router.get("/history")
|
||
def api_ops_history(limit: int = 20):
|
||
"""查询命令执行历史(含每台机器的执行结果)"""
|
||
return {"commands": CommandBus.get_status()[:limit]}
|
||
|
||
|
||
@router.get("/machines")
|
||
def api_ops_machines():
|
||
"""查询所有机器聚合状态"""
|
||
return CommandBus.get_all_machines_status()
|
||
|
||
|
||
@router.get("/running")
|
||
def api_ops_running():
|
||
"""聚合所有机器的运行中任务(从 guardd 获取)"""
|
||
from services.command_bus import _guardd_api, HOSTNAME, _get_machine_info
|
||
machines = []
|
||
# 本机
|
||
local_tasks = _guardd_api("GET", "/scheduler/tasks") or {}
|
||
local_active = local_tasks.get("active") or local_tasks
|
||
if local_active:
|
||
machines.append({"machine": HOSTNAME, "tasks": [local_active] if isinstance(local_active, dict) else local_active})
|
||
# 远程机器
|
||
info = _get_machine_info("dummy")
|
||
for name, minfo in getattr(_get_machine_info, "_oracle_machines", {}).items():
|
||
if name == HOSTNAME:
|
||
continue
|
||
ip = minfo.get("tailscale_ip", "")
|
||
if ip:
|
||
remote_tasks = _guardd_api("GET", "/scheduler/tasks", machine=name) or {}
|
||
if remote_tasks:
|
||
machines.append({"machine": name, "tasks": remote_tasks})
|
||
return {"machines": machines}
|
||
|
||
|
||
@router.post("/test-atom")
|
||
def api_ops_test_atom(data: dict = {}):
|
||
"""
|
||
单步原子操作测试
|
||
动态生成单步骤蓝图 → 通过 command_bus 执行
|
||
|
||
请求体:
|
||
op: str — 原子操作名称 (goto_home/scroll_feed/like 等)
|
||
account: str — 账号ID
|
||
platform: str — 平台 (douyin/xiaohongshu),选填,自动检测
|
||
"""
|
||
import json, tempfile, os, uuid
|
||
from pathlib import Path
|
||
|
||
op = data.get("op", "")
|
||
account = data.get("account", "")
|
||
platform = data.get("platform", "douyin")
|
||
|
||
if not op:
|
||
return {"status": "error", "message": "op 必填"}
|
||
if not account:
|
||
return {"status": "error", "message": "account 必填"}
|
||
|
||
try:
|
||
# 动态生成单步骤蓝图
|
||
bp = {
|
||
"id": f"test_{op}_{uuid.uuid4().hex[:8]}",
|
||
"name": f"测试-{op}",
|
||
"description": f"单步测试: {op}",
|
||
"version": "1.0",
|
||
"platform": platform,
|
||
"steps": [{"step_id": 1, "op": op, "args": {}}]
|
||
}
|
||
|
||
# 写入临时蓝图文件
|
||
bp_dir = Path(__file__).parent.parent / ".." / "07_matrix" / "blueprints"
|
||
bp_file = bp_dir / f"_test_{op}.json"
|
||
bp_file.parent.mkdir(parents=True, exist_ok=True)
|
||
bp_file.write_text(json.dumps(bp, ensure_ascii=False, indent=2))
|
||
|
||
# 执行 (1轮,不等待 → 后台异步执行)
|
||
result = CommandBus.dispatch("nurture", [account], {
|
||
"blueprint": f"_test_{op}".replace(".json",""),
|
||
"rounds": 1,
|
||
"single_op": op,
|
||
}, wait=False)
|
||
|
||
# 清理临时蓝图
|
||
if bp_file.exists():
|
||
bp_file.unlink()
|
||
|
||
return {
|
||
"status": result.get("status", "submitted"),
|
||
"op": op,
|
||
"account": account,
|
||
"details": result.get("message", result.get("per_machine", "已提交")),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.exception("test-atom 失败")
|
||
raise HTTPException(500, detail=str(e))
|
||
|
||
|
||
@router.get("/policy")
|
||
def api_ops_policy():
|
||
"""获取当前执行策略配置(供看板前端展示约束条)"""
|
||
import sys, os
|
||
scripts_dir = os.path.join(os.path.dirname(__file__), "..", "..", "07_matrix", "scripts")
|
||
sys.path.insert(0, scripts_dir)
|
||
try:
|
||
from mc.execution_policy import get_policy, preflight as _pf
|
||
policy = get_policy()
|
||
status = _pf()
|
||
policy["current_status"] = status
|
||
return policy
|
||
except Exception as e:
|
||
return {"version": "unknown", "error": str(e), "max_concurrent": 3}
|
||
|
||
|
||
@router.post("/cancel/{run_id}")
|
||
def api_ops_cancel(run_id: str):
|
||
"""取消命令"""
|
||
return CommandBus.cancel(run_id)
|
||
|
||
|
||
@router.post("/cleanup-stale")
|
||
def api_ops_cleanup_stale():
|
||
"""清理僵尸命令:进程已死但状态为 running 的标记为 CRASHED,并释放队列"""
|
||
from services.command_bus import cleanup_stale_commands
|
||
count = cleanup_stale_commands()
|
||
return {"status": "ok", "cleaned": count}
|
||
|
||
|
||
@router.get("/log/{run_id}")
|
||
def api_ops_log(run_id: str):
|
||
"""查看命令日志"""
|
||
from pathlib import Path
|
||
log_dir = Path.home() / "workbuddy-agent-os" / "agent-local" / "runtime" / "commands"
|
||
for f in sorted(log_dir.glob(f"{run_id}*"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||
if f.exists():
|
||
tail = f.read_text(encoding="utf-8", errors="replace")[-5000:]
|
||
return {"run_id": run_id, "log": tail, "path": str(f)}
|
||
return {"run_id": run_id, "log": "", "path": ""}
|
||
|
||
|
||
@router.get("/queue")
|
||
def api_ops_queue(machine: str = None):
|
||
"""查看各机任务队列详情(含活跃任务、排队、槽位)
|
||
|
||
使用 ThreadPoolExecutor 并行查询所有机器,避免单机超时堵塞整体。
|
||
个别机器超时/离线不影响其他机器结果。
|
||
"""
|
||
from services.command_bus import _guardd_api, ORACLE_PATH
|
||
machines = [machine] if machine else []
|
||
if not machines:
|
||
try:
|
||
import yaml
|
||
oracle = yaml.safe_load(ORACLE_PATH.read_text())
|
||
machines = list(oracle.get("machines", {}).keys())
|
||
except Exception:
|
||
machines = [__import__("utils.identity", fromlist=["resolve_hostname"]).resolve_hostname()]
|
||
|
||
results = {}
|
||
|
||
def query_machine(m):
|
||
"""查询单台机器,返回 (machine_name, data)"""
|
||
try:
|
||
data = _guardd_api("GET", "/scheduler/tasks", machine=m)
|
||
if data:
|
||
return (m, data)
|
||
else:
|
||
from services.command_bus import CommandBus
|
||
ms = CommandBus.get_machine_status(m)
|
||
return (m, {"active": ms.get("active_task"), "error": "guardd scheduler 未响应"})
|
||
except Exception as e:
|
||
return (m, {"error": str(e)})
|
||
|
||
# 并行查询所有机器(本机快,远程可能超时,但不阻塞彼此)
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
with ThreadPoolExecutor(max_workers=len(machines)) as pool:
|
||
futures = {pool.submit(query_machine, m): m for m in machines}
|
||
for future in as_completed(futures):
|
||
try:
|
||
m, data = future.result()
|
||
results[m] = data
|
||
except Exception as e:
|
||
m = futures[future]
|
||
results[m] = {"error": str(e)}
|
||
|
||
return {"machines": results}
|
||
|
||
|
||
@router.post("/task/cancel")
|
||
def api_ops_task_cancel(data: dict = {}):
|
||
"""取消任务 — 发送到对应机器的 guardd"""
|
||
task_id = data.get("task_id", "")
|
||
machine = data.get("machine", "")
|
||
if not task_id:
|
||
return {"status": "error", "message": "task_id 必填"}
|
||
from services.command_bus import _guardd_api
|
||
result = _guardd_api("POST", f"/task/{task_id}/stop", {}, machine=machine)
|
||
return {"status": "ok", "task_id": task_id, "result": result}
|
||
|
||
|
||
@router.post("/task/submit")
|
||
def api_ops_task_submit(data: dict = {}):
|
||
"""提交任务到调度器 — 发到目标机器的 guardd"""
|
||
task = data.get("task", {})
|
||
machine = data.get("machine", "")
|
||
if not task.get("task_id"):
|
||
return {"status": "error", "message": "task.task_id 必填"}
|
||
from services.command_bus import _guardd_api
|
||
result = _guardd_api("POST", "/scheduler/submit", task, machine=machine)
|
||
return {"status": "accepted", "result": result}
|
||
|
||
|
||
@router.post("/reset")
|
||
def api_ops_reset(data: dict = {}):
|
||
"""重置机器:清空任务队列 + 杀浏览器
|
||
|
||
Body:
|
||
machine: str — 指定机器名,不传则重置所有机器
|
||
"""
|
||
from services.command_bus import _guardd_api, ORACLE_PATH, HOSTNAME
|
||
import yaml
|
||
|
||
requested = data.get("machine", "")
|
||
if requested:
|
||
machines = [requested]
|
||
else:
|
||
# 重置所有机器
|
||
try:
|
||
oracle = yaml.safe_load(ORACLE_PATH.read_text())
|
||
machines = list(oracle.get("machines", {}).keys())
|
||
except:
|
||
machines = [HOSTNAME]
|
||
|
||
results = {}
|
||
for m in machines:
|
||
try:
|
||
# 调用 guardd scheduler/reset(杀活跃+清队列+杀浏览器)
|
||
r = _guardd_api("POST", "/scheduler/reset", {}, machine=m)
|
||
results[m] = "ok" if r else "no response"
|
||
except Exception as e:
|
||
results[m] = str(e)
|
||
|
||
return {"status": "ok", "machines": results}
|
||
|
||
|
||
import time as _time
|
||
import re as _re
|
||
|
||
def _extract_title(share_text: str) -> str:
|
||
"""从抖音分享文案中提取视频标题"""
|
||
if not share_text:
|
||
return ""
|
||
# 格式: "2.38 复制打开抖音,看看【「作者」的作品】标题文字#话题 https://..."
|
||
m = _re.search(r'】(.+?)(?:#|https?://)', share_text)
|
||
if m:
|
||
return m.group(1).strip()
|
||
# 兜底: 取 "看看【..." 之后到 # 或 URL 之前的内容
|
||
m2 = _re.search(r'看看【.*?】(.+?)(?:#|https?://)', share_text)
|
||
if m2:
|
||
return m2.group(1).strip()
|
||
return share_text[:80].strip()
|
||
|
||
|
||
@router.post("/import-topics")
|
||
def api_import_topics(data: dict = {}):
|
||
"""从外部 API 导入今日热门视频列表"""
|
||
import urllib.request as _urq
|
||
import json as _js
|
||
|
||
api_url = (data.get("api_url") or "").strip()
|
||
if not api_url:
|
||
return {"status": "error", "message": "api_url 必填"}
|
||
page = int(data.get("page", 1))
|
||
page_size = int(data.get("page_size", 100))
|
||
|
||
# 查询已成功评论的 ID 列表(去重用)
|
||
commented_ids = set()
|
||
try:
|
||
from services.command_bus import _load_batch_logs
|
||
for log in _load_batch_logs():
|
||
for vid in (log.get("video_ids") or []):
|
||
commented_ids.add(str(vid))
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
full_url = f"{api_url}?page={page}&pageSize={page_size}"
|
||
resp = _urq.urlopen(full_url, timeout=15)
|
||
body = _js.loads(resp.read().decode())
|
||
except Exception as e:
|
||
return {"status": "error", "message": f"请求外部 API 失败: {e}"}
|
||
|
||
raw_items = body.get("data", {}).get("items", [])
|
||
items = []
|
||
for item in raw_items:
|
||
url = (item.get("share_url") or "").strip()
|
||
if not url:
|
||
continue
|
||
vid = str(item.get("id", ""))
|
||
title = _extract_title(item.get("share_text", ""))
|
||
# 从 share_text 提取话题标签
|
||
share_text = item.get("share_text", "")
|
||
tags = _re.findall(r'#([^#\s]+)', share_text)
|
||
items.append({
|
||
"id": vid,
|
||
"url": url,
|
||
"title": title,
|
||
"author": item.get("nickname", "") or item.get("name", ""),
|
||
"tags": tags[:5], # 最多5个标签
|
||
"created_at": item.get("created_at", ""),
|
||
"completed": item.get("completion_status", 0) == 1,
|
||
"already_commented": vid in commented_ids,
|
||
})
|
||
|
||
return {
|
||
"status": "ok",
|
||
"items": items,
|
||
"total": len(items),
|
||
"page": page,
|
||
"page_size": page_size,
|
||
}
|
||
|
||
|
||
_BATCH_LOG_DIR = None
|
||
|
||
def _get_batch_log_dir():
|
||
global _BATCH_LOG_DIR
|
||
if _BATCH_LOG_DIR is None:
|
||
from pathlib import Path
|
||
_BATCH_LOG_DIR = Path.home() / "workbuddy-agent-os" / "agent-local" / "runtime" / "commands" / "batches"
|
||
_BATCH_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
return _BATCH_LOG_DIR
|
||
|
||
|
||
@router.post("/clear-all")
|
||
def api_ops_clear_all(data: dict = {}):
|
||
"""清空所有机器的所有任务(杀死运行中 + 清空队列 + 重置状态)
|
||
|
||
改进:增加超时、逐台返回结果、整体 status 反映实际结果
|
||
"""
|
||
from services.command_bus import _guardd_api, ORACLE_PATH, HOSTNAME
|
||
import yaml
|
||
try:
|
||
oracle = yaml.safe_load(ORACLE_PATH.read_text())
|
||
machines = list(oracle.get("machines", {}).keys())
|
||
except Exception:
|
||
machines = [HOSTNAME]
|
||
|
||
results = {}
|
||
all_ok = True
|
||
for m in machines:
|
||
try:
|
||
r = _guardd_api("POST", "/scheduler/clear-all", machine=m, timeout=10)
|
||
r_status = r.get("status", "error")
|
||
results[m] = r_status
|
||
if r_status != "ok":
|
||
all_ok = False
|
||
except Exception as e:
|
||
results[m] = str(e)[:60]
|
||
all_ok = False
|
||
return {"status": "ok" if all_ok else "partial", "machines": results}
|
||
|
||
|
||
@router.post("/collect-p2")
|
||
def api_ops_collect_p2(data: dict = {}):
|
||
"""提交 P2 级别采集任务(填空闲 slot 用)"""
|
||
accounts = data.get("accounts", [])
|
||
if not accounts:
|
||
return {"status": "error", "message": "accounts 必填"}
|
||
from services.command_bus import CommandBus
|
||
try:
|
||
result = CommandBus.dispatch("collect", accounts, {
|
||
"priority": 2,
|
||
"blueprint": data.get("blueprint", "douyin_read_profile"),
|
||
"rounds": 1,
|
||
})
|
||
return {
|
||
"status": "ok",
|
||
"total": len(accounts),
|
||
"detail": result,
|
||
}
|
||
except Exception as e:
|
||
return {"status": "error", "message": str(e)}
|
||
|
||
|
||
@router.post("/batch-log")
|
||
def api_batch_log_save(data: dict = {}):
|
||
"""保存一批任务的执行记录"""
|
||
import json, threading, time
|
||
log_dir = _get_batch_log_dir()
|
||
batch_id = data.get("batch_id", f"batch_{int(time.time())}")
|
||
log_entry = {
|
||
"batch_id": batch_id,
|
||
"time": data.get("time", time.strftime("%Y-%m-%d %H:%M:%S")),
|
||
"type": data.get("type", "smart_comment"),
|
||
"total": data.get("total", 0),
|
||
"video_count": data.get("video_count", 0),
|
||
"account_count": data.get("account_count", 0),
|
||
"urls": data.get("urls", []),
|
||
"video_ids": data.get("video_ids", []),
|
||
"direction": data.get("direction", ""),
|
||
"interval": data.get("interval", ""),
|
||
"machine_counts": data.get("machine_counts", {}),
|
||
}
|
||
today = time.strftime("%Y-%m-%d")
|
||
log_file = log_dir / f"{today}.jsonl"
|
||
with threading.Lock():
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
|
||
return {"status": "ok", "batch_id": batch_id}
|
||
|
||
|
||
@router.get("/batch-logs")
|
||
def api_batch_logs_list(limit: int = 50):
|
||
"""获取执行记录列表(最新 N 条)"""
|
||
import json
|
||
log_dir = _get_batch_log_dir()
|
||
if not log_dir.exists():
|
||
return {"logs": []}
|
||
entries = []
|
||
for f in sorted(log_dir.glob("*.jsonl"), reverse=True):
|
||
lines = f.read_text(encoding="utf-8").strip().split("\n")
|
||
for line in reversed(lines):
|
||
if line.strip():
|
||
try:
|
||
entries.append(json.loads(line))
|
||
except json.JSONDecodeError:
|
||
pass
|
||
if len(entries) >= limit:
|
||
break
|
||
if len(entries) >= limit:
|
||
break
|
||
return {"logs": entries}
|
||
|
||
|
||
# ── 任务状态推送(guardd → Dashboard)───────────────
|
||
|
||
_TASK_EVENTS = [] # 内存环形缓冲,保存最近 200 条
|
||
_MAX_EVENTS = 200
|
||
|
||
supported_plugins = {}
|
||
|
||
|
||
@router.post("/push/task-event")
|
||
def api_push_task_event(data: dict = {}):
|
||
"""接收 guardd 推送的任务状态变化事件"""
|
||
global _TASK_EVENTS
|
||
event = {
|
||
"event": data.get("event", "unknown"),
|
||
"machine": data.get("machine", ""),
|
||
"task_id": data.get("task_id", ""),
|
||
"cmd_type": data.get("cmd_type", ""),
|
||
"accounts": data.get("accounts", []),
|
||
"slot_id": data.get("slot_id"),
|
||
"status": data.get("status", ""),
|
||
"time": data.get("time", ""),
|
||
"received_at": _time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
_TASK_EVENTS.append(event)
|
||
if len(_TASK_EVENTS) > _MAX_EVENTS:
|
||
_TASK_EVENTS = _TASK_EVENTS[-_MAX_EVENTS:]
|
||
return {"status": "ok"}
|
||
|
||
|
||
@router.get("/task-events")
|
||
def api_task_events(limit: int = 50):
|
||
"""获取最近的任务事件列表(供前端轮询/实时展示)"""
|
||
return {"events": _TASK_EVENTS[-limit:]}
|
||
|
||
|
||
# ── AI 消耗统计(看板首页展示)───────────────
|
||
|
||
@router.get("/ai/usage")
|
||
def api_ai_usage(days: int = 7):
|
||
"""AI 消耗统计(读 agent-local/runtime/ai_usage.jsonl)
|
||
|
||
Returns:
|
||
today: 今日聚合(calls/tokens/缓存命中/费用估算)
|
||
total: 总计聚合
|
||
recent: 最近 20 条明细
|
||
"""
|
||
import json as _json
|
||
from datetime import datetime as _dt
|
||
from pathlib import Path as _Path
|
||
_log_path = _Path.home() / "workbuddy-agent-os" / "agent-local" / "runtime" / "ai_usage.jsonl"
|
||
entries = []
|
||
if _log_path.exists():
|
||
for line in _log_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
entries.append(_json.loads(line))
|
||
except Exception:
|
||
pass
|
||
|
||
today_str = _dt.now().strftime("%Y-%m-%d")
|
||
today_entries = [e for e in entries if str(e.get("ts", "")).startswith(today_str)]
|
||
|
||
# flash 官方空闲价(元/百万 token):缓存命中 0.05 / 未命中 1.5 / 输出 4.5
|
||
def _agg(lst: list) -> dict:
|
||
prompt = sum(e.get("prompt_tokens", 0) for e in lst)
|
||
completion = sum(e.get("completion_tokens", 0) for e in lst)
|
||
hit = sum(e.get("cache_hit", 0) for e in lst)
|
||
miss = sum(e.get("cache_miss", 0) for e in lst)
|
||
reasoning = sum(e.get("reasoning_tokens", 0) for e in lst)
|
||
cost = (miss * 1.5 + hit * 0.05 + completion * 4.5) / 1e6
|
||
return {
|
||
"calls": len(lst),
|
||
"prompt_tokens": prompt,
|
||
"completion_tokens": completion,
|
||
"total_tokens": prompt + completion,
|
||
"cache_hit": hit,
|
||
"cache_miss": miss,
|
||
"cache_hit_rate": round(hit / (hit + miss), 3) if (hit + miss) else 0,
|
||
"reasoning_tokens": reasoning,
|
||
"cost_estimate": round(cost, 4), # 元(按 flash 空闲价估算)
|
||
}
|
||
|
||
return {"today": _agg(today_entries), "total": _agg(entries), "recent": entries[-20:]}
|
||
|
||
|
||
@router.post("/fetch-titles")
|
||
def api_fetch_titles(data: dict = {}):
|
||
"""从视频 URL 列表中提取页面标题
|
||
|
||
Body: {"urls": ["https://www.douyin.com/video/...", ...]}
|
||
Returns: {"titles": [{"url": "...", "title": "...", "error": "..."}]}
|
||
"""
|
||
import urllib.request as _urq
|
||
import re as _re
|
||
|
||
urls = data.get("urls", [])
|
||
if not urls or not isinstance(urls, list):
|
||
return {"status": "error", "message": "urls 必填"}
|
||
|
||
results = []
|
||
for url in urls:
|
||
url = url.strip()
|
||
if not url:
|
||
continue
|
||
title = ""
|
||
error = ""
|
||
try:
|
||
req = _urq.Request(url, headers={
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||
})
|
||
resp = _urq.urlopen(req, timeout=10)
|
||
html = resp.read().decode("utf-8", errors="replace")
|
||
# 提取 <title> 标签
|
||
m = _re.search(r'<title>([^<]+)</title>', html)
|
||
if m:
|
||
title = m.group(1).strip()
|
||
# 去掉平台后缀
|
||
title = _re.sub(r'\s*[-–—|]\s*抖音$', '', title).strip()
|
||
# 如果标题太长(含大量SEO关键词),截取到有用部分
|
||
if len(title) > 60:
|
||
title = title[:60]
|
||
except Exception as e:
|
||
error = str(e)[:60]
|
||
results.append({"url": url, "title": title, "error": error})
|
||
|
||
logger.info(" 📋 提取 %d 个URL标题: %d 成功", len(results), sum(1 for r in results if r["title"]))
|
||
return {"status": "ok", "titles": results}
|