feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,910 @@
"""
sms_proxy_api.py — 短信接收 + 代理管理 API
提供 Dashboard 接口,用于查看/测试短信API配置、代理状态。
版本: 1.0.0 | 更新: 2026-06-01
"""
import json, os, subprocess, sys
from pathlib import Path
from datetime import datetime
HOME = Path.home()
AGENT_SYNC = Path(os.environ.get("AGENT_SYNC", str(HOME / "workbuddy-agent-os" / "agent-sync")))
AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(HOME / "workbuddy-agent-os" / "agent-local")))
SCRIPTS_DIR = AGENT_SYNC / "05_tools" / "07_matrix" / "scripts"
IDENTITIES_ROOT = AGENT_LOCAL / "tools" / "matrix" / "identities"
sys.path.insert(0, str(SCRIPTS_DIR))
# 本机主机名
_HN_FILE = AGENT_LOCAL / "identity" / "cached_hostname"
if _HN_FILE.exists():
HOSTNAME = _HN_FILE.read_text().strip()
else:
import socket
HOSTNAME = socket.gethostname()
if not HOSTNAME:
HOSTNAME = "unknown"
SMS_YAML = SCRIPTS_DIR / "config" / "sms.yaml"
# 预导入账号管理
try:
from matrix_mgmt import MatrixManager
_matrix_mgmt_ok = True
except Exception as e:
_matrix_mgmt_ok = False
_matrix_mgmt_err = str(e)
class SMSManager:
"""短信API管理"""
@staticmethod
def get_config() -> dict:
"""读取当前SMS配置"""
try:
import yaml
if SMS_YAML.exists():
data = yaml.safe_load(SMS_YAML.read_text())
return data.get("sms", {})
except:
pass
return {}
@staticmethod
def update_config(phone: str = None, api_key: str = None, base_url: str = None) -> dict:
"""更新SMS配置"""
try:
import yaml
cfg = {}
if SMS_YAML.exists():
cfg = yaml.safe_load(SMS_YAML.read_text()) or {}
if "sms" not in cfg:
cfg["sms"] = {}
if phone: cfg["sms"]["phone"] = phone
if api_key: cfg["sms"]["api_key"] = api_key
if base_url: cfg["sms"]["base_url"] = base_url
SMS_YAML.write_text(yaml.dump(cfg, default_flow_style=False, allow_unicode=True))
return {"status": "ok", "config": cfg["sms"]}
except Exception as e:
return {"status": "error", "error": str(e)}
@staticmethod
def test_receive(phone: str, timeout: int = 15) -> dict:
"""测试指定手机号的短信接收"""
from urllib.request import Request, urlopen
import time
cfg = SMSManager.get_config()
api_key = cfg.get("api_key", "gtmsg2026")
base_url = cfg.get("base_url", "https://wx.tyhtak.com/api/biz/msg/messages")
url = f"{base_url}?api_key={api_key}&receiver_phone={phone}&page=1&per_page=10"
try:
req = Request(url, headers={"User-Agent": "curl/7.0"})
resp = urlopen(req, timeout=10)
data = json.loads(resp.read().decode())
total = data.get("data", {}).get("total", 0)
items = data.get("data", {}).get("items", [])
latest = []
for item in items[:5]:
import re
c = item.get("content", "")
code = re.search(r"验证码[::,,]\s*(\d{4,6})", c)
latest.append({
"id": item.get("id"),
"content": c[:80],
"code": code.group(1) if code else None,
"time": item.get("created_at", ""),
})
return {
"status": "ok",
"phone": phone,
"total": total,
"messages": latest,
}
except Exception as e:
return {"status": "error", "error": str(e)}
class ProxyManager:
"""代理管理"""
@staticmethod
def get_all_proxies() -> list[dict]:
"""获取所有账号的代理配置"""
try:
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
accounts = mgr.list_accounts()
except Exception as e:
return [{"error": str(e)}]
results = []
for a in accounts:
if not a.get("is_local"):
continue
aid = a["id"]
identity_dir = IDENTITIES_ROOT / aid
config_path = identity_dir / "config.yaml"
proxy = None
if config_path.exists():
try:
import yaml
data = yaml.safe_load(config_path.read_text())
proxy = data.get("identity", {}).get("proxy", None)
except:
pass
results.append({
"account": aid,
"platform": a.get("platform", ""),
"proxy": proxy,
"has_proxy": proxy is not None and str(proxy).strip() not in ("null", "None", ""),
"login_status": a.get("_status", "unknown"),
})
return results
@staticmethod
def set_proxy(account_id: str, proxy: str) -> dict:
"""设置账号代理"""
identity_dir = IDENTITIES_ROOT / account_id
config_path = identity_dir / "config.yaml"
if not config_path.exists():
return {"status": "error", "error": f"账号 {account_id} 身份目录不存在"}
try:
import yaml
data = yaml.safe_load(config_path.read_text()) or {}
if "identity" not in data:
data["identity"] = {}
data["identity"]["proxy"] = proxy if proxy.strip() else None
config_path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
return {"status": "ok", "account": account_id, "proxy": proxy}
except Exception as e:
return {"status": "error", "error": str(e)}
@staticmethod
def test_proxy(proxy: str) -> dict:
"""测试代理连通性"""
import urllib.request
try:
start = datetime.now()
if proxy and str(proxy).strip() not in ("null", "None", ""):
handler = urllib.request.ProxyHandler({"http": proxy, "https": proxy})
opener = urllib.request.build_opener(handler)
resp = opener.open("http://httpbin.org/ip", timeout=10)
data = json.loads(resp.read().decode())
elapsed = (datetime.now() - start).total_seconds()
# 提取代理类型
proxy_type = "HTTP"
if proxy.startswith("socks5://") or proxy.startswith("socks://"):
proxy_type = "SOCKS5"
elif proxy.startswith("socks4://"):
proxy_type = "SOCKS4"
elif proxy.startswith("https://"):
proxy_type = "HTTPS"
return {
"status": "ok",
"ip": data.get("origin", ""),
"type": proxy_type,
"elapsed": f"{elapsed:.1f}s",
"speed": "快" if elapsed < 0.5 else ("中" if elapsed < 2 else "慢"),
}
else:
# 直连测试
resp = urllib.request.urlopen("http://httpbin.org/ip", timeout=10)
data = json.loads(resp.read().decode())
elapsed = (datetime.now() - start).total_seconds()
return {"status": "ok", "ip": data.get("origin", ""), "elapsed": f"{elapsed:.1f}s", "direct": True}
except Exception as e:
return {"status": "error", "error": str(e)}
# ═══ FastAPI 路由 ═══
from fastapi import APIRouter, Request
router = APIRouter(prefix="/api/matrix", tags=["matrix"])
_sms = SMSManager()
_proxy = ProxyManager()
@router.get("/sms/config")
def api_sms_config():
return _sms.get_config()
@router.put("/sms/config")
def api_sms_update(data: dict):
return _sms.update_config(
phone=data.get("phone"),
api_key=data.get("api_key"),
base_url=data.get("base_url"),
)
@router.get("/sms/test/{phone}")
def api_sms_test(phone: str, timeout: int = 15):
return _sms.test_receive(phone, timeout)
@router.get("/proxies")
def api_proxies():
return {"proxies": _proxy.get_all_proxies()}
@router.put("/proxies/{account_id}")
def api_proxy_set(account_id: str, data: dict):
return _proxy.set_proxy(account_id, data.get("proxy", ""))
@router.post("/proxies/test")
def api_proxy_test(data: dict):
return _proxy.test_proxy(data.get("proxy", ""))
# ═══════════════════════════════════════════════
# SMS 账号管理 & 注册 API
# ═══════════════════════════════════════════════
def _account_status(a: dict) -> dict:
"""四维状态检测:identity / cookie / profile / registry"""
aid = a["id"]
# ① 有身份目录(使用 identity_dir 字段,兼容共享身份)
ident_name = a.get("identity_dir") or a.get("identity_hint") or aid
identity_dir = IDENTITIES_ROOT / ident_name.replace("identities/", "")
has_identity = identity_dir.exists()
# ② 有 Cookie(已登录)
has_cookie = False
if has_identity:
ck = identity_dir / "user_data" / "cookies.sqlite"
if ck.exists() and ck.stat().st_size > 100:
try:
import sqlite3
conn = sqlite3.connect(str(ck), timeout=1)
cnt = conn.execute("SELECT count(*) FROM moz_cookies WHERE name LIKE '%session%'").fetchone()[0]
conn.close()
has_cookie = cnt > 0
except:
pass
# ③ 有昵称(已采集 profile)
has_profile = False
profiles_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
if profiles_path.exists():
try:
profiles = json.loads(profiles_path.read_text())
p = profiles.get(aid, {})
nick = p.get("nickname", "")
if nick and nick != aid:
has_profile = True
except: pass
# 也检查 homepage_info.json(新采集系统)
if not has_profile:
hp_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "homepage_info.json"
if hp_path.exists():
try:
hp_data = json.loads(hp_path.read_text())
phone = a.get("phone", "")
for r in hp_data.get("results", []):
if r.get("phone") != phone:
continue
plat = a.get("platform", "")
pdata = r.get(plat, {})
if pdata and pdata.get("nickname") and pdata["nickname"] != aid:
has_profile = True
break
except: pass
# ④ 在 registry 中有记录(已同步到联邦)
has_registry = False
reg_path = AGENT_SYNC / "05_tools" / "07_matrix" / "accounts_registry.yaml"
if reg_path.exists():
try:
import yaml
reg = yaml.safe_load(reg_path.read_text()) or {"accounts": []}
for ra in reg.get("accounts", []):
if ra.get("id") == aid:
has_registry = True
break
except: pass
return {"has_identity": has_identity, "has_cookie": has_cookie,
"has_profile": has_profile, "has_registry": has_registry,
"busy": False, "busy_since": ""}
@router.get("/sms/accounts")
def api_sms_accounts():
"""返回所有本机账号(含手机号和昵称),供前端下拉选择"""
if not _matrix_mgmt_ok:
return {"error": f"账号模块未加载: {_matrix_mgmt_err}"}
try:
mgr = MatrixManager()
accounts = mgr.list_accounts()
profiles_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
profiles = {}
if profiles_path.exists():
try:
profiles = json.loads(profiles_path.read_text())
except: pass
# ══════════════════════════════════════════════════
# 读 homepage_info.json(新采集系统的数据,按 phone 匹配)
# ══════════════════════════════════════════════════
hp_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "homepage_info.json"
hp_map = {} # phone → {douyin: {}, xiaohongshu: {}}
if hp_path.exists():
try:
hp_data = json.loads(hp_path.read_text())
for r in hp_data.get("results", []):
phone = r.get("phone", "")
if phone:
hp_map[phone] = {
"douyin": r.get("douyin") or {},
"xiaohongshu": r.get("xiaohongshu") or {},
}
except: pass
result = []
for a in accounts:
aid = a["id"]
p = profiles.get(aid, {})
# 平台校验:profiles.json 中的 platform 必须和账号实际 platform 一致
acct_plat = a.get("platform", "")
profile_plat = p.get("platform", "")
if profile_plat and profile_plat != acct_plat:
p = {} # 平台不匹配,忽略此 profile
# 优先用 homepage_info.json 的数据(新采集系统)
phone = a.get("phone", "")
hp_entry = hp_map.get(phone, {})
hp_plat = hp_entry.get(acct_plat, {})
hp_nick = hp_plat.get("nickname", "")
# 过滤无效昵称
if hp_nick and ("登录后" in hp_nick or "问点点" in hp_nick or "推荐" in hp_nick):
hp_nick = ""
nick = hp_nick or p.get("nickname") or a.get("display_name") or aid
fans = hp_plat.get("fans") or p.get("fans", "?")
if fans == "" or fans is None:
fans = p.get("fans", "?")
posts = hp_plat.get("posts") or p.get("posts", "?")
if posts == "" or posts is None:
posts = p.get("posts", "?")
following = hp_plat.get("following") or p.get("following", "?")
if following == "" or following is None:
following = p.get("following", "?")
st = _account_status(a)
# 读取指纹信息
fingerprint = {}
ident_name = a.get("identity_dir") or a.get("identity_hint") or aid
cfg_path = IDENTITIES_ROOT / ident_name.replace("identities/", "") / "config.yaml"
if cfg_path.exists():
try:
import yaml
cfg = yaml.safe_load(cfg_path.read_text())
fingerprint = cfg.get("fingerprint_summary", {}) or {}
except: pass
# 检查账号忙碌状态
busy = st.get("busy", False)
busy_since = st.get("busy_since", "")
result.append({
"id": aid, "phone": phone,
"nickname": nick, "platform": a.get("platform", ""),
"is_local": a.get("is_local", False),
"owner_machine": a.get("owner_machine") or a.get("_source_machine_name", ""),
"has_identity": st["has_identity"],
"has_cookie": st["has_cookie"],
"has_profile": st["has_profile"],
"has_registry": st["has_registry"],
"fans": fans,
"posts": posts,
"following": following,
"likes": hp_plat.get("likes") or p.get("likes", "?"),
"identity_dir": a.get("identity_dir", ""),
"fingerprint": fingerprint,
"busy": busy,
"busy_since": busy_since,
})
return {"accounts": result}
except Exception as e:
return {"error": str(e)}
@router.get("/sms/test/{phone_or_account}")
def api_sms_test_ext(phone_or_account: str, timeout: int = 15):
"""查短信(支持手机号或账号ID)"""
if phone_or_account and len(phone_or_account) < 11 and _matrix_mgmt_ok:
try:
mgr = MatrixManager()
for a in mgr.list_accounts():
if a["id"] == phone_or_account and a.get("phone"):
return _sms.test_receive(a["phone"], timeout)
except: pass
return _sms.test_receive(phone_or_account, timeout)
@router.post("/accounts/register")
async def api_account_register(request: Request):
"""注册新账号:创建身份目录→写入accounts.yaml→打开浏览器登录"""
data = await request.json() if request.headers.get("content-type") == "application/json" else {}
plat = data.get("platform", "douyin")
phone = data.get("phone", "").strip()
nick = data.get("nickname", "").strip()
if plat not in ("douyin", "xiaohongshu"):
return {"status": "error", "error": "平台只支持 douyin / xiaohongshu"}
if not phone:
return {"status": "error", "error": "手机号不能为空"}
# 生成 account_id
prefix = {"douyin": "douyin_", "xiaohongshu": "xhs_"}.get(plat, "acct_")
try:
mgr = MatrixManager()
existing = [a["id"] for a in mgr.list_accounts() if a["id"].startswith(prefix)]
nums = [int(a.replace(prefix, "")) for a in existing if a.replace(prefix, "").isdigit()]
n = max(nums) + 1 if nums else 1
except: n = 1
acct_id = f"{prefix}{n}"
try:
# 1. 判断身份目录:同手机号是否已有 identity
import yaml
LOCAL_ACCT_YAML = AGENT_LOCAL / "tools" / "matrix" / "config" / "accounts.yaml"
existing_accts = []
if LOCAL_ACCT_YAML.exists():
raw = yaml.safe_load(LOCAL_ACCT_YAML.read_text()) or {}
existing_accts = raw.get("accounts", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
# 查找同手机号的现有 identity_dir
shared_identity_name = None
for ea in existing_accts:
if isinstance(ea, dict) and ea.get("phone") == phone:
ed = ea.get("identity_dir") or ea.get("identity_hint") or ""
if ed:
shared_identity_name = ed.replace("identities/", "")
break
# 冲突检测:同手机号+同平台
conflict = [ea for ea in existing_accts
if isinstance(ea, dict) and ea.get("phone") == phone
and ea.get("platform") == plat]
if conflict:
return {"status": "error", "error": f"手机号 {phone} 已有 {plat} 账号: {conflict[0]['id']}。如需重新注册,请先删除旧账号。"}
if shared_identity_name:
# 复用已有身份目录
identity_name = shared_identity_name
identity_dir = IDENTITIES_ROOT / identity_name
# 确保目录存在
identity_dir.mkdir(parents=True, exist_ok=True)
(identity_dir / "user_data").mkdir(exist_ok=True)
else:
# 新建身份目录,以 phone_ 开头
identity_name = f"phone_{phone}"
identity_dir = IDENTITIES_ROOT / identity_name
identity_dir.mkdir(parents=True, exist_ok=True)
(identity_dir / "user_data").mkdir(exist_ok=True)
config = {
"fingerprint_summary": {"platform": "Win32", "screen": "1920x1080"},
"identity": {"name": identity_name, "platform": plat, "created_at": datetime.now().isoformat()},
"window": [802, 783],
}
(identity_dir / "config.yaml").write_text(yaml.dump(config, allow_unicode=True, default_flow_style=False))
# 2. 写入 accounts.yaml
accts = [a for a in existing_accts if isinstance(a, dict) and a.get("id") != acct_id]
accts.append({
"id": acct_id, "platform": plat, "phone": phone,
"display_name": nick or acct_id, "enabled": True,
"browser_type": "camoufox", "profile_dir": identity_name,
"identity_dir": identity_name,
})
LOCAL_ACCT_YAML.write_text(yaml.dump({"accounts": accts}, allow_unicode=True, default_flow_style=False))
# 3. 同步到 accounts_registry.yaml
registry_path = AGENT_SYNC / "05_tools" / "07_matrix" / "accounts_registry.yaml"
try:
reg = yaml.safe_load(registry_path.read_text()) or {"accounts": []}
reg["accounts"] = [a for a in reg["accounts"] if a.get("id") != acct_id]
reg["accounts"].append({
"id": acct_id, "platform": plat, "phone": phone,
"display_name": nick or acct_id, "assigned_machine": HOSTNAME or "unknown",
})
registry_path.write_text(yaml.dump(reg, allow_unicode=True, default_flow_style=False))
except: pass
# 4. 启动浏览器登录
agent_python = str(HOME / ".workbuddy" / "binaries" / "python" / "envs" / "matrix" / "bin" / "python3")
cmd = [agent_python, "-m", "mc", "account", "login", acct_id]
import subprocess
subprocess.Popen(cmd, cwd=str(SCRIPTS_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"status": "ok", "account_id": acct_id, "message": f"账号 {acct_id} 已创建,浏览器已打开请登录",
"next": "登录成功后可以在 Dashboard 采集昵称"}
except Exception as e:
return {"status": "error", "error": str(e)}
@router.post("/accounts/{account_id}/login")
def api_account_login(account_id: str):
"""智能登录: 前置检测 + 自动平台路由 + 全自动登录"""
agent_python = str(HOME / ".workbuddy" / "binaries" / "python" / "envs" / "agent-os" / "bin" / "python3")
import subprocess
try:
# 使用 smart-login: 自动检测平台 + 前置检测 + 全自动
p = subprocess.Popen(
[agent_python, "-m", "mc", "smart-login", account_id],
cwd=str(SCRIPTS_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"status": "ok", "pid": p.pid, "message": f"智能登录已启动: {account_id}"}
except Exception as e:
return {"status": "error", "error": str(e)}
@router.post("/accounts/{account_id}/collect-profile")
def api_collect_profile(account_id: str):
"""采集账号昵称信息(根据平台选择正确蓝图)"""
# 从账号注册表获取平台
platform = "douyin"
try:
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
for a in mgr.list_accounts():
if a["id"] == account_id:
p = a.get("platform", "douyin")
platform = p if p in ("douyin", "xiaohongshu") else "douyin"
break
except: pass
# 根据平台选择采集蓝图
bp_name = "xiaohongshu_read_profile" if platform == "xiaohongshu" else "douyin_read_profile"
agent_python = str(HOME / ".workbuddy" / "binaries" / "python" / "envs" / "agent-os" / "bin" / "python3")
import subprocess
try:
p = subprocess.Popen(
[agent_python, "-m", "mc", "run", "--accounts", account_id,
"--blueprints", bp_name, "--rounds", "1"],
cwd=str(SCRIPTS_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"status": "ok", "pid": p.pid, "message": f"正在采集 {account_id} 的昵称信息({bp_name})"}
except Exception as e:
return {"status": "error", "error": str(e)}
# ═══════════════════════════════════════════════
# 定时任务 & 人设 API
# ═══════════════════════════════════════════════
SCHEDULE_YAML = SCRIPTS_DIR / "config" / "schedule.yaml"
@router.get("/schedules")
def api_schedules():
if not SCHEDULE_YAML.exists():
return {"schedules": []}
try:
import yaml
data = yaml.safe_load(SCHEDULE_YAML.read_text())
scheds = data.get("schedules", {})
result = []
for sid, cfg in scheds.items():
cfg["id"] = sid
result.append(cfg)
return {"schedules": result}
except Exception as e:
return {"error": str(e)}
@router.get("/schedules")
def api_schedules():
"""返回所有定时任务"""
from mc.scheduler import load_all_schedules
return {"schedules": load_all_schedules()}
@router.post("/schedules/toggle")
def api_schedule_toggle(data: dict):
"""启用/禁用定时任务"""
sid = (data or {}).get("id", "")
enabled = (data or {}).get("enabled", True)
from mc.scheduler import load_all_schedules, save_all_schedules
all_s = load_all_schedules()
if sid in all_s:
all_s[sid]["enabled"] = enabled
save_all_schedules(all_s)
return {"status": "ok"}
return {"status": "error", "error": f"未找到任务 {sid}"}
@router.post("/schedules/delete")
def api_schedule_delete(data: dict):
"""删除定时任务"""
sid = (data or {}).get("id", "")
from mc.scheduler import load_all_schedules, save_all_schedules
all_s = load_all_schedules()
if sid in all_s:
del all_s[sid]
save_all_schedules(all_s)
return {"status": "ok"}
return {"status": "error", "error": f"未找到任务 {sid}"}
@router.get("/schedules/history")
def api_schedule_history():
"""返回最近20条执行历史"""
from mc.scheduler import read_history
return {"history": read_history(limit=20)}
@router.get("/corpus/personas")
def api_corpus_personas():
"""列出语料库中的身份(v2格式)"""
from mc.corpus import CorpusManager
cm = CorpusManager()
return {"personas": cm.list_personas()}
@router.get("/corpus/scenes")
def api_corpus_scenes(persona: str = ""):
"""列出语料库中的场景(v2格式)"""
from mc.corpus import CorpusManager
cm = CorpusManager()
return {"scenes": cm.list_scenes(persona) if not persona else cm.list_scenes(persona)}
@router.get("/personas")
def api_personas():
pf = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
if not pf.exists():
return {"personas": {}}
try:
data = json.loads(pf.read_text())
personas = {}
for aid, info in data.items():
if "persona" in info:
personas[aid] = info["persona"]
return {"personas": personas}
except Exception as e:
return {"error": str(e)}
# ═══════════════════════════════════════════════
# 任务执行 API
# ═══════════════════════════════════════════════
@router.post("/task/run")
async def api_task_run(request: Request):
"""从 Dashboard 执行智能任务"""
data = await request.json() if request.headers.get("content-type") == "application/json" else {}
task_type = data.get("type", "comment")
url = data.get("url", "")
keyword = data.get("keyword", "")
direction = data.get("direction", "")
account = data.get("account", "")
comment_text = data.get("comment", "")
from mc.task import Task, run_task
task = Task(type=task_type, url=url, keyword=keyword,
direction=direction, account=account, comment_text=comment_text)
errors = task.validate()
if errors:
return {"status": "error", "errors": errors}
task.auto_fill()
if not task.account:
return {"status": "error", "errors": [f"没有可用的{task.platform}账号"]}
result = await run_task(task)
return {"status": "ok" if result.get("status") != "error" else "error",
"success": result.get("success", 0),
"failed": result.get("failed", 0),
"total": result.get("total_steps", 0),
"duration": result.get("duration", 0),
"task_summary": task.summary(),
"detail": result.get("error", "")}
# [删除账号使用 app.py 中已有的 DELETE /api/matrix/accounts/{id}]
# 前端调用时加 ?delete_identity=true
# 但 app.py 的 delete 有 bug(不写回 accounts.yaml),前端需额外调用此 cleanup
@router.post("/accounts/{account_id}/cleanup")
def api_account_cleanup(account_id: str):
"""删除后被 app.py 的 bug 遗漏时,补清理 accounts.yaml 和 registry"""
try:
import yaml
changed = False
deleted_identity = False
# 清理 accounts.yaml
LOCAL_ACCT = AGENT_LOCAL / "tools" / "matrix" / "config" / "accounts.yaml"
if LOCAL_ACCT.exists():
raw = yaml.safe_load(LOCAL_ACCT.read_text()) or {}
accts = raw.get("accounts", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
# 先记录这个账号的 identity_dir
deleted_identity_dir = None
for a in accts:
if isinstance(a, dict) and a.get("id") == account_id:
deleted_identity_dir = a.get("identity_dir") or a.get("identity_hint") or ""
break
# 从配置中移除
before = len(accts)
accts = [a for a in accts if isinstance(a, dict) and a.get("id") != account_id]
if len(accts) != before:
data = {"accounts": accts}
LOCAL_ACCT.write_text(yaml.dump(data, allow_unicode=True, default_flow_style=False))
changed = True
# 删除身份目录(仅当没有其他账号使用同一 identity_dir)
if deleted_identity_dir:
still_in_use = any(
isinstance(a, dict) and (a.get("identity_dir") or a.get("identity_hint") or "") == deleted_identity_dir
for a in accts
)
if not still_in_use:
import shutil
ident_path = IDENTITIES_ROOT / deleted_identity_dir.replace("identities/", "")
if ident_path.exists():
shutil.rmtree(ident_path)
deleted_identity = True
# 清理 registry
reg_path = AGENT_SYNC / "05_tools" / "07_matrix" / "accounts_registry.yaml"
if reg_path.exists():
reg = yaml.safe_load(reg_path.read_text()) or {"accounts": []}
before = len(reg["accounts"])
reg["accounts"] = [a for a in reg["accounts"] if a.get("id") != account_id]
if len(reg["accounts"]) != before:
reg_path.write_text(yaml.dump(reg, allow_unicode=True, default_flow_style=False))
changed = True
return {"status": "ok", "cleaned": changed, "identity_deleted": deleted_identity}
except Exception as e:
return {"status": "error", "error": str(e)}
# ═══════════════════════════════════════════════
# 清除登录状态 API
# ═══════════════════════════════════════════════
@router.post("/accounts/{account_id}/clear-cookies")
def api_clear_cookies(account_id: str):
"""清除指定账号的登录状态(只清该平台 cookie,不影响同身份的其他平台)"""
try:
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
acct = None
for a in mgr.list_accounts():
if a["id"] == account_id:
acct = a
break
if not acct:
return {"status": "error", "error": "账号不存在"}
identity_dir = acct.get("identity_dir", account_id).replace("identities/", "")
plat = acct.get("platform", "douyin")
# 平台对应的域名模式
domain_pattern = "%douyin%" if plat == "douyin" else "%xiaohongshu%"
cookie_path = IDENTITIES_ROOT / identity_dir / "user_data" / "cookies.sqlite"
if not cookie_path.exists():
return {"status": "ok", "message": f"{plat} 无 cookie 可清除"}
try:
import sqlite3
conn = sqlite3.connect(str(cookie_path), timeout=3)
cur = conn.execute("DELETE FROM moz_cookies WHERE host LIKE ?", (domain_pattern,))
deleted = cur.rowcount
conn.commit()
conn.close()
return {"status": "ok", "message": f"已清除 {plat} 的 {deleted} 个 cookie", "deleted": deleted}
except Exception as e:
return {"status": "error", "error": f"清除失败: {e}"}
except Exception as e:
return {"status": "error", "error": str(e)}
# ═══════════════════════════════════════════════
# 录制管理 API
# ═══════════════════════════════════════════════
RECORDINGS_DIR = AGENT_LOCAL / "tools" / "matrix" / "recordings"
_recording_process = None
@router.get("/recordings/stats")
def api_recording_stats():
"""按账号统计录制包数量"""
try:
from mc.recorder import RecordingSession
recordings = RecordingSession.list_recordings()
stats = {}
for r in recordings:
acct = r.get("account", "unknown")
stats[acct] = stats.get(acct, 0) + 1
return {"stats": stats}
except Exception as e:
return {"stats": {}, "error": str(e)}
@router.post("/recordings/start")
def api_recording_start(data: dict = None):
"""启动录制"""
global _recording_process
account = (data or {}).get("account", "douyin_test")
platform = "douyin"
try:
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
for a in mgr.list_accounts():
if a["id"] == account:
p = a.get("platform", "douyin")
platform = {"douyin": "douyin", "xiaohongshu": "xiaohongshu"}.get(p, "douyin")
break
except: pass
agent_python = str(HOME / ".workbuddy" / "binaries" / "python" / "envs" / "matrix" / "bin" / "python3")
cmd = [agent_python, "-m", "mc.recorder", account, platform]
import subprocess
try:
p = subprocess.Popen(cmd, cwd=str(SCRIPTS_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
import time; time.sleep(2)
if p.poll() is not None:
stderr = p.stderr.read().decode()[:200] if p.stderr else ""
return {"status": "error", "error": f"录制进程退出 (code={p.returncode})", "detail": stderr}
_recording_process = {"pid": p.pid, "account": account}
return {"status": "ok", "pid": p.pid, "account": account, "message": "录制已启动"}
except Exception as e:
return {"status": "error", "error": str(e)}
@router.post("/recordings/stop")
def api_recording_stop():
"""停止录制"""
global _recording_process
if _recording_process:
import os, signal
try:
os.kill(_recording_process["pid"], signal.SIGTERM)
import time; time.sleep(1)
except: pass
_recording_process = None
return {"status": "ok", "message": "录制已停止"}
return {"status": "ok", "message": "无运行中的录制"}
@router.get("/recordings/status")
def api_recording_status():
"""查看录制状态"""
global _recording_process
if _recording_process:
import os
try:
os.kill(_recording_process["pid"], 0)
return {"status": "running", "pid": _recording_process["pid"], "account": _recording_process["account"]}
except: pass
_recording_process = None
return {"status": "idle"}
@router.get("/recordings")
def api_recordings_list():
"""列出录制包"""
from mc.recorder import RecordingSession
return {"recordings": RecordingSession.list_recordings()}
@router.get("/recordings/{name}")
def api_recording_analyze(name: str):
"""分析录制包"""
from mc.analyzer import analyze_recording_file
p = RECORDINGS_DIR / name
if p.exists():
return analyze_recording_file(str(p))
return {"error": f"录制包不存在: {name}"}
@router.post("/recordings/{name}/export")
def api_recording_export(name: str):
"""导出录制包"""
from mc.exporter import export_recording
p = RECORDINGS_DIR / name
if p.exists():
return export_recording(str(p))
return {"error": f"录制包不存在: {name}"}
@router.delete("/recordings/{name}")
def api_recording_delete(name: str):
"""删除录制包"""
from mc.recorder import RecordingSession
ok = RecordingSession.delete_recording(name)
return {"deleted": ok}