feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,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))
|
||||
Reference in New Issue
Block a user