Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/routes/matrix.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

1442 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
社交矩阵路由 — 账号管理、养号、采集、蓝图、原子操作等
所有路由以 /api/matrix/ 为前缀。
"""
import json, os, subprocess, sys, time, asyncio, logging
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, HTTPException
logger = logging.getLogger("dashboard")
# ── 路径 ──
FILE_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")))
# agent-os 统一 venv Python 路径(用于本地和远程 SSH 执行)
PYTHON = str(Path.home() / ".workbuddy" / "binaries" / "python" / "envs" / "agent-os" / "bin" / "python3")
# 远程机器的 Python 路径(动态替换 home 目录)
REMOTE_PYTHON = "$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3"
from utils.identity import resolve_hostname
HOSTNAME = resolve_hostname()
router = APIRouter(prefix="/api/matrix", tags=["matrix"])
def _get_matrix_mgr():
"""获取 MatrixManager 实例"""
scripts_dir = AGENT_SYNC / "05_tools" / "07_matrix" / "scripts"
sys.path.insert(0, str(scripts_dir))
from matrix_mgmt import MatrixManager
return MatrixManager()
@router.get("/accounts")
def api_matrix_accounts():
"""
🚫 DEPRECATED — 数据读取请使用 /api/v2/accounts
此接口供旧系统(inline.js / modules/)向后兼容。
所有新的视图代码(views/*.js)已迁移到 /api/v2/accounts。
新增功能请勿调用此接口。
"""
logger.warning("🚫 DEPRECATED: /api/matrix/accounts 被调用,请使用 /api/v2/accounts")
try:
mgr = _get_matrix_mgr()
accounts = mgr.list_accounts()
# 合并 profiles.json 数据(昵称/粉丝/头像)
profiles_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
profiles = {}
if profiles_path.exists():
try:
profiles = json.loads(profiles_path.read_text())
except Exception:
pass
for acct in accounts:
aid = acct.get("id", "")
pid = acct.get("identity_dir", "").replace("identities/", "")
profile = profiles.get(aid) or profiles.get(pid) or {}
if profile:
acct["nickname"] = profile.get("nickname", acct.get("nickname", ""))
acct["fans"] = profile.get("fans", acct.get("fans", ""))
acct["avatar"] = profile.get("avatar", acct.get("avatar", ""))
acct["following"] = profile.get("following", acct.get("following", ""))
acct["likes"] = profile.get("likes", acct.get("likes", ""))
acct["posts"] = profile.get("posts", acct.get("posts", ""))
acct["industry"] = profile.get("industry", "general")
# 合并封号状态:profiles.json 中的 status 覆盖通用状态
pstatus = profile.get("status", "")
if pstatus == "banned":
acct["_status"] = "banned"
acct["_banned"] = True
# 从 homepage-info 补充联邦采集数据
if not profile:
fleet_dir = AGENT_LOCAL / "runtime" / "fleet_collector"
if fleet_dir.exists():
for machine_dir in sorted(fleet_dir.iterdir()):
try:
# 1. 按 identity_dir 查 homepage_info
hp_file = machine_dir / "homepage_info.json"
if hp_file.exists():
hp_data = json.loads(hp_file.read_text())
for entry in hp_data.get("results", []):
if entry.get("identity_dir", "").replace("identities/", "") == pid:
for plat_key in ("douyin", "xiaohongshu"):
plat_data = entry.get(plat_key, {}) or {}
if plat_data.get("nickname"):
acct.setdefault("nickname", plat_data["nickname"])
acct.setdefault("fans", str(plat_data.get("fans", "")))
acct.setdefault("avatar", plat_data.get("avatar", ""))
acct.setdefault("following", str(plat_data.get("following", "")))
acct.setdefault("likes", str(plat_data.get("likes", "")))
acct.setdefault("posts", str(plat_data.get("posts", "")))
acct["_source_machine"] = machine_dir.name
break
if acct.get("nickname"):
break
# 2. 按 account_id 查 profiles.json
if not acct.get("nickname"):
pf_file = machine_dir / "profiles.json"
if pf_file.exists():
remote_pf = json.loads(pf_file.read_text())
rp = remote_pf.get(aid) or remote_pf.get(pid) or {}
if rp:
acct["nickname"] = rp.get("nickname", acct.get("nickname", ""))
acct["fans"] = str(rp.get("fans", acct.get("fans", "")))
acct["avatar"] = rp.get("avatar", acct.get("avatar", ""))
acct["following"] = str(rp.get("following", acct.get("following", "")))
acct["likes"] = str(rp.get("likes", acct.get("likes", "")))
acct["posts"] = str(rp.get("posts", acct.get("posts", "")))
acct["_source_machine"] = machine_dir.name
except Exception:
pass
acct["_source_machine"] = acct.get("machine", acct.get("_source_machine", ""))
return accounts
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.get("/profiles")
def api_matrix_account_profiles():
"""获取所有账号的已缓存资料"""
try:
path = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
if not path.exists():
return {"profiles": {}}
return {"profiles": json.loads(path.read_text())}
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.get("/homepage-info")
def api_matrix_homepage_info():
"""获取主页信息采集结果(聚合联邦数据 + 合并 profiles.json)
数据来源(按优先级):
1. fleet_collector 缓存(其他机器数据)
2. 本机 homepage_info.json
3. profiles.json 覆盖补全
"""
# 优先使用联邦采集聚合数据
try:
from services.fleet_collector import get_merged_homepage
hp_data = get_merged_homepage()
except Exception:
hp_data = {"results": []}
# 若联邦聚合无数据,降级到本机文件
if not hp_data.get("results"):
hp_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "homepage_info.json"
hp_data = {"results": []}
if hp_path.exists():
try:
hp_data = json.loads(hp_path.read_text())
except:
hp_data = {"results": []}
pf_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
# 获取账号注册表,建立 account_id → identity_dir 映射
ident_map = {} # account_id → identity_dir
phone_map = {} # account_id → phone
try:
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
for a in mgr.list_accounts():
aid = a.get("id", "")
ident_map[aid] = a.get("identity_dir", aid).replace("identities/", "")
phone_map[aid] = a.get("phone", "")
except:
pass
# 用 profiles.json 覆盖/补全最新数据
if pf_path.exists():
try:
profiles = json.loads(pf_path.read_text())
results = hp_data.get("results", [])
# 1) 建立 (identity_dir → {platform → profile}) 映射(一个身份目录可能有抖音+小红书两个账号)
prof_by_ident_plat = {} # identity_dir → {douyin: profile, xiaohongshu: profile}
prof_by_phone_plat = {}
for acct_id, prof in profiles.items():
ident = ident_map.get(acct_id, acct_id)
phone = phone_map.get(acct_id, "")
plat = prof.get("platform", "douyin")
prof_by_ident_plat.setdefault(ident, {})
prof_by_ident_plat[ident][plat] = prof
if phone:
prof_by_phone_plat.setdefault(phone, {})
prof_by_phone_plat[phone][plat] = prof
tracked = set()
# 2) 覆盖已有 entry 的数据(按平台分别覆盖)
for entry in results:
ident = entry.get("identity_dir", "")
phone = entry.get("phone", "")
plat_profs = prof_by_ident_plat.get(ident) or prof_by_phone_plat.get(phone) or {}
if ident:
tracked.add(ident)
for plat, prof in plat_profs.items():
if plat not in entry:
entry[plat] = {}
p = entry[plat]
p["nickname"] = prof.get("nickname", p.get("nickname", ""))
p["fans"] = prof.get("fans", p.get("fans", ""))
p["likes"] = prof.get("likes", p.get("likes", ""))
p["posts"] = prof.get("posts", p.get("posts", ""))
p["following"] = prof.get("following", p.get("following", ""))
# 3) 补全 profiles.json 有但 homepage_info 中没有的条目
for acct_id, prof in profiles.items():
ident = ident_map.get(acct_id, acct_id)
if ident in tracked:
continue
plat = prof.get("platform", "douyin")
results.append({
"identity_dir": ident,
"phone": phone_map.get(acct_id, ""),
"status": "loaded",
plat: {
"nickname": prof.get("nickname", ""),
"fans": prof.get("fans", ""),
"likes": prof.get("likes", ""),
"posts": prof.get("posts", ""),
"following": prof.get("following", ""),
}
})
tracked.add(ident)
except:
pass
return hp_data
@router.get("/homepage-history")
def api_matrix_homepage_history():
"""获取主页信息采集历史"""
history_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "homepage" / "history" / "timeline.json"
if not history_path.exists():
return {"history": []}
return json.loads(history_path.read_text())
# ── 废弃:采集入口已统一到 POST /api/ops/run {type:'collect', accounts, params} ──
# 前端 matrix-collect.js 已改走统一入口,CommandBus 注册表自动按账号平台选择蓝图
# collect-homepage/phone、collect-homepage/status、collect-homepage/cancel 一并废弃
@router.get("/nurture/preview")
def api_matrix_nurture_preview(mins: int = 10, concur: int = 3, stagger: int = 15):
"""预览全部养号排期"""
try:
mgr = _get_matrix_mgr()
identities = mgr.get_identities()
total = len(identities)
batches = (total + concur - 1) // concur
return {"identities": total, "batches": batches, "estimated_minutes": mins}
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.post("/nurture/start")
def api_matrix_nurture_start(data: dict = {}):
"""(已废弃) 改用 POST /api/ops/run {type:'nurture', accounts, params}"""
accounts = data.get("accounts", [])
if not accounts:
return {"status": "error", "message": "accounts 必填"}
from services.command_bus import CommandBus
result = CommandBus.dispatch("nurture", accounts, {
"blueprint": data.get("blueprint", "douyin_daily"),
"rounds": data.get("rounds", 10),
})
return {"status": "started", "deprecated": True, "commands": result.get("commands", [])}
@router.get("/nurture/status")
def api_matrix_nurture_status():
"""返回当前运行状态(含浏览器检测)"""
try:
# 用 pgrep 直接查本机 Camoufox 进程(不依赖 browser_orchestrator 避免循环导入/挂起)
local_browsers = []
try:
r = subprocess.run(
["pgrep", "-f", "camoufox.*--remote-debugging-port|HeadlessShell"],
capture_output=True, text=True, timeout=5
)
if r.stdout.strip():
for pid in r.stdout.strip().split("\n"):
pid = pid.strip()
if not pid:
continue
try:
cmd_r = subprocess.run(
["ps", "-p", pid, "-o", "command="],
capture_output=True, text=True, timeout=3
)
local_browsers.append({"pid": int(pid), "cmd": (cmd_r.stdout or "").strip()[:80]})
except:
pass
except:
pass
# 远程机器
ORACLE_PATH = AGENT_SYNC / "ORACLE.yaml"
remote_machines = []
if ORACLE_PATH.exists():
import yaml
oracle = yaml.safe_load(ORACLE_PATH.read_text())
for name in oracle.get("machines", {}):
if name != HOSTNAME:
try:
# 简单 ping + pgrep 检查
import subprocess as _sp
pr = _sp.run(["ping", "-c", "1", "-W", "2", name],
capture_output=True, timeout=5)
alive = pr.returncode == 0
remote_machines.append({"machine": name, "alive": alive})
except:
remote_machines.append({"machine": name, "error": "不可达"})
return {
"status": "running" if local_browsers else "idle",
"running_count": len(local_browsers),
"local_browsers": local_browsers,
"remote_machines": remote_machines,
}
except Exception as e:
return {"status": "error", "message": str(e)}
@router.get("/nurture/results")
def api_matrix_nurture_results(limit: int = 20):
"""返回最近养号执行结果"""
try:
results_dir = AGENT_LOCAL / "runtime" / "nurture" / "results"
all_results = []
if results_dir.exists():
for f in sorted(results_dir.glob("nurture_*.json"), reverse=True)[:limit]:
try:
data = json.loads(f.read_text())
all_results.append(data)
except:
pass
return {"results": all_results, "total": len(all_results)}
except Exception as e:
return {"results": [], "error": str(e)}
@router.get("/nurture/log/{run_id}")
def api_matrix_nurture_log(run_id: str):
"""返回指定 run_id 的详细日志"""
try:
log_path = AGENT_LOCAL / "runtime" / "nurture" / "logs" / f"{run_id}.log"
if not log_path.exists():
return {"error": f"日志不存在: {run_id}", "log": ""}
content = log_path.read_text(encoding="utf-8", errors="replace")
return {"run_id": run_id, "log": content[-5000:]}
except Exception as e:
return {"error": str(e), "log": ""}
@router.post("/nurture/cleanup")
def api_matrix_nurture_cleanup(data: dict = {}):
"""清理残留浏览器进程"""
machine = data.get("machine", HOSTNAME)
from services.browser_orchestrator import cleanup_stale
return cleanup_stale(machine)
@router.get("/machine/{machine_name}/status")
def api_machine_status(machine_name: str):
"""获取一台机器的实时状态"""
from services.browser_orchestrator import get_machine_status
return get_machine_status(machine_name)
@router.get("/accounts/{account_id}")
def api_matrix_account(account_id: str):
"""获取单个账号详情"""
try:
mgr = _get_matrix_mgr()
accts = mgr.list_accounts()
for a in accts:
if a.get("id") == account_id:
return a
raise HTTPException(404, detail=f"账号 {account_id} 不存在")
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.post("/accounts")
async def api_matrix_create_account(data: dict):
"""创建新账号(支持写入远程机器)"""
import subprocess
import yaml
try:
mgr = _get_matrix_mgr()
mgr.create_account(data)
# 更新 ORACLE 机器归属
owner_machine = data.get("owner_machine", "")
aid = data.get("id", "")
if owner_machine and aid:
try:
oracle_path = AGENT_SYNC / "ORACLE.yaml"
if oracle_path.exists():
oracle = yaml.safe_load(oracle_path.read_text())
changed = False
for entry in oracle.get("accounts", []):
if aid in entry.get("platforms", {}).values():
entry["machine"] = owner_machine
changed = True
break
if not changed:
# 新账号添加到 ORACLE
plat = data.get("platform", "")
# identity 目录名:若传了 identity_dir 则取最后一段,否则用 aid
raw_id_dir = data.get("identity_dir", aid)
identity_name = raw_id_dir.replace("identities/", "")
oracle.setdefault("accounts", []).append({
"phone": data.get("phone", ""),
"machine": owner_machine,
"identity": identity_name,
"platforms": {plat: aid},
})
oracle_path.write_text(yaml.dump(oracle, default_flow_style=False, allow_unicode=True, sort_keys=False))
except Exception:
pass
# 如果指定了远程机器,通过 SSH 写入该机器的 accounts.yaml
if owner_machine and owner_machine != HOSTNAME:
# 读取 ORACLE.yaml 获取机器 IP
oracle_path = AGENT_SYNC / "ORACLE.yaml"
if oracle_path.exists():
oracle = yaml.safe_load(oracle_path.read_text())
machine_cfg = oracle.get("machines", {}).get(owner_machine, {})
remote_ip = machine_cfg.get("tailscale_ip", "")
remote_user = machine_cfg.get("ssh_user", owner_machine)
if remote_ip:
aid = data.get("id", "")
plat = data.get("platform", "")
phone = data.get("phone", "")
identity_dir = data.get("identity_dir", f"identities/{aid}")
# SSH 到远程机器创建账号(使用远程机器自己的 Python 路径)
remote_cmd = (
f"cd ~/workbuddy-agent-os/agent-sync/05_tools/07_matrix/scripts && "
f"PYTHONPATH=\"$HOME/workbuddy-agent-os/agent-sync/05_tools/07_matrix/scripts\" "
f"{REMOTE_PYTHON} -c "
f"\"from matrix_mgmt import MatrixManager; "
f"mgr = MatrixManager(); "
f"mgr.create_account({{'id':'{aid}','platform':'{plat}',"
f"'phone':'{phone}','identity_dir':'{identity_dir}','enabled':True}})\""
)
try:
r = subprocess.run(
["ssh", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=no",
f"{remote_user}@{remote_ip}", remote_cmd],
capture_output=True, text=True, timeout=30
)
if r.returncode != 0:
logger.warning(f"远程创建账号 {aid} 失败 (stderr): {r.stderr.strip()}")
else:
logger.info(f"远程账号 {aid} 已在 {owner_machine} 创建成功")
except subprocess.TimeoutExpired:
logger.warning(f"远程创建账号 {aid} 超时 (30s)")
except Exception as e:
logger.warning(f"远程创建账号 {aid} 异常: {e}")
return {"status": "ok"}
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.put("/accounts/{account_id}")
async def api_matrix_update_account(account_id: str, data: dict):
"""更新账号"""
try:
mgr = _get_matrix_mgr()
mgr.update_account(account_id, data)
return {"status": "ok"}
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.delete("/accounts/{account_id}")
def api_matrix_delete_account(account_id: str):
"""彻底删除账号(配置+身份目录+ORACLE,支持远程机器同步清理)"""
import yaml
try:
mgr = _get_matrix_mgr()
# 查一下这个账号属于哪台机器(用于远程清理)
owner_machine = ""
try:
oracle_path = AGENT_SYNC / "ORACLE.yaml"
if oracle_path.exists():
oracle = yaml.safe_load(oracle_path.read_text())
for entry in oracle.get("accounts", []):
for plat, aid in entry.get("platforms", {}).items():
if aid == account_id:
owner_machine = entry.get("machine", "") or entry.get("assigned_machine", "")
break
except Exception:
pass
# 删除本地配置 + 身份目录
mgr.delete_account(account_id, delete_identity=True)
# 从 ORACLE.yaml 中移除
try:
oracle_path = AGENT_SYNC / "ORACLE.yaml"
if oracle_path.exists():
oracle = yaml.safe_load(oracle_path.read_text())
before = len(oracle.get("accounts", []))
oracle["accounts"] = [
e for e in oracle.get("accounts", [])
if not any(account_id in v for v in e.get("platforms", {}).values())
]
if len(oracle["accounts"]) < before:
oracle_path.write_text(
yaml.dump(oracle, default_flow_style=False, allow_unicode=True, sort_keys=False)
)
# 清除 AccountService 的 60 秒缓存,让后续查询立即读新数据
try:
from services.account_service import AccountService
svc = AccountService()
svc._oracle = None
svc._oracle_loaded = 0
except Exception:
pass
except Exception:
pass
# 如果是远程机器的账号,SSH 同步删除
if owner_machine and owner_machine != HOSTNAME:
try:
oracle_path = AGENT_SYNC / "ORACLE.yaml"
if oracle_path.exists():
oracle = yaml.safe_load(oracle_path.read_text())
machine_cfg = oracle.get("machines", {}).get(owner_machine, {})
remote_ip = machine_cfg.get("tailscale_ip", "")
remote_user = machine_cfg.get("ssh_user", owner_machine)
if remote_ip:
remote_cmd = (
f"cd ~/workbuddy-agent-os/agent-sync/05_tools/07_matrix/scripts && "
f"PYTHONPATH=\"$HOME/workbuddy-agent-os/agent-sync/05_tools/07_matrix/scripts\" "
f"{REMOTE_PYTHON} -c "
f"\"from matrix_mgmt import MatrixManager; "
f"mgr = MatrixManager(); "
f"mgr.delete_account('{account_id}', delete_identity=True)\""
)
subprocess.run(
["ssh", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=no",
f"{remote_user}@{remote_ip}", remote_cmd],
capture_output=True, text=True, timeout=30
)
except Exception:
pass
return {"status": "ok"}
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.get("/accounts/{account_id}/login-status")
def api_matrix_login_status(account_id: str):
"""检查登录状态"""
return {"account_id": account_id, "status": "unknown"}
@router.get("/identities")
def api_matrix_identities():
"""获取所有身份的聚合视图"""
try:
mgr = _get_matrix_mgr()
return {"identities": mgr.get_identities()}
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.delete("/identities/{identity_dir:path}")
def api_matrix_delete_identity(identity_dir: str):
"""删除整个身份"""
try:
mgr = _get_matrix_mgr()
logger.info(f"Matrix: 删除身份 {identity_dir}")
return mgr.delete_identity(identity_dir)
except ValueError as e:
raise HTTPException(404, detail=str(e))
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.post("/accounts/{account_id}/unbind")
def api_matrix_unbind_account(account_id: str):
"""从身份中解绑单个平台账号"""
try:
mgr = _get_matrix_mgr()
logger.info(f"Matrix: 解绑账号 {account_id}")
return mgr.unbind_account(account_id)
except ValueError as e:
raise HTTPException(404, detail=str(e))
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.post("/batch-run")
async def api_matrix_batch_run(data: dict):
"""启动批量执行(通过 mc CLI)"""
try:
accounts = data.get("accounts", [])
blueprints = data.get("blueprints", [])
rounds = data.get("rounds", 5)
interval = data.get("interval", "30-60")
stagger = data.get("stagger", "15-30")
mix = data.get("mix", False)
if not accounts or not blueprints:
raise HTTPException(400, detail="accounts 和 blueprints 必填")
mc_path = str(AGENT_SYNC / "05_tools" / "07_matrix" / "mc")
cmd = [mc_path, "run", f"--accounts={','.join(accounts)}", f"--blueprints={','.join(blueprints)}", f"--rounds={rounds}", f"--interval={interval}", f"--stagger={stagger}"]
if mix:
cmd.append("--mix")
p = subprocess.Popen(cmd, cwd=str((AGENT_SYNC / "05_tools" / "07_matrix" / "scripts").resolve()))
return {"status": "started", "pid": p.pid, "cmd": " ".join(cmd)}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.get("/atom-ops")
def api_matrix_atom_ops():
"""获取原子操作列表"""
try:
mgr = _get_matrix_mgr()
return {"ops": mgr.list_atomic_ops()}
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.post("/task/run")
def api_matrix_task_run(data: dict = {}):
"""(已废弃) 改用 POST /api/ops/run {type, accounts, params}"""
task_type = data.get("type", "")
account = data.get("account", "")
if not task_type or not account:
return {"error": "type 和 account 必填"}
from services.command_bus import CommandBus
params = {"rounds": 1}
if data.get("url"): params["url"] = data["url"]
if data.get("direction"): params["direction"] = data["direction"]
if data.get("corpus"): params["corpus"] = data["corpus"]
result = CommandBus.dispatch(task_type, [account], params)
return {"deprecated": True, "status": result.get("status", "ok"), "commands": result.get("commands", [])}
_RECORDING_PID_FILE = AGENT_LOCAL / "runtime" / "recording.pid"
@router.post("/accounts/{account_id}/record")
def api_matrix_account_record(account_id: str):
"""启动交互式录制(自动路由:本地 / 远程)
检查账号归属机器,如果账号在远程机器上
则通过 federation 系统在远程启动录制。
操作方式:
- Playwright 浏览器自动打开目标平台
- 浏览器中操作
- 反引号 `·` 标记步骤(截图+状态指纹)
- Esc 结束录制并保存
"""
# 检查账号是否在远程机器
_target_machine = None
try:
from services.command_bus import CommandBus
machines = CommandBus.get_all_machines_status()
for m in machines:
for a in (m.get("accounts") or []):
if a.get("id") == account_id:
owner = a.get("owner_machine", "")
if owner and owner != HOSTNAME:
_target_machine = owner
break
except:
pass
# 远程路由
if _target_machine:
import urllib.request, json as _json
payload = _json.dumps({
"machine": _target_machine,
"account": account_id,
"platform": "xiaohongshu" if account_id.startswith("xhs_") else "douyin"
}).encode()
try:
req = urllib.request.Request(
f"http://localhost:9988/api/federation/record",
data=payload, headers={"Content-Type": "application/json"}, method="POST"
)
resp = urllib.request.urlopen(req, timeout=10)
result = _json.loads(resp.read())
return {"status": "ok", "account": account_id, "machine": _target_machine,
"message": f"远程录制已启动 ({_target_machine}),操作后按 `·` 标记,Esc 结束"}
except Exception as e:
return {"status": "error", "message": f"远程录制启动失败: {e}"}
# 本地执行
if _RECORDING_PID_FILE.exists():
try:
pid = int(_RECORDING_PID_FILE.read_text().strip())
import os, signal
os.kill(pid, 0)
return {"status": "ok", "message": f"已有录制进程在运行 (PID {pid}),请先结束再启动新录制"}
except (ProcessLookupError, ValueError):
_RECORDING_PID_FILE.unlink(missing_ok=True)
platform = "douyin"
if account_id.startswith("xhs_"):
platform = "xiaohongshu"
try:
import subprocess, os, signal
scripts_dir = str(AGENT_SYNC / "05_tools" / "07_matrix" / "scripts")
recorder_py = str(AGENT_SYNC / "05_tools" / "07_matrix" / "scripts" / "mc" / "recorder.py")
log_dir = AGENT_LOCAL / "runtime" / "commands"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"recorder_{account_id}_{int(time.time())}.log"
log_file = open(log_path, "w")
# 设置环境变量:PYTHONPATH + AGENT_SYNC/AGENT_LOCAL
rec_env = os.environ.copy()
rec_env["PYTHONPATH"] = f"{scripts_dir}:{rec_env.get('PYTHONPATH', '')}"
rec_env["AGENT_SYNC"] = str(AGENT_SYNC)
rec_env["AGENT_LOCAL"] = str(AGENT_LOCAL)
proc = subprocess.Popen(
[sys.executable, recorder_py, account_id, platform],
stdout=log_file, stderr=subprocess.STDOUT,
start_new_session=True,
env=rec_env,
)
log_file.write(f"recorder启动: account={account_id} platform={platform} pid={proc.pid}\nenv PYTHONPATH={scripts_dir}\n")
log_file.flush()
_RECORDING_PID_FILE.write_text(str(proc.pid))
return {"status": "ok", "account": account_id, "platform": platform,
"pid": proc.pid,
"message": f"录制进程已启动 (PID {proc.pid}),操作后按 `·` 标记步骤,Esc 结束保存"}
except Exception as e:
return {"status": "error", "message": f"录制启动失败: {e}"}
@router.post("/record/stop")
def api_matrix_record_stop():
"""停止当前录制进程"""
if not _RECORDING_PID_FILE.exists():
return {"status": "ok", "message": "没有正在运行的录制"}
try:
pid = int(_RECORDING_PID_FILE.read_text().strip())
import os, signal
os.kill(pid, signal.SIGTERM)
_RECORDING_PID_FILE.unlink(missing_ok=True)
return {"status": "ok", "message": f"已发送停止信号 (PID {pid})"}
except ProcessLookupError:
_RECORDING_PID_FILE.unlink(missing_ok=True)
return {"status": "ok", "message": "录制进程已结束"}
except Exception as e:
return {"status": "error", "message": f"停止失败: {e}"}
@router.get("/record/status")
def api_matrix_record_status():
"""查询录制进程状态"""
if not _RECORDING_PID_FILE.exists():
return {"running": False, "status": "idle", "pid": None}
try:
pid = int(_RECORDING_PID_FILE.read_text().strip())
import os, signal
os.kill(pid, 0)
return {"running": True, "pid": pid}
except (ProcessLookupError, ValueError):
_RECORDING_PID_FILE.unlink(missing_ok=True)
return {"running": False, "pid": None}
# ═══════════════════════════════════════════════════════════
# 录制 API — 别名兼容(编译产物用 /recordings/ 路径)
# ═══════════════════════════════════════════════════════════
@router.get("/recordings")
def api_matrix_recordings_list():
"""兼容别名: /recordings → /record/list"""
return api_matrix_record_list()
@router.get("/recordings/stats")
def api_matrix_recordings_stats():
"""录制统计(按账号、平台)"""
rec_dir = AGENT_LOCAL / "tools" / "matrix" / "recordings"
stats = {"total": 0, "by_account": {}, "by_platform": {}}
if not rec_dir.exists():
return {"stats": stats}
for f in rec_dir.glob("recording_*.json"):
try:
name = f.stem.replace("recording_", "", 1)
parts = name.split("_")
acct = parts[0] if parts else "?"
plat = "douyin" if "douyin" in name else "xiaohongshu"
stats["total"] += 1
stats["by_account"][acct] = stats["by_account"].get(acct, 0) + 1
stats["by_platform"][plat] = stats["by_platform"].get(plat, 0) + 1
except:
pass
return {"stats": stats}
@router.get("/recordings/status")
def api_matrix_recordings_status():
"""兼容别名: /recordings/status → /record/status"""
return api_matrix_record_status()
@router.post("/record/start")
def api_matrix_record_start(data: dict = {}):
"""兼容别名: /record/start → /accounts/{id}/record"""
account = data.get("account", "")
if not account:
return {"status": "error", "message": "account 必填"}
return api_matrix_account_record(account)
@router.post("/recordings/start")
def api_matrix_recordings_start(data: dict = {}):
"""兼容别名: /recordings/start → /accounts/{id}/record"""
account = data.get("account", "")
if not account:
return {"status": "error", "message": "account 必填"}
return api_matrix_account_record(account)
@router.post("/recordings/stop")
def api_matrix_recordings_stop():
"""兼容别名: /recordings/stop → /record/stop"""
return api_matrix_record_stop()
@router.get("/recordings/{name}")
def api_matrix_recordings_detail(name: str):
"""兼容别名: /recordings/{name} → /record/detail/{name}"""
return api_matrix_record_detail(name)
@router.post("/recordings/{name}/export")
def api_matrix_recordings_export(name: str):
"""兼容别名: /recordings/{name}/export → /record/export"""
return api_matrix_record_export({"name": name, "labels": []})
@router.delete("/recordings/{name}")
def api_matrix_recordings_delete(name: str):
"""兼容别名: DELETE /recordings/{name} → /record/delete"""
return api_matrix_record_delete({"name": name})
@router.get("/screenshot/{screenshot_path:path}")
def api_matrix_screenshot(screenshot_path: str):
"""提供录制截图访问(位于 agent-local,不在 web 根目录下)"""
from fastapi.responses import FileResponse
full_path = AGENT_LOCAL / "tools" / "matrix" / "recordings" / "screenshots" / screenshot_path
if not full_path.exists():
# 也可能是绝对路径
alt_path = Path(screenshot_path)
if alt_path.exists():
full_path = alt_path
else:
raise HTTPException(404, detail="截图不存在")
return FileResponse(str(full_path))
@router.get("/record/list")
def api_matrix_record_list():
"""获取录制列表(本机 + 联邦聚合)"""
from services.command_bus import _guardd_api, HOSTNAME as CB_HOSTNAME
seen = set()
recordings = []
# 1. 本机录制
record_dir = AGENT_LOCAL / "tools" / "matrix" / "recordings"
if record_dir.exists():
for f in sorted(record_dir.glob("recording_*.json"), reverse=True):
try:
raw = f.read_text(encoding="utf-8")
meta_end = raw.find('"steps"')
if meta_end > 0:
meta_json = raw[:meta_end].rstrip(',\n ') + '}'
meta = json.loads(meta_json).get("meta", {})
else:
meta = json.loads(raw).get("meta", {})
rec = {
"name": f.stem,
"account": meta.get("account_id", "?"),
"platform": meta.get("platform", "?"),
"machine": meta.get("machine", HOSTNAME),
"phone": meta.get("phone", ""),
"nickname": meta.get("nickname", ""),
"steps": meta.get("total_steps", 0),
"duration": meta.get("duration", 0),
"created": meta.get("created", ""),
"size_kb": round(f.stat().st_size / 1024, 1),
}
seen.add(f.stem)
recordings.append(rec)
except:
recordings.append({"name": f.stem, "error": "parse_error", "machine": HOSTNAME})
# 2. 远程机器录制(通过 guardd API)
try:
import yaml
oracle_path = AGENT_SYNC / "ORACLE.yaml"
if oracle_path.exists():
oracle_data = yaml.safe_load(oracle_path.read_text())
all_machines = list(oracle_data.get("machines", {}).keys())
else:
all_machines = ["5kechengdeAir", "7kecheng"]
for machine in all_machines:
if machine == HOSTNAME:
continue
try:
data = _guardd_api("GET", "/recordings", machine=machine)
if not data or not isinstance(data, dict):
continue
remote_recs = data.get("recordings", [])
for r in remote_recs:
rname = r.get("name", "").replace(".json", "")
if rname not in seen:
seen.add(rname)
r["name"] = rname
r.setdefault("machine", machine)
recordings.append(r)
except Exception:
pass
except Exception:
pass
# 按时间倒序
recordings.sort(key=lambda x: x.get("created", ""), reverse=True)
return {"recordings": recordings}
@router.get("/record/detail/{name}")
def api_matrix_record_detail(name: str):
"""获取录制详情(含分析结果)"""
record_dir = AGENT_LOCAL / "tools" / "matrix" / "recordings"
f = record_dir / f"{name}.json"
if not f.exists():
raise HTTPException(404, detail="录制包不存在")
try:
pkg = json.loads(f.read_text(encoding="utf-8"))
except:
raise HTTPException(500, detail="录制包解析失败")
# 运行分析器
analysis = {"actions": [], "steps_analyzed": 0}
try:
sys.path.insert(0, str(AGENT_SYNC / "05_tools" / "07_matrix" / "scripts"))
from mc.analyzer import analyze_recording
analysis = analyze_recording(pkg)
except Exception as e:
analysis = {"error": str(e)}
# 组装返回(截图路径转 API URL)
steps = pkg.get("steps", [])
for s in steps:
ss = s.get("screenshot", "")
if ss and os.path.exists(ss):
# 绝对路径 → /api/matrix/screenshot/ 相对URL
s["screenshot_url"] = f"/api/matrix/screenshot/{os.path.basename(ss)}"
else:
s["screenshot_url"] = ""
return {
"meta": pkg.get("meta", {}),
"steps": steps,
"analysis": analysis,
"total_steps": len(steps),
"steps_analyzed": analysis.get("steps_analyzed", 0),
"actions": analysis.get("actions", []),
"blueprint_suggestions": analysis.get("blueprint_suggestions", []),
}
@router.post("/record/delete/{name}")
def api_matrix_record_delete(name: str):
"""删除录制包"""
record_dir = AGENT_LOCAL / "tools" / "matrix" / "recordings"
f = record_dir / f"{name}.json"
if not f.exists():
# 尝试带 .json 后缀
f2 = record_dir / name
if f2.exists():
f = f2
else:
return {"status": "error", "message": "录制包不存在"}
try:
from mc.recorder import RecordingSession
RecordingSession.delete_recording(str(f))
return {"status": "ok", "message": f"已删除: {name}"}
except Exception as e:
return {"status": "error", "message": f"删除失败: {e}"}
@router.post("/record/analyze")
def api_matrix_record_analyze(data: dict):
"""分析录制内容 — 已合并到 /record/detail/{name}"""
name = data.get("name", "")
if not name:
return {"status": "error", "message": "name 必填"}
return api_matrix_record_detail(name)
@router.post("/record/export")
def api_matrix_record_export(data: dict):
"""导出录制为蓝图——根据你的标签生成 JSON 蓝图"""
name = data.get("name", "")
labels = data.get("labels", []) # [{"step":1, "op":"like"}, ...]
blueprint_name = data.get("blueprint_name", name.replace("recording_", ""))
if not name or not labels:
return {"status": "error", "message": "name 和 labels 必填"}
record_dir = AGENT_LOCAL / "tools" / "matrix" / "recordings"
f = record_dir / f"{name}.json"
if not f.exists():
raise HTTPException(404, detail="录制包不存在")
try:
pkg = json.loads(f.read_text(encoding="utf-8"))
except:
raise HTTPException(500, detail="录制包解析失败")
steps = pkg.get("steps", [])
bp_steps = []
seen_ops = set()
for label in labels:
step_num = label.get("step")
op = label.get("op", "wait")
# 去重:跳过连续重复的操作
if op == seen_ops and op in ("scroll_feed", "wait_watch", "wait"):
continue
seen_ops = op
step = {"step_id": len(bp_steps) + 1, "op": op, "args": label.get("args", {})}
bp_steps.append(step)
# 生成蓝图
blueprint = {
"id": blueprint_name,
"name": blueprint_name,
"platform": pkg.get("meta", {}).get("platform", "douyin"),
"version": "1.0",
"source": "recorded",
"steps": bp_steps,
}
# 保存到 blueprints 目录
blueprints_dir = AGENT_SYNC / "05_tools" / "07_matrix" / "blueprints"
bp_file = blueprints_dir / f"{blueprint_name}.json"
bp_file.write_text(json.dumps(blueprint, indent=2, ensure_ascii=False), encoding="utf-8")
return {"status": "ok", "blueprint": blueprint_name, "steps": len(bp_steps), "file": str(bp_file)}
@router.post("/record/delete")
def api_matrix_record_delete(data: dict):
"""删除录制"""
name = data.get("name", "")
if not name:
return {"status": "error", "message": "name 必填"}
record_dir = AGENT_LOCAL / "tools" / "matrix" / "recordings"
f = record_dir / f"{name}.json"
if f.exists():
f.unlink()
return {"status": "ok", "deleted": name}
@router.get("/blueprints")
def api_matrix_blueprints():
"""获取蓝图列表"""
try:
mgr = _get_matrix_mgr()
return mgr.list_blueprints()
except Exception as e:
raise HTTPException(500, detail=str(e))
@router.post("/blueprints")
async def api_matrix_create_blueprint(data: dict):
"""创建蓝图"""
return {"status": "ok", "note": "请使用独立管理页 /matrix-mgmt 编辑蓝图"}
@router.delete("/blueprints/{name}")
def api_matrix_delete_blueprint(name: str):
"""删除蓝图"""
return {"status": "ok"}
@router.post("/blueprints/{name}/execute")
async def api_matrix_execute_blueprint(name: str, data: dict):
"""执行蓝图"""
return {"status": "ok", "blueprint": name}
@router.get("/export")
def api_matrix_export():
"""获取导出数据列表"""
export_dir = AGENT_LOCAL / "tools" / "matrix" / "exports"
exports = []
if export_dir.exists():
for f in export_dir.glob("*.json"):
exports.append({"name": f.stem, "size": f.stat().st_size, "modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat()})
return {"exports": exports}
@router.post("/import")
async def api_matrix_import(data: dict):
"""导入账号"""
return {"status": "ok"}
@router.get("/system-info")
def api_matrix_system_info():
"""获取矩阵系统信息"""
return {"hostname": HOSTNAME, "status": "ok"}
@router.post("/blueprints/validate")
async def api_matrix_validate_blueprint(data: dict):
"""验证蓝图"""
return {"status": "ok", "valid": True}
@router.get("/backups")
def api_matrix_backups():
"""获取备份列表"""
backup_dir = AGENT_LOCAL / "tools" / "matrix" / "backups"
backups = []
if backup_dir.exists():
for f in sorted(backup_dir.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
backups.append({"name": f.stem, "size": f.stat().st_size, "modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat()})
return {"backups": backups}
@router.post("/backup")
async def api_matrix_create_backup():
"""创建备份"""
return {"status": "ok"}
@router.post("/restore")
async def api_matrix_restore_backup(data: dict):
"""恢复备份"""
return {"status": "ok"}
@router.get("/cross-machines")
def api_matrix_cross_machines():
"""获取跨机器账号状态"""
return {"machines": [{"hostname": HOSTNAME, "accounts": [], "status": "online"}]}
@router.get("/diag/accounts")
def api_matrix_diag_accounts():
"""诊断:追踪单个账号的完整数据链"""
result = {
"steps": {},
"error": None,
}
try:
# Step 1: ORACLE.yaml 中的账号声明
oracle_path = AGENT_SYNC / "ORACLE.yaml"
if oracle_path.exists():
import yaml
oracle = yaml.safe_load(oracle_path.read_text())
result["steps"]["oracle_accounts"] = len(oracle.get("accounts", []))
# Step 2: accounts.yaml 配置
mgr = _get_matrix_mgr()
accounts = mgr.list_accounts()
result["steps"]["yaml_accounts"] = len(accounts)
# 只看第一个远程账号的完整数据
sample = None
for a in accounts:
if a.get("owner_machine") and a.get("owner_machine") != HOSTNAME:
sample = {k: v for k, v in a.items() if not k.startswith("_")}
sample["_status"] = a.get("_status")
sample["owner_machine"] = a.get("owner_machine")
break
result["steps"]["sample_account"] = sample
# Step 3: 本地 profiles.json
pf_local = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
if pf_local.exists():
pf_data = json.loads(pf_local.read_text())
result["steps"]["local_profiles_count"] = len(pf_data)
if sample:
aid = sample.get("id", "")
pid = sample.get("identity_dir", "").replace("identities/", "")
result["steps"]["local_profiles_by_aid"] = pf_data.get(aid, "NOT FOUND")
result["steps"]["local_profiles_by_pid"] = pf_data.get(pid, "NOT FOUND")
else:
result["steps"]["local_profiles_count"] = "FILE NOT FOUND"
# Step 4: fleet_collector 缓存
fleet_dir = AGENT_LOCAL / "runtime" / "fleet_collector"
result["steps"]["fleet_cache"] = {}
if fleet_dir.exists():
for md in sorted(fleet_dir.iterdir()):
cache_entry = {"has_homepage": False, "has_profiles": False, "collected_at": ""}
hp_file = md / "homepage_info.json"
if hp_file.exists():
cache_entry["has_homepage"] = True
hp_data = json.loads(hp_file.read_text())
cache_entry["homepage_count"] = len(hp_data.get("results", []))
# 找 sample 账号
if sample:
pid = sample.get("identity_dir", "").replace("identities/", "")
for entry in hp_data.get("results", []):
if entry.get("identity_dir", "").replace("identities/", "") == pid:
cache_entry["homepage_match"] = entry
break
pf_file = md / "profiles.json"
if pf_file.exists():
cache_entry["has_profiles"] = True
pf_data = json.loads(pf_file.read_text())
cache_entry["profiles_count"] = len(pf_data)
if sample:
aid = sample.get("id", "")
cache_entry["profiles_by_aid"] = pf_data.get(aid, "NOT FOUND")
ca_file = md / "collected_at.txt"
if ca_file.exists():
cache_entry["collected_at"] = ca_file.read_text().strip()[:19]
result["steps"]["fleet_cache"][md.name] = cache_entry
else:
result["steps"]["fleet_cache"]["status"] = "NOT CREATED"
# Step 5: API 最终返回
final = {}
if sample:
aid = sample.get("id", "")
pid = sample.get("identity_dir", "").replace("identities/", "")
# 模拟 API 合并逻辑
pf_local_data = json.loads(pf_local.read_text()) if pf_local.exists() else {}
profile = pf_local_data.get(aid) or pf_local_data.get(pid) or {}
final["from_local_profiles"] = profile.get("nickname", "-") if profile else "NOT FOUND"
if not profile:
# fleet collector fallback
for md in sorted(fleet_dir.iterdir()):
hp_file = md / "homepage_info.json"
if hp_file.exists():
hp_data = json.loads(hp_file.read_text())
for entry in hp_data.get("results", []):
if entry.get("identity_dir", "").replace("identities/", "") == pid:
for pk in ("douyin", "xiaohongshu"):
pd = entry.get(pk, {}) or {}
if pd.get("nickname"):
final["from_fleet_homepage"] = pd["nickname"]
final["from_fleet_machine"] = md.name
break
if final.get("from_fleet_homepage"):
break
if final.get("from_fleet_homepage"):
break
result["steps"]["final_api_nickname"] = final.get("from_fleet_homepage") or final.get("from_local_profiles") or "EMPTY!"
result["steps"]["final_source"] = "fleet" if "from_fleet_homepage" in final else ("local" if "from_local_profiles" in final else "NONE")
except Exception as e:
import traceback
result["error"] = f"{e}\n{traceback.format_exc()}"
return result
@router.get("/corpus")
def api_matrix_corpus():
"""获取语料库概览(按平台+分类统计)"""
try:
from mc.corpus import CorpusManager
cm = CorpusManager()
cats = cm.list_categories()
# 同时获取三阶场景数据
scenes = cm.list_scenes()
return {"categories": cats, "scenes": scenes, "total_comments": sum(c.get("count", 0) for c in cats)}
except Exception as e:
return {"categories": [], "scenes": [], "total_comments": 0, "error": str(e)}
@router.get("/corpus/category")
def api_matrix_corpus_category(platform: str = "douyin", category: str = ""):
"""获取指定分类的详情(所有评论+模板)"""
if not category:
return {"error": "category 必填"}
try:
from mc.corpus import CorpusManager
cm = CorpusManager()
data = cm._load(platform)
cat = data.get("categories", {}).get(category, {})
return {
"platform": platform,
"category": category,
"label": cat.get("label", category),
"weight": cat.get("weight", 10),
"enabled": cat.get("enabled", True),
"accessible": cat.get("accessible", []),
"comments": cat.get("comments", []),
"templates": cat.get("templates", []),
}
except Exception as e:
return {"error": str(e)}
@router.post("/corpus/add")
async def api_matrix_add_corpus(data: dict):
"""添加一条评论"""
platform = data.get("platform", "douyin")
category = data.get("category", "")
text = data.get("text", "")
if not category or not text:
return {"status": "error", "error": "category 和 text 必填"}
try:
from mc.corpus import CorpusManager
cm = CorpusManager()
cm.add_comment(category, text, platform)
return {"status": "ok"}
except Exception as e:
return {"status": "error", "error": str(e)}
@router.post("/corpus/batch-add")
async def api_matrix_corpus_batch_add(data: dict):
"""批量添加语料(逐条处理)"""
platform = data.get("platform", "douyin")
category = data.get("category", "")
texts = data.get("texts", [])
if not category or not texts:
return {"status": "error", "error": "category 和 texts 必填"}
try:
from mc.corpus import CorpusManager
cm = CorpusManager()
added = 0
for t in texts:
if t.strip():
cm.add_comment(category, t.strip(), platform)
added += 1
return {"status": "ok", "added": added}
except Exception as e:
return {"status": "error", "error": str(e)}
@router.post("/corpus/delete")
async def api_matrix_corpus_delete(data: dict):
"""删除指定索引的评论"""
platform = data.get("platform", "douyin")
category = data.get("category", "")
index = data.get("index", -1)
if not category or index < 0:
return {"status": "error", "error": "category 和 index 必填"}
try:
from mc.corpus import CorpusManager
cm = CorpusManager()
cm.delete_comment(category, index, platform)
return {"status": "ok"}
except Exception as e:
return {"status": "error", "error": str(e)}
@router.post("/corpus/category/save")
async def api_matrix_corpus_category_save(data: dict):
"""保存分类设置(权重/启用/标签)"""
platform = data.get("platform", "douyin")
category = data.get("category", "")
updates = {k: v for k, v in data.items() if k in ("weight", "enabled", "label")}
if not category:
return {"status": "error", "error": "category 必填"}
try:
from mc.corpus import CorpusManager
cm = CorpusManager()
cm.update_category(platform, category, **updates)
return {"status": "ok"}
except Exception as e:
return {"status": "error", "error": str(e)}
@router.get("/corpus/scenes")
def api_matrix_corpus_scenes():
"""获取三阶场景语料"""
try:
from mc.corpus import CorpusManager
cm = CorpusManager()
scenes = cm.list_scenes()
# 按场景类型分组
groups = {"first_comment": [], "reply": [], "re_reply": []}
for s in scenes:
scene_id = s.get("id", "")
if scene_id in groups:
groups[scene_id].append(s)
return {"scenes": scenes, "groups": groups}
except Exception as e:
return {"scenes": [], "groups": {}, "error": str(e)}
@router.post("/accounts/{account_id}/login")
def api_matrix_account_login(account_id: str):
"""打开浏览器登录指定账号 — 小红书走全自动 SMS,抖音走 CommandBus"""
# 判断平台
platform = "douyin"
try:
from matrix_mgmt import MatrixManager
for a in MatrixManager().list_accounts():
if a["id"] == account_id:
p = a.get("platform", "")
if p == "xiaohongshu":
platform = "xiaohongshu"
break
except Exception:
pass
if platform == "xiaohongshu":
# 小红书:直接启动全自动 SMS 登录
import subprocess
scripts_dir = str(AGENT_SYNC / "05_tools" / "07_matrix" / "scripts")
login_script = scripts_dir + "/matrix_modules/account/xiaohongshu_login.py"
env = {**os.environ, "PYTHONPATH": scripts_dir, "AGENT_SYNC": str(AGENT_SYNC), "AGENT_LOCAL": str(AGENT_LOCAL)}
try:
proc = subprocess.Popen(
[sys.executable, login_script, account_id, "--force"],
env=env, start_new_session=True,
)
return {"status": "ok", "account": account_id, "message": f"小红书登录已启动 (PID {proc.pid}),浏览器自动处理验证码"}
except Exception as e:
return {"status": "error", "message": f"启动失败: {e}"}
else:
# 抖音:走现有 CommandBus
from services.command_bus import CommandBus
result = CommandBus.dispatch("login", [account_id], {})
if result.get("status") == "accepted":
return {"status": "ok", "account": account_id, "message": f"登录命令已发送"}
return {"status": "error", "message": result.get("message", "调度失败")}