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,550 @@
"""API Key 配置接口
用途:让每个使用者配置自己的 API 密钥。
安全原则:
- 密钥只写入 agent-local/tools/ave/config/local.yaml(600 权限,不被 git 跟踪)
- 接口返回一律脱敏(仅前 4 + 后 4 位),绝不回显完整 key
- 日志只记录字段名与后 4 位
"""
import logging
import os
import time
from pathlib import Path
import httpx
import yaml
from fastapi import APIRouter, HTTPException
logger = logging.getLogger("dashboard.api_config")
router = APIRouter(prefix="/api", tags=["api-config"])
AGENT_LOCAL = Path(os.environ.get(
"AGENT_LOCAL",
Path.home() / "workbuddy-agent-os" / "agent-local",
))
LOCAL_YAML = AGENT_LOCAL / "tools" / "ave" / "config" / "local.yaml"
# ── 可配置字段清单(path 对应 local.yaml 的嵌套路径)────────────────
FIELDS = [
{
"path": "llm.api_key", "group": "🤖 AI 文本生成",
"label": "DeepSeek API Key",
"hint": "评论生成 / 讨论剧本 · 申请: platform.deepseek.com",
"test": "deepseek", "required": True,
},
{
"path": "llm.model", "group": "🤖 AI 文本生成",
"label": "模型名",
"hint": "deepseek-v4-flash(推荐·最划算)/ deepseek-chat · 留空则用 ai.yaml 默认",
"test": "", "required": False, "secret": False,
},
{
"path": "llm.base_url", "group": "🤖 AI 文本生成",
"label": "API 地址",
"hint": "默认 https://api.deepseek.com/v1",
"test": "", "required": False, "secret": False,
},
{
"path": "aliyun.api_key", "group": "👁️ 视觉分析 / 🔄 人物置换",
"label": "阿里百炼 API Key",
"hint": "视觉分析 + 人物置换 + TTS(CosyVoice)共用同一个 Key · 申请: bailian.console.aliyun.com",
"test": "aliyun_tts", "required": True,
},
{
"path": "kling.api_key", "group": "🎨 图像生成",
"label": "可灵 Kling API Key",
"hint": "定妆照 / 角色图 / 文生视频 · 申请: klingai.com(格式 api-key-kling-xxx)",
"test": "kling", "required": False,
},
{
"path": "kling.webhook_secret", "group": "🎨 图像生成",
"label": "可灵 Webhook Secret",
"hint": "验证回调来源(格式 whsec_xxx)· 可灵控制台 → Webhook 配置获取;本机回调需公网可达地址",
"test": "", "required": False,
},
{
"path": "pexels.api_key", "group": "🖼️ 素材图库",
"label": "Pexels API Key",
"hint": "视频/图片素材下载 · 申请: pexels.com/api",
"test": "pexels", "required": False,
},
{
"path": "volcano.ark_api_key", "group": "🎬 视频生成(火山方舟)",
"label": "方舟 API Key",
"hint": "即梦文生视频 · 火山引擎控制台",
"test": "", "required": False,
},
{
"path": "volcano.jimeng_access_key_id", "group": "🎬 视频生成(火山方舟)",
"label": "即梦 AccessKey ID",
"hint": "与下方 Secret 配对使用",
"test": "", "required": False,
},
{
"path": "volcano.jimeng_secret_access_key", "group": "🎬 视频生成(火山方舟)",
"label": "即梦 Secret AccessKey",
"hint": "高度敏感 · 仅存本机",
"test": "", "required": False,
},
{
"path": "volcano.voice_app_id", "group": "🔊 语音合成(火山)",
"label": "语音 App ID",
"hint": "配音服务",
"test": "", "required": False,
},
{
"path": "volcano.voice_app_key", "group": "🔊 语音合成(火山)",
"label": "语音 Access Token",
"hint": "配音服务",
"test": "", "required": False,
},
{
"path": "volcano.speaker_name", "group": "🔊 语音合成(火山)",
"label": "默认音色 speaker_name",
"hint": "非密钥,可留空用默认",
"test": "", "required": False,
},
# ── 补充项(2026-10-02 整理:与本机 local.yaml 实际配置对齐)─────────
{
"path": "aliyun.wan_model", "group": "👁️ 视觉分析 / 🔄 人物置换",
"label": "阿里视频换人模型",
"hint": "wan2.2-s2v(人物置换用)· 非密钥,一般不用改",
"test": "", "required": False, "secret": False,
},
{
"path": "aliyun.cosyvoice_model", "group": "🔊 语音合成(阿里 CosyVoice)",
"label": "CosyVoice 模型名",
"hint": "TTS 用(如 cosyvoice-v3.5-plus-bailian-xxxx)· 阿里百炼控制台 → 语音合成 → 应用管理复制;保存后用上方「阿里百炼 API Key」的 🧪 测试一起校验",
"test": "", "required": False, "secret": False,
},
{
"path": "kling.base_url", "group": "🎨 图像生成 / 视频生成",
"label": "可灵 API 地址",
"hint": "默认 https://api-beijing.klingai.com",
"test": "", "required": False, "secret": False,
},
{
"path": "kling.auth_type", "group": "🎨 图像生成 / 视频生成",
"label": "可灵鉴权方式",
"hint": "bearer(api_key 方式)/ aksk(AK+SK 方式)",
"test": "", "required": False, "secret": False,
},
{
"path": "pexels.rate_limit", "group": "🖼️ 素材图库",
"label": "Pexels 每小时请求上限",
"hint": "免费版一般 200 · 非密钥",
"test": "", "required": False, "secret": False,
},
{
"path": "llm.volc_ark_key", "group": "🤖 AI 文本生成",
"label": "火山方舟 Key(备用文本模型)",
"hint": "可选:用豆包做文本生成时填",
"test": "", "required": False,
},
{
"path": "volcano.app_id", "group": "🔊 语音合成(火山)",
"label": "App ID(TTS 必需)",
"hint": "火山语音控制台 → 应用 ID(一串数字)· 与下方 Access Token 配对",
"test": "", "required": False, "secret": False,
},
{
"path": "volcano.api_key", "group": "🔊 语音合成(火山)",
"label": "火山 API Key",
"hint": "新版控制台 Key(与 AK/SK 二选一)",
"test": "", "required": False,
},
{
"path": "volcano.access_key_id", "group": "🔊 语音合成(火山)",
"label": "AccessKey ID(旧版 AK/SK)",
"hint": "老版鉴权用 · 新版可用 api_key 代替",
"test": "", "required": False,
},
{
"path": "volcano.secret_access_key", "group": "🔊 语音合成(火山)",
"label": "Secret AccessKey",
"hint": "高度敏感 · 仅存本机",
"test": "", "required": False,
},
{
"path": "volcano.tts_model", "group": "🔊 语音合成(火山)",
"label": "TTS 模型名",
"hint": "默认 volcano_tts_2.0",
"test": "", "required": False, "secret": False,
},
]
FIELD_MAP = {f["path"]: f for f in FIELDS}
# ── 读写工具 ────────────────────────────────────────────────────────
def _read_local() -> dict:
if not LOCAL_YAML.exists():
return {}
try:
return yaml.safe_load(LOCAL_YAML.read_text()) or {}
except Exception as e:
logger.warning("读取 local.yaml 失败: %s", e)
return {}
def _write_local(cfg: dict) -> None:
LOCAL_YAML.parent.mkdir(parents=True, exist_ok=True)
LOCAL_YAML.write_text(
yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False, default_flow_style=False)
)
try:
os.chmod(LOCAL_YAML, 0o600) # 仅本用户可读写
except Exception:
pass
def _get_path(cfg: dict, path: str):
cur = cfg
for part in path.split("."):
if not isinstance(cur, dict) or part not in cur:
return ""
cur = cur[part]
return cur or ""
def _set_path(cfg: dict, path: str, value: str) -> None:
parts = path.split(".")
cur = cfg
for part in parts[:-1]:
if part not in cur or not isinstance(cur[part], dict):
cur[part] = {}
cur = cur[part]
cur[parts[-1]] = value
def _mask(v: str) -> str:
v = str(v or "")
if not v:
return ""
if len(v) <= 12:
return v[:2] + "…" + v[-2:]
return v[:5] + "…" + v[-4:]
# ── Key 历史版本(轮换 / 回滚用)──────────────────────────────────
HISTORY_PATH = AGENT_LOCAL / "tools" / "ave" / "config" / "key_history.yaml"
def _read_history() -> dict:
"""{字段路径: [{"value","label","added_at","active"}]}"""
if not HISTORY_PATH.exists():
return {}
try:
return yaml.safe_load(HISTORY_PATH.read_text()) or {}
except Exception as e:
logger.warning("读取 key_history.yaml 失败: %s", e)
return {}
def _write_history(hist: dict) -> None:
HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
HISTORY_PATH.write_text(
yaml.safe_dump(hist, allow_unicode=True, sort_keys=False, default_flow_style=False)
)
try:
os.chmod(HISTORY_PATH, 0o600)
except Exception:
pass
def _archive(path: str, value: str, label: str = "", active: bool = False) -> None:
"""把 key 值记入历史(同值则更新备注/状态)"""
value = str(value or "").strip()
if not value:
return
hist = _read_history()
items = hist.setdefault(path, [])
for it in items:
if it.get("value") == value:
it["active"] = active
if label:
it["label"] = label
_write_history(hist)
return
items.append({
"value": value,
"label": label or "(无备注)",
"added_at": time.strftime("%Y-%m-%d %H:%M"),
"active": active,
})
_write_history(hist)
def _mark_active(path: str, value: str) -> None:
"""把历史中该值标为使用中,其余取消"""
hist = _read_history()
for it in hist.get(path, []):
it["active"] = (it.get("value") == value)
_write_history(hist)
# ── 接口 1:查询状态(脱敏)─────────────────────────────────────────
@router.get("/config/api-keys")
def api_get_api_keys():
"""返回各平台 key 配置状态(脱敏,不回显完整值)"""
cfg = _read_local()
hist_all = _read_history()
out = []
for f in FIELDS:
val = str(_get_path(cfg, f["path"]) or "")
is_secret = f.get("secret", True) # 密钥类默认脱敏;模型名/地址等普通配置明文
history = []
for i, it in enumerate(hist_all.get(f["path"]) or []):
hv = str(it.get("value") or "")
history.append({
"idx": i,
"masked": _mask(hv) if is_secret else hv,
"label": it.get("label") or "",
"added_at": it.get("added_at") or "",
"active": bool(it.get("active")),
})
out.append({
"path": f["path"],
"group": f["group"],
"label": f["label"],
"hint": f["hint"],
"required": f.get("required", False),
"testable": bool(f.get("test")),
"secret": is_secret,
"configured": bool(val.strip()),
"masked": _mask(val) if is_secret else val,
"history": history,
})
groups = []
for item in out:
g = next((x for x in groups if x["name"] == item["group"]), None)
if not g:
g = {"name": item["group"], "items": []}
groups.append(g)
g["items"].append(item)
return {
"status": "ok",
"groups": groups,
"total": len(out),
"configured": sum(1 for x in out if x["configured"]),
"config_path": str(LOCAL_YAML),
}
# ── 接口 2:保存 key ────────────────────────────────────────────────
@router.post("/config/api-keys")
def api_save_api_keys(data: dict):
"""保存 key(body: {"updates": {"llm.api_key": "sk-xxx"}})
- 只允许更新 FIELDS 里声明的路径(防越权写任意字段)
- 写入 local.yaml,权限 600
"""
updates = data.get("updates") or {}
if not isinstance(updates, dict) or not updates:
raise HTTPException(400, detail="updates 不能为空")
invalid = [p for p in updates if p not in FIELD_MAP]
if invalid:
raise HTTPException(400, detail=f"不支持的配置项: {invalid}")
labels = data.get("labels") or {}
cfg = _read_local()
saved, cleared = [], []
for path, value in updates.items():
v = str(value or "").strip()
old = str(_get_path(cfg, path) or "").strip()
_set_path(cfg, path, v)
if v:
# 旧值归档到历史(轮换场景:保留可回滚)
if old and old != v:
_archive(path, old, label="(被替换)", active=False)
_mark_active(path, v)
saved.append(f"{path}(***{v[-4:]})")
else:
if old:
_archive(path, old, label="(已清除)", active=False)
cleared.append(path)
_write_local(cfg)
logger.info(" 🔑 API 配置更新: 保存 %s | 清空 %s", saved or "无", cleared or "无")
return {
"status": "ok",
"saved": len(saved),
"cleared": len(cleared),
"config_path": str(LOCAL_YAML),
}
# ── 接口 4:切换生效版本(从历史里选择用哪个)──────────────────────
@router.post("/config/api-keys/activate")
def api_activate_api_key(data: dict):
"""切换生效版本: body {path, idx}
把历史里第 idx 个版本设为当前生效(写入 local.yaml);
切换前的当前值自动归档,可随时再切回。
"""
path = (data.get("path") or "").strip()
if path not in FIELD_MAP:
raise HTTPException(400, detail=f"不支持的配置项: {path}")
try:
idx = int(data.get("idx"))
except Exception:
raise HTTPException(400, detail="idx 必须是数字")
hist = _read_history()
items = hist.get(path) or []
if idx < 0 or idx >= len(items):
raise HTTPException(404, detail="历史版本不存在")
target = str(items[idx].get("value") or "").strip()
if not target:
raise HTTPException(400, detail="该版本为空,无法启用")
cfg = _read_local()
old = str(_get_path(cfg, path) or "").strip()
if old and old != target:
_archive(path, old, label="(切换前)", active=False)
_set_path(cfg, path, target)
_write_local(cfg)
_mark_active(path, target)
logger.info(" 🔄 API 版本切换: %s → ***%s", path, target[-4:])
return {"status": "ok", "activated": _mask(target)}
# ── 接口 5:删除历史版本 ────────────────────────────────────────────
@router.post("/config/api-keys/delete")
def api_delete_api_key(data: dict):
"""删除历史版本: body {path, idx}(使用中的版本不允许删)"""
path = (data.get("path") or "").strip()
if path not in FIELD_MAP:
raise HTTPException(400, detail=f"不支持的配置项: {path}")
try:
idx = int(data.get("idx"))
except Exception:
raise HTTPException(400, detail="idx 必须是数字")
hist = _read_history()
items = hist.get(path) or []
if idx < 0 or idx >= len(items):
raise HTTPException(404, detail="历史版本不存在")
if items[idx].get("active"):
raise HTTPException(400, detail="使用中的版本不能删除,请先切换到其他版本")
removed = items.pop(idx)
if not items:
hist.pop(path, None)
_write_history(hist)
logger.info(" 🗑 删除 API 历史版本: %s ***%s", path, str(removed.get("value") or "")[-4:])
return {"status": "ok", "removed": _mask(str(removed.get("value") or ""))}
# ── 接口 3:测试连通性 ──────────────────────────────────────────────
def _test_deepseek(key: str) -> tuple:
r = httpx.post(
"https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={"model": "deepseek-chat", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5},
timeout=30,
)
if r.status_code == 200:
return True, "✅ 连通正常"
if r.status_code == 401:
return False, "❌ Key 无效(401)"
if r.status_code == 402 or "Insufficient Balance" in r.text:
return False, "⚠️ Key 有效但账户余额不足"
return False, f"❌ HTTP {r.status_code}: {r.text[:80]}"
def _test_dashscope(key: str) -> tuple:
r = httpx.post(
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={"model": "qwen-turbo", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5},
timeout=30,
)
if r.status_code == 200:
return True, "✅ 连通正常"
if r.status_code == 401:
return False, "❌ Key 无效(401)"
if "Arrearage" in r.text or "good standing" in r.text:
return False, "⚠️ Key 有效但账号欠费/状态不良(需充值)"
return False, f"❌ HTTP {r.status_code}: {r.text[:80]}"
def _test_kling(key: str) -> tuple:
base = "https://api-beijing.klingai.com"
r = httpx.get(
f"{base}/v1/images/generations",
headers={"Authorization": f"Bearer {key}"},
timeout=30,
)
if r.status_code != 200:
return False, f"❌ HTTP {r.status_code}: {r.text[:80]}"
d = r.json()
code = d.get("code")
if code == 0:
return True, f"✅ 鉴权通过(历史任务 {len(d.get('data') or [])} 个)"
if code == 1102:
return False, "⚠️ Key 有效但账户余额不足(需充值)"
return False, f"❌ code={code}: {str(d.get('message'))[:60]}"
def _test_aliyun_tts(key: str) -> tuple:
"""验证阿里 CosyVoice TTS 配置(**零费用**:只校验 Key + 模型名,不实际生成音频)"""
cfg = _read_local()
model = ((cfg.get("aliyun") or {}).get("cosyvoice_model") or "").strip()
if not model:
return False, ("⚠️ 缺「CosyVoice 模型名」—— 阿里百炼控制台 → 语音合成 → 应用管理 → "
"创建应用后复制模型名(形如 cosyvoice-v2-xxxx)")
if not model.lower().startswith("cosyvoice"):
return False, f"⚠️ 模型名格式可疑(应以 cosyvoice 开头):{model[:36]}"
# 用同一个 DashScope Key 走通用接口验证(不消耗 TTS 额度)
ok, msg = _test_dashscope(key)
if not ok:
return False, f"Key 校验未通过({msg})"
return True, f"✅ Key 有效 · 模型名 {model[:34]}(未实际生成音频,零费用)"
def _test_pexels(key: str) -> tuple:
r = httpx.get(
"https://api.pexels.com/v1/search?query=test&per_page=1",
headers={"Authorization": key},
timeout=30,
)
if r.status_code == 200:
return True, "✅ 连通正常"
if r.status_code == 401:
return False, "❌ Key 无效(401)"
return False, f"❌ HTTP {r.status_code}"
_TESTERS = {
"aliyun_tts": _test_aliyun_tts,
"deepseek": _test_deepseek,
"dashscope": _test_dashscope,
"kling": _test_kling,
"pexels": _test_pexels,
}
@router.post("/config/api-keys/test")
def api_test_api_key(data: dict):
"""测试指定字段的 key 连通性
body: {"path": "llm.api_key", "value": "可选,不传则用已保存的值"}
"""
path = (data.get("path") or "").strip()
f = FIELD_MAP.get(path)
if not f:
raise HTTPException(400, detail=f"不支持的配置项: {path}")
tester = _TESTERS.get(f.get("test") or "")
if not tester:
return {"status": "ok", "ok": None, "detail": "该平台暂不支持自动测试(保存后可实际调用验证)"}
value = str(data.get("value") or "").strip() or str(_get_path(_read_local(), path) or "").strip()
if not value:
return {"status": "ok", "ok": False, "detail": "⚠️ 未配置 key"}
try:
ok, detail = tester(value)
except Exception as e:
ok, detail = False, f"❌ 测试异常: {str(e)[:100]}"
logger.info(" 🧪 API 测试 %s: %s", path, detail)
return {"status": "ok", "ok": ok, "detail": detail}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,262 @@
"""
routes/ave_creatives.py — 创意库 API(GapEngine 落地)
前缀:/api/ave/creatives
组成:
· 创意卡 CRUD(创意库)
· 参数库读写(创意工坊 ①六层参数)
· 矩阵配置读写(②负面矩阵 / ③正向矩阵 / ④道具场景)
· 候选生成(⑤生成&筛选,LLM 出 30 条)
· 缺口试算(Tab① 实时反馈)
⚠️ 路由顺序:静态段(stats/library/matrix/generate/calculate)必须定义在
`/{cid}` 通配之前,否则会被吞掉。
"""
import logging
import os
import sys
from pathlib import Path
import yaml
from fastapi import APIRouter, HTTPException
logger = logging.getLogger("dashboard.ave_creatives")
_SCRIPTS = Path(os.environ.get(
"AGENT_SYNC", Path(__file__).resolve().parent.parent.parent)) / "05_tools" / "09_ave" / "scripts"
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
router = APIRouter(prefix="/api/ave/creatives", tags=["ave_creatives"])
def _ge():
"""惰性导入 gap_engine(避免启动顺序问题)"""
try:
import gap_engine
return gap_engine
except Exception as e:
raise HTTPException(500, detail=f"创意引擎不可用: {e}")
# ═══════════════════════════════════════════════════════════
# 参数库(① 六层参数)
# ═══════════════════════════════════════════════════════════
@router.get("/library")
def api_library():
"""六层参数库全量(Tab① 平铺展示用)"""
ge = _ge()
lib = ge.load_library()
layers = []
for L in lib.layers:
layers.append({
"id": L.id, "name": L.name,
"params": [{
"id": p.id, "name": p.name,
"values": [{"value": v.value, "intensity": v.intensity, "cue": v.cue}
for v in p.values],
} for p in L.params],
})
n_param = sum(len(L.params) for L in lib.layers)
n_val = sum(len(p.values) for L in lib.layers for p in L.params)
return {"layers": layers, "stats": {"layers": len(layers), "params": n_param, "values": n_val}}
@router.put("/library")
def api_library_save(data: dict):
"""保存参数库(整份 YAML 文本;供高级编辑)"""
ge = _ge()
text = (data or {}).get("yaml_text", "")
if not text:
raise HTTPException(400, detail="需要 yaml_text")
try:
return {"status": "ok", "stats": ge.save_library_text(text)}
except Exception as e:
raise HTTPException(400, detail=str(e))
# ═══════════════════════════════════════════════════════════
# 矩阵配置(②③④)
# ═══════════════════════════════════════════════════════════
@router.get("/matrix")
def api_matrix():
"""负面/正向/道具场景 + 生成参数"""
return _ge().load_matrix()
@router.put("/matrix")
def api_matrix_save(data: dict):
"""保存矩阵(②负面勾选 / ③正向勾选 / ④道具场景 + presets)"""
body = (data or {}).get("matrix") or data
if not isinstance(body, dict) or "negative" not in body:
raise HTTPException(400, detail="矩阵格式错误:需要 negative 字段")
return {"status": "ok", "matrix": _ge().save_matrix(body)}
# ═══════════════════════════════════════════════════════════
# 生成与试算(⑤)
# ═══════════════════════════════════════════════════════════
@router.post("/generate")
def api_generate(data: dict):
"""生成候选创意(LLM 出 N 条 → 缺口分 → 过滤加权 → 排序)
Body: { brief: str, n?: int, matrix?: dict,
selections?: [{layer_id,param_id,value}] } # 参数框架("按当前参数生成")
selections 为空 = 完全随机模式(AI 自由配参数)
"""
ge = _ge()
brief = (data or {}).get("brief", "").strip()
if not brief:
raise HTTPException(400, detail="需要 brief(1-2 句话场景诉求)")
try:
return {"status": "ok", **ge.generate_candidates(
brief, n=int((data or {}).get("n") or 0),
matrix=(data or {}).get("matrix") or None,
constraint_selections=(data or {}).get("selections") or None)}
except Exception as e:
logger.warning("生成候选失败: %s", e)
raise HTTPException(500, detail=str(e))
@router.post("/preselect")
def api_preselect(data: dict):
"""AI 参数预选(Tab① 用):按场景 + 矩阵给 N 套七层参数基础搭配
Body: { brief: str, n_schemes?: int, matrix?: dict }
返回每套含 selections + 缺口分 + 思路,供前端一键填入 Tab① 后微调。
"""
ge = _ge()
brief = (data or {}).get("brief", "").strip()
if not brief:
raise HTTPException(400, detail="需要 brief(1-2 句话场景诉求)")
try:
return {"status": "ok", **ge.preselect_params(
brief, n_schemes=int((data or {}).get("n_schemes") or 3),
matrix=(data or {}).get("matrix") or None)}
except Exception as e:
logger.warning("AI 预选失败: %s", e)
raise HTTPException(500, detail=str(e))
@router.post("/calculate")
def api_calculate(data: dict):
"""缺口试算(Tab① 手动挑参数 → 即时看缺口分/联想卡,零费用不调 LLM)
Body: { selections: [{layer_id, param_id, value}] }
"""
ge = _ge()
lib = ge.load_library()
raw = (data or {}).get("selections") or []
sels = []
for rs in raw:
lid, pid, val = rs.get("layer_id", ""), rs.get("param_id", ""), rs.get("value", "")
pv = lib.find_value(lid, pid, val)
if pv is None:
continue
sels.append(ge.Selection(layer_id=lid, param_id=pid, param_name=lib.param_name(lid, pid),
value=pv.value, intensity=pv.intensity, cue=pv.cue))
if len(sels) < 2:
return {"status": "ok", "insufficient": True,
"hint": "至少选 2 个参数才能计算缺口(照原文校验)", "count": len(sels)}
g = ge.calc_gap(sels)
return {"status": "ok", "insufficient": False, **g.model_dump()}
# ═══════════════════════════════════════════════════════════
# 创意卡 CRUD(创意库)
# ═══════════════════════════════════════════════════════════
@router.get("/stats")
def api_stats():
return {"status": "ok", **_ge().store.stats()}
@router.get("")
@router.get("/")
def api_list(q: str = "", status: str = "", inject_mode: str = "",
sort: str = "updated_desc", limit: int = 200):
"""创意卡列表"""
ge = _ge()
cards = ge.store.list_cards(q=q, status=status, inject_mode=inject_mode,
sort=sort, limit=min(int(limit), 1000))
return {"status": "ok", "total": len(cards), "cards": cards}
@router.post("")
@router.post("/")
def api_create(data: dict):
"""新建创意卡
两种来源:
1) { candidate: {...}, brief, matrix, inject_mode } ← 从生成结果保存
2) { card: {...} } ← 手动/编辑后保存
"""
ge = _ge()
try:
if (data or {}).get("candidate"):
cand = ge.ComboCandidate(**(data["candidate"]))
card = ge.candidate_to_card(
cand, brief=(data or {}).get("brief", ""),
title=(data or {}).get("title", ""),
matrix=(data or {}).get("matrix") or None,
inject_mode=(data or {}).get("inject_mode", "reference"))
return {"status": "ok", "card": ge.store.create(card)}
if (data or {}).get("card"):
card = ge.CreativeCard(**(data["card"]))
return {"status": "ok", "card": ge.store.create(card)}
raise HTTPException(400, detail="需要 candidate 或 card")
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, detail=f"创建失败: {e}")
@router.post("/save-batch")
def api_save_batch(data: dict):
"""批量保存(筛选后一次入库)
Body: { cards: [{candidate, brief, inject_mode, title}], matrix? }
"""
ge = _ge()
items = (data or {}).get("cards") or []
if not items:
raise HTTPException(400, detail="需要 cards 数组")
saved = []
for it in items:
try:
cand = ge.ComboCandidate(**(it.get("candidate") or {}))
card = ge.candidate_to_card(
cand, brief=it.get("brief", ""), title=it.get("title", ""),
matrix=(data or {}).get("matrix") or None,
inject_mode=it.get("inject_mode", "reference"))
saved.append(ge.store.create(card))
except Exception as e:
logger.warning("批量保存单条失败: %s", e)
return {"status": "ok", "saved": len(saved), "cards": saved}
@router.get("/{cid}")
def api_get(cid: str):
card = _ge().store.get(cid)
if not card:
raise HTTPException(404, detail="创意卡不存在")
return {"status": "ok", "card": card}
@router.patch("/{cid}")
def api_update(cid: str, data: dict):
"""更新创意卡(含 inject_mode / status / title 等)"""
card = _ge().store.update(cid, data or {})
if not card:
raise HTTPException(404, detail="创意卡不存在")
return {"status": "ok", "card": card}
@router.delete("/{cid}")
def api_delete(cid: str):
if not _ge().store.delete(cid):
raise HTTPException(404, detail="创意卡不存在")
return {"status": "ok", "deleted": cid}
@@ -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": "抓取已在后台开始(完成刷新页面即可看到)"}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
"""
routes/ave_scene.py — 场景生成器(分层字段 → 提示词 → 图像)
与角色生成器对称的完整能力:
· GET /scene-schema 字段定义(10 层)
· GET /scene-views 视角定义(5 个)
· POST /scene/preview 提示词预览(**零费用**)
· POST /scene/generate 生成(单张 / 套装;**需 confirm_cost**)
· GET /scene/jobs/{job_id} 进度
"""
from __future__ import annotations
import sys
import threading
import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from routes.ave_assets import _AVE_SCRIPTS, _store, logger
router = APIRouter()
_SCENE_JOBS: dict = {}
@router.get("/schema")
def api_scene_schema():
"""场景分层字段定义(10 层,供前端渲染表单)"""
try:
if str(_AVE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_AVE_SCRIPTS))
from scene_generator.prompt_assembler import (
SCENE_LAYERS, SCENE_VIEWS, scene_layer_summary, parse_prompt_schema)
except Exception as e:
raise HTTPException(500, detail=f"场景模块加载失败: {e}")
return {"status": "ok", "layers": SCENE_LAYERS, "views": SCENE_VIEWS,
"summary": scene_layer_summary(), "parse_schema": parse_prompt_schema()}
@router.get("/presets")
def api_scene_presets():
"""场景预设(成套提示词,**零费用**)
返回 `config/scene_presets.yaml` —— 前端可一键载入各机位提示词。
"""
from pathlib import Path as _P
import yaml as _yaml
try:
from routes.ave_assets import _AVE_CONFIG
cands = [_P(_AVE_CONFIG) / "scene_presets.yaml"]
except Exception:
cands = []
cands.append(_P(__file__).resolve().parents[2] / "09_ave" / "config" / "scene_presets.yaml")
p = next((c for c in cands if c.exists()), None)
if not p:
return {"status": "ok", "presets": {}, "note": "预设文件不存在"}
try:
d = _yaml.safe_load(p.read_text(encoding="utf-8")) or {}
except Exception as e:
raise HTTPException(500, detail=f"预设解析失败: {e}")
return {"status": "ok", "presets": d.get("presets") or {}, "source": str(p)}
@router.post("/preview")
def api_scene_preview(data: dict):
"""组装场景提示词(**纯计算,零费用**)
Body: { layers: {...}, view?: "wide"|"front"|..., extra?: str, set?: bool }
· set=true → 返回 5 视角完整套装提示词
"""
try:
if str(_AVE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_AVE_SCRIPTS))
from scene_generator.prompt_assembler import (
build_scene_prompt, build_scene_view_prompt, build_scene_set)
except Exception as e:
raise HTTPException(500, detail=f"场景模块加载失败: {e}")
data = data or {}
layers = data.get("layers") or {}
prompts = data.get("prompts") or {}
preset_id = (data.get("preset_id") or "").strip()
# ★ 预设模式:从 config/scene_presets.yaml 取该预设的各机位成品提示词
if preset_id and not prompts:
try:
from pathlib import Path as _P
import yaml as _yaml
from routes.ave_assets import _AVE_CONFIG
pp = _P(_AVE_CONFIG) / "scene_presets.yaml"
if pp.exists():
_d = _yaml.safe_load(pp.read_text(encoding="utf-8")) or {}
prompts = ((_d.get("presets") or {}).get(preset_id) or {}).get("slots") or {}
except Exception as e:
raise HTTPException(400, detail=f"预设读取失败: {e}")
if not layers and not prompts:
raise HTTPException(400, detail="需要 layers(场景字段)、prompts(各机位成品提示词)或 preset_id(预设)之一")
extra = (data.get("extra") or "").strip()
if data.get("set"):
return {"status": "ok", "set": build_scene_set(layers, prompts=prompts, extra=extra)}
view = (data.get("view") or "").strip()
# ★ 优先用成品提示词(用户自带 / 预设)
if prompts and str(prompts.get(view) or "").strip():
prompt = str(prompts[view]).strip()
elif view:
prompt = build_scene_view_prompt(view, layers, extra=extra)
else:
prompt = build_scene_prompt(layers, extra=extra)
return {"status": "ok", "view": view or "(主图)", "prompt": prompt}
@router.post("/generate")
def api_scene_generate(data: dict):
"""生成场景图(**真实扣费**,约 ¥0.2/张;套装 5 张 ≈ ¥1)
Body: {
layers: {...}, scene_name?: str, view?: str, # 单张模式
set?: bool, # 套装模式(5 视角)
anchor_image?: str, # 复用已有主图
confirm_cost: true
}
"""
if not data.get("confirm_cost"):
raise HTTPException(400, detail="⛔ 生成场景图会真实扣费(约 ¥0.2/张),需显式确认(confirm_cost=true)")
layers = data.get("layers") or {}
prompts = data.get("prompts") or {}
preset_id = (data.get("preset_id") or "").strip()
# ★ 预设模式:从 config/scene_presets.yaml 取该预设的各机位成品提示词
if preset_id and not prompts:
try:
from pathlib import Path as _P
import yaml as _yaml
from routes.ave_assets import _AVE_CONFIG
pp = _P(_AVE_CONFIG) / "scene_presets.yaml"
if pp.exists():
_d = _yaml.safe_load(pp.read_text(encoding="utf-8")) or {}
prompts = ((_d.get("presets") or {}).get(preset_id) or {}).get("slots") or {}
except Exception as e:
raise HTTPException(400, detail=f"预设读取失败: {e}")
if not layers and not prompts:
raise HTTPException(400, detail="需要 layers(场景字段)、prompts(各机位成品提示词)或 preset_id(预设)之一")
job_id = uuid.uuid4().hex[:12]
_SCENE_JOBS[job_id] = {"job_id": job_id, "status": "running", "progress": 0,
"note": "准备中", "result": None, "error": None}
def _run():
def _cb(p, n):
j = _SCENE_JOBS.get(job_id)
if j:
j["progress"] = p
j["note"] = n
try:
if str(_AVE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_AVE_SCRIPTS))
from scene_generator.generator import build_scene_set_images, generate_scene_image
from scene_generator.prompt_assembler import build_scene_prompt, build_scene_view_prompt
name = (data.get("scene_name") or "").strip()
extra = (data.get("extra") or "").strip()
if data.get("set"):
out = build_scene_set_images(
layers, scene_name=name,
anchor_image=(data.get("anchor_image") or "").strip(),
extra=extra, prompts=prompts, progress_cb=_cb)
else:
# 单张模式
from character_sheet import CACHE_DIR
view = (data.get("view") or "wide").strip()
# ★ 优先用成品提示词(用户自带 / 预设)
if prompts and str(prompts.get(view) or "").strip():
prompt = str(prompts[view]).strip()
elif view != "wide":
prompt = build_scene_view_prompt(view, layers, extra=extra)
else:
prompt = build_scene_prompt(layers, extra=extra)
base = CACHE_DIR.parent / "scene_sheet" / (name or "scene")
base.mkdir(parents=True, exist_ok=True)
dst = base / f"{view}.png"
_cb(30, "生成中")
r = generate_scene_image(prompt, str(dst),
ref_image=(data.get("anchor_image") or "").strip())
out = {"views": {view: {"path": r.get("path", ""), "ok": r.get("ok"),
"error": r.get("error", "")}},
"done": 1 if r.get("ok") else 0, "cost_estimate": 0.2,
"note": "单张场景图完成" if r.get("ok") else f"失败: {r.get('error','')}"}
if r.get("ok") and name:
try:
from scene_generator.generator import _save_scene_to_assets
out["saved_to"] = _save_scene_to_assets(name, out, base)
except Exception as e:
out["save_error"] = str(e)[:200]
_SCENE_JOBS[job_id].update({"status": "done", "progress": 100,
"note": out.get("note", "完成"), "result": out})
except Exception as e:
logger.warning("场景生成失败 %s: %s", job_id, e)
_SCENE_JOBS[job_id].update({"status": "failed", "error": str(e)[:400]})
threading.Thread(target=_run, daemon=True).start()
return {"status": "ok", "job_id": job_id,
"message": "已开始生成。用 GET /api/ave/scene/jobs/{job_id} 查进度。"}
@router.get("/jobs/{job_id}")
def api_scene_job(job_id: str):
j = _SCENE_JOBS.get(job_id)
if not j:
raise HTTPException(404, detail="任务不存在")
return j
@@ -0,0 +1,306 @@
"""
comment_workbench.py — 评论工作台 API(v3 角色化评论)
路由前缀: /api/comment-workbench
"""
import logging
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException
logger = logging.getLogger("dashboard.comment_workbench")
_THIS_DIR = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = _THIS_DIR.parent / "07_matrix" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
router = APIRouter(prefix="/api/comment-workbench", tags=["comment_workbench"])
ROLE_LABELS = {
"sharer": "分享型", "questioner": "提问型", "sympathizer": "共情型",
"skeptic": "质疑型", "sufferer": "患者型", "filler": "灌水型",
"expert_ref": "推荐型", "answerer": "解答型",
}
DEFAULT_ROLES = {
"filler": 0.30, "questioner": 0.17, "sharer": 0.17,
"sympathizer": 0.13, "sufferer": 0.10, "skeptic": 0.07,
"expert_ref": 0.06, "answerer": 0.12,
}
def _get_corpus_mgr():
from mc.corpus import CorpusManager
return CorpusManager()
@router.get("/roles")
def api_roles(platform: str = "douyin"):
"""获取可用角色列表及默认比例"""
mgr = _get_corpus_mgr()
roles_raw = mgr.get_roles(platform)
result = []
for role_id, info in roles_raw.items():
label = ROLE_LABELS.get(role_id, role_id)
default_pct = DEFAULT_ROLES.get(role_id, 0)
result.append({
"id": role_id,
"label": label,
"count": info["count"],
"examples": info["examples"][:3],
"default_pct": default_pct,
})
return {"roles": result}
@router.post("/generate")
def api_generate_comments(data: dict):
"""按角色生成评论
Body:
video_title: str — 视频标题
video_tags: [str] — 视频标签(可选)
platform: str — 平台,默认 douyin
role_distribution: dict — 角色比例,空则用默认(比例模式)
role_counts: dict — 角色精确条数 {"filler": 2, ...}(数字模式,优先于比例)
total: int — 比例模式下生成多少条,默认 30
ai_enhance: bool — 是否 AI 改写
long_ratio: float — 长评占比 0~1
"""
video_title = data.get("video_title", "")
video_tags = data.get("video_tags", []) or []
video_industry = data.get("video_industry") or None
guide_points = data.get("guide_points", "") or ""
content_type = data.get("content_type", "") or ""
direction = data.get("direction", "auto")
platform = data.get("platform", "douyin")
role_dist = data.get("role_distribution", {}) or DEFAULT_ROLES
role_counts = data.get("role_counts") or None
total = data.get("total", 30)
ai_enhance = data.get("ai_enhance", False)
long_ratio = data.get("long_ratio", 0.0)
guide_ratio = min(max(float(data.get("guide_ratio", 1.0)), 0.0), 1.0) # 引导结合比例 0~1
if not video_title:
raise HTTPException(400, detail="video_title 必填")
mgr = _get_corpus_mgr()
# 行业:前端指定 > 自动识别 > None(通用)
if video_industry:
industry = video_industry
else:
industry = mgr._classify_video(video_title)
if not industry:
for tag in video_tags:
industry = mgr._classify_video(tag)
if industry:
break
# 视频标签转为逗号字符串
tags_str = ", ".join(video_tags) if isinstance(video_tags, list) else str(video_tags)
logger.info(" 📋 视频行业: %s | 内容类型: %s | 引导: %.30s", industry or "通用", content_type or "-", guide_points or "-")
gen_kwargs = dict(
platform=platform,
video_title=video_title,
video_industry=industry,
guide_points=guide_points,
content_type=content_type,
video_tags=tags_str,
ai_enhance=ai_enhance,
long_ratio=long_ratio,
guide_ratio=guide_ratio,
)
if role_counts:
# 数字模式:精确按角色条数取,total 由后端自动求和
comments = mgr.batch_get_comments_by_roles(
role_counts=role_counts,
**gen_kwargs,
)
else:
comments = mgr.batch_get_comments_by_roles(
role_distribution=role_dist,
total=total,
**gen_kwargs,
)
return {"comments": comments, "total": len(comments)}
@router.post("/generate-discussion")
def api_generate_discussion(data: dict):
"""多人讨论模式:一次生成完整讨论剧本(单次 AI 调用)→ turns 列表
Body:
video_title: str — 视频标题
topic: str — 讨论主题(用户主导概念)
guide_items: [str]|str — 引导要素(多个推荐对象,每行一个)
outline: str — 剧本走向(自由文本,如"先质疑→过来人澄清→带出医生")
total: int — 总条数(3~40,默认 15)
bystander_ratio: float — 路人打酱油占比(0~0.5,默认 0.2)
"""
video_title = (data.get("video_title") or "").strip()
topic = (data.get("topic") or "").strip()
outline = (data.get("outline") or "").strip()
guide_path = (data.get("guide_path") or "").strip()
total = max(3, min(60, int(data.get("total", 15))))
bystander_ratio = min(max(float(data.get("bystander_ratio", 0.2)), 0.0), 0.5)
role_counts = data.get("role_counts") or {}
if not isinstance(role_counts, dict):
role_counts = {}
long_comment_count = max(0, min(3, int(data.get("long_comment_count", 2))))
guide_count = max(0, int(data.get("guide_count", 0))) # 0 = 自动按 30%
guide_raw = data.get("guide_items") or []
if isinstance(guide_raw, str):
guide_items = [l.strip() for l in guide_raw.split("\n") if l.strip()]
else:
guide_items = [str(g).strip() for g in guide_raw if str(g).strip()]
if not video_title and not topic:
raise HTTPException(400, detail="video_title 或 topic 至少填一个")
from mc.corpus import AIGenerator
ai = AIGenerator()
if not ai.available:
raise HTTPException(503, detail="AI 不可用(检查 config/ai.yaml 或 agent-local 配置)")
import asyncio as _asyncio
try:
loop = _asyncio.get_event_loop()
except RuntimeError:
loop = _asyncio.new_event_loop()
turns = loop.run_until_complete(ai.generate_discussion_script(
video_title=video_title,
topic=topic,
guide_items=guide_items,
outline=outline,
total=total,
bystander_ratio=bystander_ratio,
guide_path=guide_path,
role_counts=role_counts,
long_comment_count=long_comment_count,
guide_count=guide_count,
))
logger.info(" 💬 讨论剧本生成: %d 条 (主题=%s 要素=%d)", len(turns), topic[:20], len(guide_items))
warning = ""
# 期望条数:配比模式 = 配比总和;自由模式 = total
expected = total
if role_counts:
try:
expected = sum(int(v) for v in role_counts.values() if str(v).strip().isdigit())
except Exception:
expected = total
if expected and len(turns) < expected:
warning = f"AI 少输出 {expected - len(turns)} 条(要求 {expected},实际 {len(turns)}),可重新生成或手动补"
return {"status": "ok", "turns": turns, "total": len(turns), "requested": expected, "warning": warning}
@router.post("/extract-topic")
def api_extract_topic(data: dict):
"""从视频标题提炼「讨论主题」(简短核心词,供讨论模式作起点)
Body: { video_title: str }
"""
video_title = (data.get("video_title") or "").strip()
if not video_title:
raise HTTPException(400, detail="video_title 必填")
from mc.corpus import AIGenerator
ai = AIGenerator()
if not ai.available:
raise HTTPException(503, detail="AI 不可用(检查 config/ai.yaml 或 agent-local 配置)")
import asyncio as _asyncio
try:
loop = _asyncio.get_event_loop()
except RuntimeError:
loop = _asyncio.new_event_loop()
topic = loop.run_until_complete(ai.extract_topic(video_title))
logger.info(" 🎯 主题提炼: %s → %s", video_title[:24], topic)
return {"status": "ok", "topic": topic}
@router.post("/generate-path")
def api_generate_path(data: dict):
"""生成「引导路径」:起点(主题) → 终点(引导要素),步数自适应 max(3, 2+要素数) 封顶 6
Body: { topic: str, guide_items: [str]|str, outline?: str }
"""
topic = (data.get("topic") or "").strip()
outline = (data.get("outline") or "").strip()
if not topic:
raise HTTPException(400, detail="topic 必填")
guide_raw = data.get("guide_items") or []
if isinstance(guide_raw, str):
guide_items = [l.strip() for l in guide_raw.split("\n") if l.strip()]
else:
guide_items = [str(g).strip() for g in guide_raw if str(g).strip()]
from mc.corpus import AIGenerator
ai = AIGenerator()
if not ai.available:
raise HTTPException(503, detail="AI 不可用(检查 config/ai.yaml 或 agent-local 配置)")
import asyncio as _asyncio
try:
loop = _asyncio.get_event_loop()
except RuntimeError:
loop = _asyncio.new_event_loop()
path = loop.run_until_complete(ai.generate_guide_path(topic, guide_items, outline))
steps = len([l for l in (path or "").split("\n") if l.strip()])
logger.info(" 🧭 引导路径: %s + %d 要素 → %d 步", topic[:16], len(guide_items), steps)
return {"status": "ok", "path": path, "steps": steps}
@router.post("/parse-outline")
def api_parse_outline(data: dict):
"""把用户的自然语言想法拆解成结构化「讨论走向」(供讨论模式生成用)
Body: { raw_idea: str } — 用户原始描述(可能跳跃/口语)
"""
raw_idea = (data.get("raw_idea") or "").strip()
if not raw_idea:
raise HTTPException(400, detail="raw_idea 必填")
from mc.corpus import AIGenerator
ai = AIGenerator()
if not ai.available:
raise HTTPException(503, detail="AI 不可用(检查 config/ai.yaml 或 agent-local 配置)")
import asyncio as _asyncio
try:
loop = _asyncio.get_event_loop()
except RuntimeError:
loop = _asyncio.new_event_loop()
outline = loop.run_until_complete(ai.parse_discussion_outline(raw_idea))
logger.info(" 🧭 走向拆解: %s → %d 字", raw_idea[:24], len(outline or ""))
return {"status": "ok", "outline": outline}
@router.post("/save-comments")
def api_save_comments(data: dict):
"""将精选评论保存到语料库
Body: {
"comments": [{"text": "...", "role": "...", "category": "..."}],
"platform": "douyin"
}
"""
comments = data.get("comments", [])
platform = data.get("platform", "douyin")
if not comments:
raise HTTPException(400, detail="comments 必填")
mgr = _get_corpus_mgr()
saved = 0
for c in comments:
text = c.get("text", "").strip()
role = c.get("role", "filler")
cat = c.get("category", "") or "入库评论"
if not text:
continue
# 入库到指定分类(不存在则自动创建)
mgr.add_comment(cat, text, platform)
saved += 1
logger.info(" ✅ 入库 %d 条评论 → %s/%s", saved, platform, cat)
return {"status": "ok", "saved": saved}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,654 @@
"""
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}
@@ -0,0 +1,190 @@
"""
routes/person_swap.py — 人物置换 API(相对独立的业务模块)
前缀: /api/person-swap
功能: 上传(原视频+人物参考图) → 任务队列 → 轮询状态 → 预览/成片访问
后端: person_swap service (AVE scripts 下独立包, SQLite + 后台 worker)
说明:
- 本模块是业务功能, 不经过 command_bus(不占养号槽位), 独立队列
- 账号欠费时页面给出明确提示, 任务会 failed 且不烧重试
- 文件服务只读 agent-local/runtime/person_swap/ 下受控路径(路径取自任务DB, 不接用户输入)
"""
import logging
import sys
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
logger = logging.getLogger("dashboard.person_swap")
# ── 定位 AVE scripts (person_swap 包所在) ──
_AVE_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "09_ave" / "scripts"
if str(_AVE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_AVE_SCRIPTS))
router = APIRouter(prefix="/api/person-swap", tags=["person_swap"])
_RUNTIME = Path.home() / "workbuddy-agent-os" / "agent-local" / "runtime" / "person_swap"
_RAW_DIR = _RUNTIME / "raw"
_RAW_DIR.mkdir(parents=True, exist_ok=True)
_ALLOW_VIDEO_EXT = {".mp4", ".mov", ".m4v", ".webm"}
_ALLOW_IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
_MAX_VIDEO_MB = 300
_MAX_IMAGE_MB = 20
def _get_service():
from person_swap.service import get_service
return get_service()
def _safe_path(path: str) -> Path:
"""仅允许 agent-local/runtime/person_swap 下的文件"""
p = Path(path).resolve()
root = _RUNTIME.resolve()
try:
p.relative_to(root)
except ValueError:
raise HTTPException(400, detail="非法路径")
if not p.exists():
raise HTTPException(404, detail="文件不存在")
return p
# ════════════════════════════════════════════════════════════
# 状态与配置
# ════════════════════════════════════════════════════════════
@router.get("/status")
def api_ps_status():
"""整体状态: 账号可用性 + 模型 + 队列 + 月费用(页面顶部卡片)"""
from person_swap.api import check_account_status, get_model
svc = _get_service()
acct = check_account_status()
active = [t for t in svc.list_tasks(100)
if t["status"] in ("queued", "processing")]
return {
"account": acct,
"model": acct.get("model", get_model()),
"queue_active": len(active),
"monthly": svc.monthly_cost(),
"limits": {"max_duration": 10, "orientation": "9:16"}, # 与 config person_swap 对齐的显示值
"version": "0.1.0",
}
# ════════════════════════════════════════════════════════════
# 任务 CRUD
# ════════════════════════════════════════════════════════════
@router.get("/tasks")
def api_ps_tasks(limit: int = 50, status: str = ""):
"""任务列表(按时间倒序) + 附加预览/成片是否可访问标记"""
svc = _get_service()
tasks = svc.list_tasks(limit=min(limit, 200), status=status)
return {"tasks": tasks, "monthly": svc.monthly_cost()}
@router.get("/tasks/{task_id}")
def api_ps_task(task_id: str):
svc = _get_service()
t = svc.get_task(task_id)
if not t:
raise HTTPException(404, detail="任务不存在")
return t
@router.post("/tasks")
async def api_ps_create(
video: UploadFile = File(...),
image: UploadFile = File(...),
prompt: str = Form(""),
character_name: str = Form(""),
duration_sec: int = Form(0),
):
"""创建人物置换任务(multipart): video=原视频, image=人物参考图"""
from person_swap import preprocess as pp
vname = video.filename or "source.bin"
iname = image.filename or "ref.bin"
vext = Path(vname).suffix.lower()
iext = Path(iname).suffix.lower()
if vext not in _ALLOW_VIDEO_EXT:
raise HTTPException(400, detail=f"视频格式不支持: {vext} (支持 {sorted(_ALLOW_VIDEO_EXT)})")
if iext not in _ALLOW_IMAGE_EXT:
raise HTTPException(400, detail=f"图片格式不支持: {iext} (支持 {sorted(_ALLOW_IMAGE_EXT)})")
if duration_sec and not (1 <= duration_sec <= 30):
raise HTTPException(400, detail="duration_sec 需在 1~30 之间(0=模型默认)")
batch = uuid.uuid4().hex[:8]
vpath = _RAW_DIR / f"{batch}_video{vext}"
ipath = _RAW_DIR / f"{batch}_image{iext}"
# 流式落盘 + 大小校验
v_size, i_size = 0, 0
with open(vpath, "wb") as f:
while chunk := await video.read(1024 * 1024):
v_size += len(chunk)
if v_size > _MAX_VIDEO_MB * 1024 * 1024:
vpath.unlink(missing_ok=True)
raise HTTPException(413, detail=f"视频超过 {_MAX_VIDEO_MB}MB 上限")
f.write(chunk)
with open(ipath, "wb") as f:
while chunk := await image.read(1024 * 1024):
i_size += len(chunk)
if i_size > _MAX_IMAGE_MB * 1024 * 1024:
vpath.unlink(missing_ok=True)
ipath.unlink(missing_ok=True)
raise HTTPException(413, detail=f"图片超过 {_MAX_IMAGE_MB}MB 上限")
f.write(chunk)
# 预检(时长/图片可解码) — 失败即删文件并报错
try:
svc = _get_service()
task = svc.create_task(str(vpath), str(ipath),
prompt=prompt.strip(),
character_name=character_name.strip(),
duration_sec=duration_sec)
except Exception as e:
vpath.unlink(missing_ok=True)
ipath.unlink(missing_ok=True)
raise HTTPException(400, detail=str(e))
return {"task": task}
@router.post("/tasks/{task_id}/cancel")
def api_ps_cancel(task_id: str):
svc = _get_service()
try:
t = svc.cancel_task(task_id)
except ValueError as e:
raise HTTPException(404, detail=str(e))
return {"task": t}
# ════════════════════════════════════════════════════════════
# 文件访问(受控: 预览/成片, 路径取自任务DB)
# ════════════════════════════════════════════════════════════
@router.get("/tasks/{task_id}/file/{kind}")
def api_ps_file(task_id: str, kind: str):
"""kind: preview(源视频预览帧) | output(成片) | source(原始上传视频) | image(人物参考图)"""
svc = _get_service()
t = svc.get_task(task_id)
if not t:
raise HTTPException(404, detail="任务不存在")
key = {"preview": "preview_path", "output": "output_path",
"source": "source_orig", "image": "image_orig"}.get(kind)
if not key:
raise HTTPException(400, detail="kind 必须是 preview/output/source/image")
path = t.get(key, "")
if not path:
raise HTTPException(404, detail="该任务暂无此文件")
p = _safe_path(path)
media_type = "video/mp4" if kind in ("output", "source") else "image/jpeg"
return FileResponse(str(p), media_type=media_type,
filename=f"{task_id}_{kind}{p.suffix}")
@@ -0,0 +1,711 @@
"""
routes/scrape.py — 抓取系统 API 路由 v1
端点:
POST /api/scrape/run — 执行抓取
POST /api/scrape/resolve — 批量解析 URL
GET /api/scrape/result — 异步任务结果查询
GET /api/scrape/tasks — 任务列表
GET /api/scrape/items — 内容列表
GET /api/scrape/items/{id} — 单条详情
GET /api/scrape/stats — 抓取统计
POST /api/scrape/sources — 创建抓取源
GET /api/scrape/sources — 抓取源列表
DELETE /api/scrape/sources/{id} — 删除抓取源
"""
import asyncio, json, logging
from fastapi import APIRouter, HTTPException
from services.scrape_engine import ScrapeEngine
logger = logging.getLogger("dashboard.routes.scrape")
router = APIRouter(prefix="/api/scrape", tags=["scrape"])
_engine = None
def _get_engine():
global _engine
if _engine is None:
_engine = ScrapeEngine()
return _engine
@router.post("/douyin-stats")
async def api_douyin_stats(data: dict = {}):
"""获取抖音视频详细统计数据(隔离于养号系统)"""
from services.douyin_stats import get_video_data
url = (data.get("url") or "").strip()
if not url:
return {"status": "error", "message": "url 必填"}
result = await get_video_data(url)
return {"status": "ok" if "error" not in result else "error", **result}
@router.post("/run")
async def api_scrape_run(data: dict = {}):
"""执行抓取任务"""
try:
targets = data.get("targets", data.get("target", []))
if isinstance(targets, str):
targets = [targets]
request = {
"targets": targets,
"platform": data.get("platform", "auto"),
"depth": data.get("depth", "light"),
"tool_level": data.get("tool_level", 2),
"machine": data.get("machine", ""),
"multi_machine": data.get("multi_machine", False),
"async_mode": data.get("async_mode", False),
}
engine = _get_engine()
result = await engine.run(request)
return {"status": "ok", **result}
except Exception as e:
logger.exception("抓取执行失败")
return {"status": "error", "message": str(e)}
@router.post("/resolve")
async def api_scrape_resolve(data: dict = {}):
"""批量解析 URL,不执行抓取"""
try:
urls = data.get("urls", data.get("targets", []))
if isinstance(urls, str):
urls = [urls]
engine = _get_engine()
results = engine.resolve_urls(urls)
# 解析短链
for r in results:
if r["type"] == "shortlink":
aweme_id = engine._resolve_shortlink(r["input"])
if aweme_id:
r["type"] = "video"
r["target_id"] = aweme_id
r["platform"] = "douyin"
r["status"] = "resolved"
else:
r["status"] = "unresolved"
return {"status": "ok", "data": results}
except Exception as e:
return {"status": "error", "message": str(e)}
@router.get("/title")
async def api_scrape_title(url: str = ""):
"""获取网页标题(扫码级轻量请求,供互动页导入用)"""
if not url:
return {"status": "ok", "title": ""}
if not url.startswith("http"):
url = "https://" + url
try:
import urllib.request, re
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Accept": "text/html",
})
with urllib.request.urlopen(req, timeout=10) as resp:
html = resp.read(65536).decode("utf-8", errors="replace")
m = re.search(r'<title[^>]*>([^<]+)</title>', html, re.IGNORECASE)
title = m.group(1).strip() if m else ""
# 抖音特殊处理:去掉 " - 抖音" 后缀
for suffix in [" - 抖音", " - 抖音视频", " - 快手", " - 小红书"]:
if title.endswith(suffix):
title = title[:-len(suffix)]
break
return {"status": "ok", "title": title}
except Exception as e:
return {"status": "error", "message": str(e), "title": ""}
@router.get("/result")
async def api_scrape_result(run_id: str = ""):
"""查询异步任务结果"""
if not run_id:
return {"status": "error", "message": "run_id 必填"}
engine = _get_engine()
result = engine.get_async_result(run_id)
if result is None:
return {"status": "error", "message": f"任务 {run_id} 不存在"}
return {"status": "ok", "data": result}
@router.get("/tasks")
async def api_scrape_tasks(status: str = "", platform: str = "",
limit: int = 50):
"""任务列表"""
engine = _get_engine()
tasks = engine.db.list_tasks(
status=status or None,
platform=platform or None,
limit=min(limit, 200)
)
return {"status": "ok", "data": tasks}
@router.get("/items")
async def api_scrape_items(platform: str = "", author_id: str = "",
limit: int = 100):
"""内容列表"""
engine = _get_engine()
items = engine.db.list_items(
platform=platform or None,
author_id=author_id or None,
limit=min(limit, 200)
)
return {"status": "ok", "data": items}
@router.get("/items/{item_id}")
async def api_scrape_item_detail(item_id: int):
"""单条内容详情(含评论区)"""
engine = _get_engine()
item = engine.db.get_item(item_id)
if not item:
raise HTTPException(status_code=404, detail="内容不存在")
comments = engine.db.get_comments(item_id)
item["comments"] = comments
return {"status": "ok", "data": item}
@router.get("/stats")
async def api_scrape_stats():
"""抓取统计"""
engine = _get_engine()
stats = engine.get_stats()
return {"status": "ok", **stats}
@router.post("/sources")
async def api_scrape_create_source(data: dict = {}):
"""创建抓取源"""
platform = data.get("platform", "")
source_type = data.get("source_type", "")
target = data.get("target", "")
if not platform or not target:
return {"status": "error", "message": "platform 和 target 必填"}
engine = _get_engine()
engine.db.upsert_source(
platform=platform,
source_type=source_type,
target=target,
display_name=data.get("display_name", ""),
category=data.get("category", ""),
notes=data.get("notes", ""),
schedule=data.get("schedule", ""),
depth=data.get("depth", "light"),
tool_level=data.get("tool_level", 2),
)
return {"status": "ok", "message": "抓取源已创建"}
@router.patch("/sources/{source_id}")
async def api_scrape_update_source(source_id: int, data: dict = {}):
"""编辑抓取源(名称/类别/说明/类型/目标/状态等)"""
engine = _get_engine()
ok = engine.db.update_source(source_id, **data)
if not ok:
return {"status": "error", "message": "没有可更新的字段或源不存在"}
return {"status": "ok", "message": "抓取源已更新"}
@router.get("/sources")
async def api_scrape_list_sources():
"""抓取源列表"""
engine = _get_engine()
sources = engine.db.list_sources()
return {"status": "ok", "data": sources}
@router.delete("/sources/{source_id}")
async def api_scrape_delete_source(source_id: int):
"""删除抓取源"""
engine = _get_engine()
engine.db.delete_source(source_id)
return {"status": "ok", "message": "抓取源已删除"}
# ── 抖音追踪系统(隔离于养号) ──
import json as _js, os as _os, time as _time, subprocess as _sp
from pathlib import Path as _Path
_TRACKER_DB = _Path(_os.environ.get("AGENT_LOCAL",
str(_Path.home() / "workbuddy-agent-os" / "agent-local"))) / "data" / "douyin_tracker.json"
def _load_tracker() -> list:
if _TRACKER_DB.exists():
try:
return _js.loads(_TRACKER_DB.read_text())
except:
pass
return []
def _save_tracker(items: list):
_TRACKER_DB.parent.mkdir(parents=True, exist_ok=True)
_TRACKER_DB.write_text(_js.dumps(items, ensure_ascii=False, indent=2))
def _clean_douyin_title(raw: str) -> str:
"""清洗抖音标题:去掉『2.07 复制打开抖音,看看【xxx的作品】』前缀"""
if not raw:
return ""
t = str(raw).strip()
# 去掉开头的数字+空格(如 "2.07 ")
import re as _re
t = _re.sub(r'^[\d.]+[\s]*', '', t)
# 去掉 "复制打开抖音" 及其变体
t = _re.sub(r'^复制打开抖音[,,。.]*\s*', '', t)
t = _re.sub(r'^打开抖音[,,。.]*\s*', '', t)
t = _re.sub(r'^看看[,,。.]*\s*', '', t)
# 去掉【xxx的作品】前缀
t = _re.sub(r'^【[^】]+的作品】', '', t)
# 再次去掉可能残留的 "复制打开抖音"
t = _re.sub(r'复制打开抖音', '', t)
return t.strip() or raw[:60]
@router.post("/import-topics")
async def api_import_topics(data: dict = {}):
"""从 tyhtak API 导入视频列表(轻量化,不调用 ops)"""
import urllib.request as _urq
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))
try:
# api_url 可能已带查询参数(如 ?page=1&pageSize=100)→ 用 & 追加,避免 ?page= 重复
sep = "&" if "?" in api_url else "?"
full_url = f"{api_url}{sep}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"请求失败: {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", ""))
items.append({
"id": vid,
"url": url,
"title": _clean_douyin_title(item.get("share_text", "")),
"author": item.get("dyname", "") or item.get("nickname", "") or item.get("name", ""),
# 结构化字段(归一化,任何 API 按常见键名映射;未采集也能展示/筛选)
"dyid": item.get("dyid", ""),
"likes": item.get("like_count", 0),
"comments": item.get("comment_count", 0),
"collects": item.get("favorite_count", 0),
"plays": item.get("play_count", 0),
"account_level": item.get("account_level", ""),
"task_id": item.get("task_id", ""),
"share_check_status": item.get("share_check_status", ""),
"submit_time": item.get("submit_time", ""),
"created_at": item.get("created_at", ""),
"raw": item, # 完整原始对象(列表展开查看)
})
return {"status": "ok", "items": items, "total": len(items)}
@router.post("/track-video")
async def api_track_video(data: dict = {}):
"""采集单条视频并加入跟踪"""
from services.douyin_stats import get_video_data
url = (data.get("url") or "").strip()
if not url:
return {"status": "error", "message": "url 必填"}
stats = await get_video_data(url)
if "error" in stats:
return {"status": "error", "message": stats["error"]}
# 生成唯一 ID
item_id = f"dy_{stats.get('aweme_id','')}"
record = {
"id": item_id,
"url": url,
"title": stats.get("title", ""),
"author": stats.get("author", ""),
"author_uid": stats.get("author_uid", ""), # 博主数字ID(用于判断是否已跟踪)
"stats": {
"likes": stats.get("likes", 0),
"comments": stats.get("comments", 0),
"collects": stats.get("collects", 0),
"shares": stats.get("shares", 0),
},
"comment_texts": stats.get("comment_texts", []),
"collected_at": _time.strftime("%Y-%m-%d %H:%M:%S"),
}
# 保存
items = _load_tracker()
# 去重(同 ID 替换)
# ⚠️ 修复:不能整个删掉重建(会丢失 prev_stats 对比链)。
# 视频已存在时 → 保留旧 stats 作为 prev_stats,再更新新 stats。
for i, it in enumerate(items):
if it["id"] == item_id:
# 已存在:保留历史对比数据
if "stats" in it:
record["prev_stats"] = it.get("stats")
record["prev_collected_at"] = it.get("collected_at", "")
# 保留旧的 video 级字段(author_uid 等)
record["author_uid"] = record.get("author_uid") or it.get("author_uid", "")
items.pop(i)
break
items.insert(0, record)
_save_tracker(items)
return {"status": "ok", "item": record}
@router.get("/tracked-videos")
async def api_tracked_videos():
"""获取已跟踪的视频列表"""
return {"status": "ok", "items": _load_tracker()}
@router.post("/delete-tracked/{item_id}")
async def api_delete_tracked(item_id: str):
"""删除单条跟踪视频"""
items = _load_tracker()
before = len(items)
items = [it for it in items if it["id"] != item_id]
if len(items) < before:
_save_tracker(items)
return {"status": "ok"}
return {"status": "error", "message": "未找到"}
@router.post("/refresh-video/{item_id}")
async def api_refresh_video(item_id: str):
"""刷新单条跟踪视频的数据"""
from services.douyin_stats import get_video_data
items = _load_tracker()
found = None
for it in items:
if it["id"] == item_id:
found = it
break
if not found:
return {"status": "error", "message": "未找到该视频"}
stats = await get_video_data(found["url"])
if "error" in stats:
resp = {"status": "error", "message": stats["error"]}
if stats.get("login_expired"):
resp["login_expired"] = True
resp["message"] = "⛔ 抖音登录已过期,请重新登录。点击顶部「📱 打开抖音登录」按钮"
return resp
# 保存旧数据(用于对比)
if "stats" in found:
found["prev_stats"] = found.get("stats")
found["prev_collected_at"] = found.get("collected_at", "")
found["stats"] = {
"likes": stats.get("likes", 0),
"comments": stats.get("comments", 0),
"collects": stats.get("collects", 0),
"shares": stats.get("shares", 0),
}
found["comment_texts"] = stats.get("comment_texts", [])
found["collected_at"] = _time.strftime("%Y-%m-%d %H:%M:%S")
_save_tracker(items)
return {"status": "ok", "item": found}
# ── 抖音博主监控(跟踪博主 + 每日快照 + 新视频发现) ──
# 数据流:
# 视频URL/博主主页URL → 拿 uid(数字ID)→ 存 douyin_authors.json
# → profile/other 打基线(粉丝/获赞/作品数)
# → aweme/post 拉最新视频 → 发现新视频自动加入视频跟踪
# 存储:agent-local/data/douyin_authors.json
# [{author_id, nickname, unique_id, uid, added_at, last_collected_at,
# snapshots: [{date, fans, total_favorited, works, new_videos: [id]}],
# video_ids: [已见视频ID集合,用于新视频发现]}]
_AUTHORS_DB = _Path(_os.environ.get("AGENT_LOCAL",
str(_Path.home() / "workbuddy-agent-os" / "agent-local"))) / "data" / "douyin_authors.json"
def _load_authors() -> list:
if _AUTHORS_DB.exists():
try:
return _js.loads(_AUTHORS_DB.read_text())
except:
pass
return []
def _save_authors(items: list):
_AUTHORS_DB.parent.mkdir(parents=True, exist_ok=True)
_AUTHORS_DB.write_text(_js.dumps(items, ensure_ascii=False, indent=2))
def _author_id_from_url(url: str) -> str:
"""从 URL 提取博主 ID:支持 /user/{sec_uid} 或 /video/{id}"""
import re as _re
m = _re.search(r'douyin\.com/user/([^/?#]+)', url)
if m:
return m.group(1)
return ""
@router.post("/track-author")
async def api_track_author(data: dict = {}):
"""加入博主跟踪(传视频URL或博主主页URL,自动解析 uid 并打基线)"""
from services.mediacrawler_adapter import get_video_data, get_author_profile, get_author_videos
url = (data.get("url") or data.get("video_url") or "").strip()
if not url:
return {"status": "error", "message": "url 必填(视频链接或博主主页链接)"}
# 情况1:博主主页链接 douyin.com/user/xxx → xxx 可能是 sec_uid
author_id = _author_id_from_url(url)
if author_id:
# 主页链接里的 ID 是 sec_uid,需要解析成数字 uid
# 方式:先当作视频无解,直接尝试用 sec_uid 查 profile(会失败),
# 因此这里先提示用视频链接,或尝试用 sec_uid 反查
return await _add_author_by_sec_uid(author_id, url)
# 情况2:视频链接 → 先采视频详情拿 uid
stats = await get_video_data(url)
if "error" in stats:
return {"status": "error", "message": stats["error"]}
uid = str(stats.get("author_uid", ""))
nickname = stats.get("author", "")
if not uid:
return {"status": "error", "message": "未能从视频解析博主ID"}
return await _add_author_by_uid(uid, nickname, url)
async def _add_author_by_sec_uid(sec_uid: str, source_url: str):
"""通过 sec_uid 添加博主(先采集一次打基线)"""
# sec_uid 不能直接查 profile/other(会报 UserId不合法)
# 尝试通过视频详情反查:没有视频 URL 时先用 sec_uid 查 aweme/post(同样要数字uid)
# 目前无法直接转换 → 提示用户使用视频链接
return {"status": "error", "message": "博主主页链接暂不支持直接添加,请改用视频链接(主页 ID 是加密格式)"}
async def _add_author_by_uid(uid: str, nickname: str, source_url: str):
"""通过数字 uid 添加博主并打基线"""
from services.mediacrawler_adapter import get_author_profile, get_author_videos
authors = _load_authors()
for a in authors:
if a.get("uid") == uid:
return {"status": "ok", "message": f"博主 {nickname} 已在跟踪中", "item": a, "already": True}
profile = await get_author_profile(uid)
if "error" in profile:
return {"status": "error", "message": profile["error"]}
# 拉最新视频(用于新视频发现基线)
videos_res = await get_author_videos(uid, count=20)
video_ids = [v["aweme_id"] for v in videos_res.get("videos", [])]
author = {
"author_id": f"author_{uid}",
"uid": uid,
"nickname": profile.get("nickname", nickname),
"unique_id": profile.get("unique_id", ""),
"sec_uid": profile.get("sec_uid", ""),
"added_at": _time.strftime("%Y-%m-%d %H:%M:%S"),
"source_url": source_url,
"last_collected_at": _time.strftime("%Y-%m-%d %H:%M:%S"),
"latest_videos": videos_res.get("videos", [])[:3], # 最新3条视频(标题+数据)
"snapshots": [{
"date": _time.strftime("%Y-%m-%d"),
"fans": profile.get("fans", 0),
"total_favorited": profile.get("total_favorited", 0),
"works": profile.get("works", 0),
"new_videos": video_ids,
}],
"video_ids": video_ids,
}
authors.insert(0, author)
_save_authors(authors)
return {"status": "ok", "message": f"已跟踪博主 {author['nickname']}", "item": author}
@router.get("/tracked-authors")
async def api_tracked_authors():
"""博主跟踪列表(含最新快照、今日涨粉)"""
authors = _load_authors()
today = _time.strftime("%Y-%m-%d")
result = []
for a in authors:
snaps = a.get("snapshots", [])
latest = snaps[-1] if snaps else {}
prev = snaps[-2] if len(snaps) > 1 else {}
result.append({
"author_id": a.get("author_id"),
"uid": a.get("uid"),
"nickname": a.get("nickname"),
"unique_id": a.get("unique_id"),
"added_at": a.get("added_at"),
"last_collected_at": a.get("last_collected_at"),
"fans": latest.get("fans", 0),
"fans_delta": (latest.get("fans", 0) - prev.get("fans", 0)) if prev else 0,
"total_favorited": latest.get("total_favorited", 0),
"works": latest.get("works", 0),
"snapshot_count": len(snaps),
"last_snapshot_date": latest.get("date", ""),
"today_collected": latest.get("date") == today,
"latest_videos": a.get("latest_videos", []), # 最新3条视频(标题+数据)
})
return {"status": "ok", "items": result}
@router.post("/refresh-author/{author_id}")
async def api_refresh_author(author_id: str):
"""手动刷新单个博主:采主页快照 + 发现新视频(自动加入视频跟踪)"""
from services.mediacrawler_adapter import get_author_profile, get_author_videos
authors = _load_authors()
found = next((a for a in authors if a.get("author_id") == author_id), None)
if not found:
return {"status": "error", "message": "未找到该博主"}
profile = await get_author_profile(found["uid"])
if "error" in profile:
resp = {"status": "error", "message": profile["error"]}
if profile.get("login_expired"):
resp["login_expired"] = True
resp["message"] = "⛔ 抖音登录已过期,请重新登录"
return resp
# 拉最新视频,发现新视频
videos_res = await get_author_videos(found["uid"], count=20)
new_videos = []
known = set(found.get("video_ids", []))
for v in videos_res.get("videos", []):
if v["aweme_id"] not in known:
new_videos.append(v["aweme_id"])
# 自动加入视频跟踪
await api_track_video({"url": v["url"]})
found["video_ids"] = list(known) + new_videos
# 更新最新3条视频(标题+数据)
found["latest_videos"] = videos_res.get("videos", [])[:3]
# 存快照
today = _time.strftime("%Y-%m-%d")
found.setdefault("snapshots", []).append({
"date": today,
"fans": profile.get("fans", 0),
"total_favorited": profile.get("total_favorited", 0),
"works": profile.get("works", 0),
"new_videos": new_videos,
})
# 同一天重复刷新 → 替换今天的快照(不重复堆积)
snaps = found["snapshots"]
same_day = [i for i, s in enumerate(snaps) if s.get("date") == today]
if len(same_day) > 1:
# 保留第一次,删除后续同日快照
for i in sorted(same_day[1:], reverse=True):
snaps.pop(i)
found["last_collected_at"] = _time.strftime("%Y-%m-%d %H:%M:%S")
_save_authors(authors)
return {"status": "ok", "item": {
"author_id": found["author_id"],
"nickname": found["nickname"],
"fans": profile.get("fans", 0),
"total_favorited": profile.get("total_favorited", 0),
"works": profile.get("works", 0),
"new_videos": new_videos,
"new_video_count": len(new_videos),
}}
@router.post("/refresh-all-authors")
async def api_refresh_all_authors():
"""刷新全部博主(限速 3 秒/个)"""
from services.mediacrawler_adapter import get_author_profile
authors = _load_authors()
results = []
for a in authors:
try:
r = await api_refresh_author(a["author_id"])
results.append({"author_id": a["author_id"], "nickname": a.get("nickname"), "status": r.get("status"), "new_videos": r.get("item", {}).get("new_video_count", 0)})
except Exception as e:
results.append({"author_id": a["author_id"], "nickname": a.get("nickname"), "status": "error", "error": str(e)[:100]})
await asyncio.sleep(3)
return {"status": "ok", "results": results, "total": len(authors)}
@router.get("/author-history/{author_id}")
async def api_author_history(author_id: str):
"""博主历史趋势(每日序列)"""
authors = _load_authors()
found = next((a for a in authors if a.get("author_id") == author_id), None)
if not found:
return {"status": "error", "message": "未找到该博主"}
return {"status": "ok", "item": {
"author_id": found["author_id"],
"nickname": found["nickname"],
"uid": found["uid"],
"snapshots": found.get("snapshots", []),
}}
@router.get("/author-videos/{author_id}")
async def api_author_videos(author_id: str):
"""博主最新视频列表(实时拉取)"""
from services.mediacrawler_adapter import get_author_videos
authors = _load_authors()
found = next((a for a in authors if a.get("author_id") == author_id), None)
if not found:
return {"status": "error", "message": "未找到该博主"}
res = await get_author_videos(found["uid"], count=30)
return {"status": "ok", "videos": res.get("videos", []), "has_more": res.get("has_more", False)}
@router.delete("/tracked-authors/{author_id}")
async def api_delete_tracked_author(author_id: str):
"""取消博主跟踪"""
authors = _load_authors()
before = len(authors)
authors = [a for a in authors if a.get("author_id") != author_id]
if len(authors) < before:
_save_authors(authors)
return {"status": "ok"}
return {"status": "error", "message": "未找到"}
@router.get("/author-new-videos")
async def api_author_new_videos():
"""全部博主的新视频汇总(最近一次刷新发现的新视频)"""
authors = _load_authors()
items = []
for a in authors:
snaps = a.get("snapshots", [])
if snaps:
latest = snaps[-1]
for vid in latest.get("new_videos", []):
items.append({"author_id": a.get("author_id"), "nickname": a.get("nickname"), "video_id": vid})
return {"status": "ok", "items": items}
@router.get("/chrome-status")
async def api_chrome_status():
"""检查采集 Chrome 是否可用(只检查 profile 目录,不检查 CDP 端口)"""
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
profile_ok = _os.path.exists("/tmp/chrome-douyin-profile/Default/Cookies")
chrome_installed = _os.path.exists(CHROME)
return {"status": "ok", "online": True, "chrome_installed": chrome_installed, "profile_ok": profile_ok}
@router.get("/check-login")
async def api_check_login():
"""检查抖音登录状态"""
from services.mediacrawler_adapter import check_login_status
try:
result = await check_login_status()
return {"status": "ok", **result}
except Exception as e:
return {"status": "error", "message": str(e)}
@router.post("/open-login")
async def api_open_login():
"""在 Chrome 中打开抖音登录页"""
from services.mediacrawler_adapter import open_login_page
try:
result = await open_login_page()
return result
except Exception as e:
return {"status": "error", "message": str(e)}
@@ -0,0 +1,216 @@
"""
v2_accounts.py — V2 统一账号管理 API 路由
基于 AccountService 提供联邦账号中心的统一数据视图。
旧 API(matrix.py /api/matrix/accounts)保留兼容。
前缀: /api/v2
"""
import json
import logging
import os
import sys
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
logger = logging.getLogger("dashboard.routes.v2_accounts")
_THIS_DIR = Path(__file__).resolve().parent.parent
AGENT_SYNC = Path(os.environ.get("AGENT_SYNC", str(Path.home() / "workbuddy-agent-os" / "agent-sync")))
AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(Path.home() / "workbuddy-agent-os" / "agent-local")))
router = APIRouter(prefix="/api/v2", tags=["v2_accounts"])
def _get_svc():
"""获取 AccountService 实例(延迟导入,单例缓存)"""
if not hasattr(_get_svc, "_instance"):
sys.path.insert(0, str(_THIS_DIR))
from services.account_service import AccountService
_get_svc._instance = AccountService()
return _get_svc._instance
# ═══════════════════════════════════════════════════════════
# 账号列表与详情
# ═══════════════════════════════════════════════════════════
@router.get("/accounts")
def api_v2_accounts(
machine: str = "",
platform: str = "",
status: str = "",
q: str = "",
):
"""获取所有账号的统一视图"""
try:
svc = _get_svc()
accounts = svc.get_all_accounts()
if machine:
accounts = [a for a in accounts if a.get("owner_machine") == machine]
if platform:
accounts = [a for a in accounts if a.get("platform") == platform]
if status:
accounts = [a for a in accounts if a.get("login_status") == status]
if q:
lq = q.lower()
accounts = [a for a in accounts if lq in a["id"].lower() or lq in a.get("phone", "").lower()
or lq in a.get("nickname", "").lower() or lq in a.get("identity_dir", "").lower()
or lq in a.get("owner_machine", "").lower()]
return {"accounts": accounts, "total": len(accounts), "generated_at": time.time()}
except Exception as e:
logger.error(f"v2/accounts 查询失败: {e}", exc_info=True)
raise HTTPException(500, detail=str(e))
@router.get("/accounts/{account_id}")
def api_v2_account_detail(account_id: str):
"""获取单个账号详情"""
try:
svc = _get_svc()
acct = svc.get_account_detail(account_id)
if not acct:
raise HTTPException(404, detail=f"账号 {account_id} 不存在")
return acct
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.get("/status-summary")
def api_v2_status_summary():
"""获取账号状态汇总"""
try:
svc = _get_svc()
accounts = svc.get_all_accounts()
summary = {"total": len(accounts), "logged_in": 0, "cookie_expiring": 0,
"no_cookie": 0, "banned": 0, "disabled": 0, "unknown": 0}
for a in accounts:
s = a.get("login_status", "unknown")
if s in summary:
summary[s] += 1
else:
summary["unknown"] += 1
return summary
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.patch("/accounts/{account_id}")
def api_v2_update_notes(account_id: str, data: dict):
"""更新账号备注/标签
- 本机账号:写入本机 accounts.yaml(MatrixManager)
- 远程账号:本地找不到时,tags 写入集中标签文件(agent-local/data/account_tags_cache.json)
"""
try:
import yaml
sys.path.insert(0, str(AGENT_SYNC / "05_tools" / "07_matrix" / "scripts"))
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
update_data = {}
if "notes" in data:
update_data["notes"] = data["notes"]
if "tags" in data:
update_data["tags"] = data["tags"]
if not update_data:
return {"status": "ok"}
try:
mgr.update_account(account_id, update_data)
except ValueError:
# 远程账号(本机 MatrixManager 无此账号)→ tags 写入集中标签文件
if "tags" in update_data and update_data["tags"] is not None:
_save_remote_tags(account_id, update_data["tags"])
else:
raise HTTPException(404, detail=f"账号 {account_id} 不存在")
return {"status": "ok"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, detail=str(e))
def _save_remote_tags(account_id: str, tags: list) -> None:
"""远程账号标签 → 集中标签文件(agent-local/data/account_tags_cache.json)
读取侧 account_service._load_tags_cache 已从此文件读远程 tags,写入即可生效。
"""
import json as _json
_CACHE_PATH = AGENT_LOCAL / "data" / "account_tags_cache.json"
data = {}
if _CACHE_PATH.exists():
try:
data = _json.loads(_CACHE_PATH.read_text())
except Exception:
data = {}
data[account_id] = [str(t) for t in (tags or [])]
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_CACHE_PATH.write_text(_json.dumps(data, ensure_ascii=False, indent=1))
logger.info(" 🏷️ 远程账号 %s 标签已保存到集中文件 (%d 条)", account_id, len(data[account_id]))
# ═══════════════════════════════════════════════════════════
# 批量操作
# ═══════════════════════════════════════════════════════════
@router.post("/accounts/batch")
def api_v2_accounts_batch(data: dict):
"""批量操作选中的账号"""
account_ids = data.get("account_ids", [])
action = data.get("action", "")
params = data.get("params", {})
if not account_ids:
raise HTTPException(400, detail="account_ids 必填")
if not action:
raise HTTPException(400, detail="action 必填")
valid_actions = ("collect", "login", "nurture", "comment", "record")
if action not in valid_actions:
raise HTTPException(400, detail=f"action 必须是 {valid_actions} 之一")
try:
from services.command_bus import CommandBus
svc = _get_svc()
all_accounts = svc.get_all_accounts()
acct_map = {a["id"]: a for a in all_accounts}
machine_groups = {}
for aid in account_ids:
a = acct_map.get(aid)
if not a:
raise HTTPException(400, detail=f"账号 {aid} 不存在")
m = a.get("owner_machine", "")
if m not in machine_groups:
machine_groups[m] = []
machine_groups[m].append(aid)
results = []
for machine, ids in machine_groups.items():
r = CommandBus.dispatch(action, ids, params)
results.append(r)
return {"status": "ok", "results": results}
except HTTPException:
raise
except Exception as e:
logger.error(f"v2/accounts/batch 失败: {e}")
raise HTTPException(500, detail=str(e))
# ═══════════════════════════════════════════════════════════
# 状态刷新
# ═══════════════════════════════════════════════════════════
@router.post("/accounts/refresh")
def api_v2_accounts_refresh(data: dict):
"""强制刷新账号状态"""
try:
svc = _get_svc()
account_ids = data.get("account_ids")
result = svc.batch_refresh_status(account_ids)
return {"status": "ok", "refreshed": len(result), "accounts": result}
except Exception as e:
raise HTTPException(500, detail=str(e))