移植自 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 带网关原话 / 正常返回)。
1572 lines
75 KiB
Python
1572 lines
75 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
matrix_mgmt.py — Matrix 账号管理系统后端 (v1.0)
|
||
提供账号CRUD、蓝图编排、原子操作查询、导入导出等管理能力,
|
||
供 Dashboard 插件和前端调用。
|
||
|
||
用法:
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
mgr.list_accounts()
|
||
mgr.create_account({...})
|
||
"""
|
||
import json, os, yaml, shutil, tempfile, zipfile, sqlite3, time, subprocess, glob
|
||
from pathlib import Path
|
||
from typing import Optional, List, Dict, Any
|
||
from datetime import datetime
|
||
|
||
HOME = Path.home()
|
||
_AGENT_SYNC_ENV = os.environ.get("AGENT_SYNC", "")
|
||
_AGENT_LOCAL_ENV = os.environ.get("AGENT_LOCAL", "")
|
||
AGENT_SYNC = Path(_AGENT_SYNC_ENV) if _AGENT_SYNC_ENV else HOME / "workbuddy-agent-os" / "agent-sync"
|
||
AGENT_LOCAL = Path(_AGENT_LOCAL_ENV) if _AGENT_LOCAL_ENV else HOME / "workbuddy-agent-os" / "agent-local"
|
||
|
||
MATRIX_CODE = AGENT_SYNC / "05_tools" / "07_matrix"
|
||
MATRIX_SCRIPTS = MATRIX_CODE / "scripts"
|
||
MATRIX_LOCAL = AGENT_LOCAL / "tools" / "matrix"
|
||
MATRIX_IDENTITIES = MATRIX_LOCAL / "identities"
|
||
MATRIX_ACCOUNTS_CFG = MATRIX_LOCAL / "config" / "accounts.yaml"
|
||
MATRIX_BACKUPS = MATRIX_LOCAL / "backups"
|
||
MATRIX_BLUEPRINTS = MATRIX_CODE / "blueprints"
|
||
MATRIX_CONFIG_TEMPLATE = MATRIX_CODE / "config_template"
|
||
MATRIX_ACCOUNT_WORKSPACES = MATRIX_LOCAL / "accounts"
|
||
|
||
PYTHON = str(HOME / ".workbuddy" / "binaries" / "python" / "envs" / "agent-os" / "bin" / "python3")
|
||
MATRIX_CLI = str(MATRIX_SCRIPTS / "matrix.py")
|
||
|
||
|
||
class MatrixManager:
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 账号管理
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def list_accounts(self) -> list[dict]:
|
||
"""列出所有已配置账号及实时状态"""
|
||
accounts = self._read_accounts_yaml()
|
||
result = []
|
||
for acct in accounts:
|
||
if not acct.get("enabled", False):
|
||
acct["_status"] = "disabled"
|
||
else:
|
||
acct["_status"] = self._check_login_status(acct)
|
||
acct["_identity_dir_exists"] = self._identity_exists(acct)
|
||
result.append(acct)
|
||
return result
|
||
|
||
def get_account(self, account_id: str) -> Optional[dict]:
|
||
"""获取单个账号详情"""
|
||
accounts = self._read_accounts_yaml()
|
||
for acct in accounts:
|
||
if acct["id"] == account_id:
|
||
acct["_status"] = self._check_login_status(acct)
|
||
acct["_identity_dir_exists"] = self._identity_exists(acct)
|
||
acct["_cookie_backups"] = self._count_backups(acct)
|
||
return acct
|
||
return None
|
||
|
||
def create_account(self, data: dict) -> dict:
|
||
"""创建新账号(添加到 accounts.yaml)"""
|
||
required = ["id", "platform"]
|
||
for field in required:
|
||
if field not in data:
|
||
raise ValueError(f"缺少必填字段: {field}")
|
||
|
||
accounts = self._read_accounts_yaml()
|
||
# 检查重名
|
||
if any(a["id"] == data["id"] for a in accounts):
|
||
raise ValueError(f"账号 {data['id']} 已存在")
|
||
|
||
# 同手机号已有身份?复用,不新建
|
||
phone = data.get("phone", "")
|
||
existing_identity = ""
|
||
if phone:
|
||
for a in accounts:
|
||
if a.get("phone") == phone and a.get("identity_dir"):
|
||
existing_identity = a["identity_dir"]
|
||
break
|
||
if not existing_identity:
|
||
# 也查 override
|
||
hostname, override = self._read_override()
|
||
for a in override:
|
||
if a.get("phone") == phone and a.get("identity_dir"):
|
||
existing_identity = a["identity_dir"]
|
||
break
|
||
|
||
new_identity = data.get("identity_dir", existing_identity or f"identities/{data['id']}")
|
||
new_acct = {
|
||
"id": data["id"],
|
||
"platform": data["platform"],
|
||
"phone": phone,
|
||
"identity_dir": new_identity,
|
||
"window": [702, 783],
|
||
"window_position": data.get("window_position", [0, 0]),
|
||
"proxy": data.get("proxy", None),
|
||
"enabled": data.get("enabled", True),
|
||
"notes": data.get("notes", ""),
|
||
"tags": data.get("tags", []),
|
||
}
|
||
accounts.append(new_acct)
|
||
self._write_accounts_yaml(accounts)
|
||
|
||
# ── WPRA v2.0: 同步写入 machines/{uid}/accounts.yaml ──
|
||
self._write_self_accounts()
|
||
|
||
# 创建身份目录
|
||
identity_dir_name = new_acct["identity_dir"].replace("identities/", "")
|
||
identity_path = MATRIX_IDENTITIES / identity_dir_name
|
||
identity_path.mkdir(parents=True, exist_ok=True)
|
||
# 只有当 config.yaml 不存在时才创建(避免覆盖已有的 identity/指纹配置)
|
||
config_file = identity_path / "config.yaml"
|
||
if not config_file.exists():
|
||
config_file.write_text(
|
||
yaml.dump({
|
||
"window": [702, 783],
|
||
"screen": {"width": 702, "height": 783},
|
||
"identity": {
|
||
"name": identity_dir_name,
|
||
"platform": data.get("platform", "douyin"),
|
||
"created_at": datetime.now().isoformat(),
|
||
"notes": data.get("notes", ""),
|
||
"tags": data.get("tags", []),
|
||
},
|
||
}, default_flow_style=False),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
return {"status": "ok", "account": new_acct}
|
||
|
||
def update_account(self, account_id: str, data: dict) -> dict:
|
||
"""更新账号配置"""
|
||
# 写入 Registry (notes 等元数据)
|
||
reg = self._read_registry()
|
||
reg_found = None
|
||
for acct in reg:
|
||
if acct["id"] == account_id:
|
||
reg_found = acct
|
||
break
|
||
if not reg_found:
|
||
# fallback: 旧版 accounts.yaml
|
||
accounts = self._read_accounts_yaml()
|
||
found = None
|
||
for acct in accounts:
|
||
if acct["id"] == account_id:
|
||
found = acct
|
||
break
|
||
if not found:
|
||
raise ValueError(f"账号 {account_id} 不存在")
|
||
for key in ["phone", "window", "window_position", "proxy", "enabled", "notes", "tags", "platform"]:
|
||
if key in data:
|
||
found[key] = data[key]
|
||
self._write_accounts_yaml(accounts)
|
||
else:
|
||
for key in ["notes", "tags", "window", "window_position", "platform"]:
|
||
if key in data:
|
||
reg_found[key] = data[key]
|
||
self.REGISTRY_PATH.write_text(
|
||
yaml.dump({"version": "1.0", "accounts": reg}, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||
)
|
||
|
||
# 写入 Override (phone, proxy, enabled 等敏感字段)
|
||
hostname, override = self._read_override()
|
||
ovr_found = None
|
||
for acct in override:
|
||
if acct["id"] == account_id:
|
||
ovr_found = acct
|
||
break
|
||
if ovr_found is None:
|
||
override.append({"id": account_id})
|
||
ovr_found = override[-1]
|
||
|
||
for key in ["phone", "proxy", "enabled"]:
|
||
if key in data:
|
||
ovr_found[key] = data[key]
|
||
|
||
ovr_data = {"version": "1.0", "hostname": hostname or self._local_hostname(), "accounts": override}
|
||
self.OVERRIDE_PATH.write_text(
|
||
yaml.dump(ovr_data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||
)
|
||
|
||
return {"status": "ok", "account_id": account_id}
|
||
|
||
def delete_account(self, account_id: str, delete_identity: bool = False) -> dict:
|
||
"""删除账号配置,可选同时删除身份目录"""
|
||
accounts = self._read_accounts_yaml()
|
||
found = None
|
||
for acct in accounts:
|
||
if acct["id"] == account_id:
|
||
found = acct
|
||
break
|
||
if not found:
|
||
raise ValueError(f"账号 {account_id} 不存在")
|
||
|
||
# 从配置中移除
|
||
accounts.remove(found)
|
||
self._write_accounts_yaml(accounts)
|
||
|
||
# ── WPRA v2.0: 同步写入 machines/{uid}/accounts.yaml ──
|
||
self._write_self_accounts()
|
||
|
||
# 可选删除身份目录(谨慎操作)
|
||
result = {"status": "ok", "removed_from_config": True, "identity_deleted": False}
|
||
if delete_identity:
|
||
identity_dir_name = found.get("identity_dir", "").replace("identities/", "")
|
||
if identity_dir_name:
|
||
identity_path = MATRIX_IDENTITIES / identity_dir_name
|
||
if identity_path.exists():
|
||
shutil.rmtree(identity_path)
|
||
result["identity_deleted"] = True
|
||
|
||
return result
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 身份管理(v5.2 新增)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def get_identities(self) -> list[dict]:
|
||
"""返回本机所有身份的聚合视图
|
||
|
||
一个身份 = 一个 identity_dir,可包含多个平台账号
|
||
按 身份目录 分组,展示手机号/平台账号/登录状态
|
||
"""
|
||
accounts = self.list_accounts()
|
||
# 只聚合本机账号
|
||
local_accounts = [a for a in accounts if a.get("is_local")]
|
||
|
||
# 按 identity_dir 分组
|
||
groups = {}
|
||
for a in local_accounts:
|
||
aid = a["id"]
|
||
identity_dir = None
|
||
|
||
# 从本地 accounts.yaml 读 identity_dir
|
||
legacy_accounts = self._read_accounts_yaml()
|
||
for la in legacy_accounts:
|
||
if la["id"] == aid:
|
||
id_dir = la.get("identity_dir", "")
|
||
if id_dir:
|
||
identity_dir = id_dir.replace("identities/", "")
|
||
break
|
||
|
||
if not identity_dir:
|
||
identity_dir = a.get("identity_hint", aid)
|
||
|
||
if identity_dir not in groups:
|
||
groups[identity_dir] = {
|
||
"identity_dir": identity_dir,
|
||
"phone": "",
|
||
"accounts": [],
|
||
"has_cookie": False,
|
||
}
|
||
|
||
phone = a.get("phone", a.get("phone_mask", ""))
|
||
if phone and not groups[identity_dir]["phone"]:
|
||
groups[identity_dir]["phone"] = phone
|
||
|
||
# 检查身份目录是否存在和有 Cookie
|
||
id_path = MATRIX_IDENTITIES / identity_dir
|
||
cookie_file = id_path / "user_data" / "cookies.sqlite"
|
||
has_cookie = cookie_file.exists() and cookie_file.stat().st_size > 0
|
||
if has_cookie:
|
||
groups[identity_dir]["has_cookie"] = True
|
||
|
||
groups[identity_dir]["accounts"].append({
|
||
"id": aid,
|
||
"platform": a.get("platform", ""),
|
||
"status": a.get("_status", "unknown"),
|
||
"phone": a.get("phone", a.get("phone_mask", "")),
|
||
})
|
||
|
||
return list(groups.values())
|
||
|
||
def unbind_account(self, account_id: str) -> dict:
|
||
"""从身份中解绑单个平台账号(保留 Cookie/身份目录)
|
||
|
||
适用场景: 同一身份下有多个平台账号,只想移除其中一个
|
||
"""
|
||
accounts = self._read_accounts_yaml()
|
||
found = None
|
||
for acct in accounts:
|
||
if acct["id"] == account_id:
|
||
found = acct
|
||
break
|
||
if not found:
|
||
raise ValueError(f"账号 {account_id} 不存在")
|
||
|
||
# 从本机配置移除
|
||
accounts.remove(found)
|
||
self._write_accounts_yaml(accounts)
|
||
|
||
# 同步 WPRA
|
||
self._write_self_accounts()
|
||
|
||
return {
|
||
"status": "ok",
|
||
"account_id": account_id,
|
||
"identity_preserved": True,
|
||
"note": "账号已解绑,身份目录和Cookie已保留",
|
||
}
|
||
|
||
def delete_identity(self, identity_dir: str) -> dict:
|
||
"""删除整个身份(身份目录 + 旗下所有账号)
|
||
|
||
适用场景: 彻底移除一个手机号的所有账号和浏览器指纹
|
||
"""
|
||
# 1. 获取该身份下的所有账号
|
||
accounts = self._read_accounts_yaml()
|
||
to_remove = []
|
||
for acct in accounts:
|
||
id_dir = acct.get("identity_dir", "").replace("identities/", "")
|
||
if id_dir == identity_dir or acct["id"] == identity_dir:
|
||
to_remove.append(acct)
|
||
|
||
removed_ids = [a["id"] for a in to_remove]
|
||
|
||
# 2. 从配置中移除
|
||
accounts = [a for a in accounts if a["id"] not in removed_ids]
|
||
self._write_accounts_yaml(accounts)
|
||
|
||
# 3. 同步 WPRA
|
||
self._write_self_accounts()
|
||
|
||
# 4. 删除身份目录
|
||
id_path = MATRIX_IDENTITIES / identity_dir
|
||
identity_deleted = False
|
||
if id_path.exists():
|
||
shutil.rmtree(id_path)
|
||
identity_deleted = True
|
||
|
||
# 5. 删除 Cookie 备份
|
||
backup_dir = MATRIX_BACKUPS / "cookies" / identity_dir
|
||
if backup_dir.exists():
|
||
shutil.rmtree(backup_dir)
|
||
|
||
return {
|
||
"status": "ok",
|
||
"identity_dir": identity_dir,
|
||
"removed_accounts": removed_ids,
|
||
"account_count": len(to_remove),
|
||
"identity_deleted": identity_deleted,
|
||
}
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 账号操作
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def run_nurture(self, accounts: list[str], rounds: int = 10, daemon: bool = False) -> dict:
|
||
"""启动养号"""
|
||
args = [PYTHON, MATRIX_CLI, "nurture", "run"]
|
||
for a in accounts:
|
||
args.extend(["-a", a])
|
||
args.extend(["-r", str(rounds)])
|
||
if daemon:
|
||
args.append("--daemon")
|
||
else:
|
||
args.append("--no-daemon")
|
||
|
||
log_file = f"/tmp/matrix_mgmt_nurture_{int(time.time())}.log"
|
||
cmd_str = " ".join(str(a) for a in args) + f" > {log_file} 2>&1 &"
|
||
subprocess.Popen(
|
||
" ".join(str(a) for a in args),
|
||
shell=True,
|
||
stdout=open(log_file, "w"),
|
||
stderr=subprocess.STDOUT,
|
||
)
|
||
return {"status": "started", "command": " ".join(str(a) for a in args), "log": log_file}
|
||
|
||
def check_login_status(self, account_id: str) -> dict:
|
||
"""检查指定账号的登录状态"""
|
||
acct = self.get_account(account_id)
|
||
if not acct:
|
||
return {"status": "unknown", "error": "账号不存在"}
|
||
return {"account": account_id, "status": acct["_status"]}
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 蓝图(原子操作编排)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 原子操作依赖模型 v2.0 — 严格按平台隔离
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 每个操作定义:
|
||
# platform: 所属平台 (douyin / xiaohongshu / 通用)
|
||
# category: 操作类别 (navigation/browse/interact/utility)
|
||
# requires: 前置操作(必须至少一个在此列表中)
|
||
# allows: 可选的后继操作("*" 表示任意)
|
||
# can_be_first: 是否可作为第一个步骤
|
||
#
|
||
# 核心规则:
|
||
# - requires/allows 只引用同平台或"通用"的操作
|
||
# - 跨平台编排=不合法(蓝图验证会拦截)
|
||
# - 每个操作只能属于一个平台
|
||
#
|
||
# 新增操作:
|
||
# account_check_status — 检查账号登录状态
|
||
# account_read_info — 读取账号信息(粉丝数/作品数等)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
OP_GRAPH = {
|
||
# ═══════════════════════════════════════════════════
|
||
# 🎵 抖音专用
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
"goto_home": {
|
||
"platform": "douyin", "category": "navigation", "label": "🏠 回到推荐页",
|
||
"requires": [], "allows": ["browse_feed", "dy_enter_video", "dy_scroll_feed", "dy_search", "dy_goto_profile", "rest", "go_back", "dy_like", "dy_collect"],
|
||
"can_be_first": True, "desc": "回到抖音推荐页,固定起点"
|
||
},
|
||
"browse_feed": {
|
||
"platform": "douyin", "category": "browse", "label": "📱 浏览推荐流",
|
||
"requires": ["goto_home", "go_back", "rest"], "allows": ["dy_enter_video", "dy_scroll_feed", "dy_search", "dy_like", "dy_collect", "rest", "go_back"],
|
||
"can_be_first": True, "desc": "浏览推荐页视频流,自动滑视频"
|
||
},
|
||
"dy_scroll_feed": {
|
||
"platform": "douyin", "category": "browse", "label": "⬇ 下滑加载",
|
||
"requires": ["browse_feed", "dy_enter_video", "goto_home", "go_back", "rest"],
|
||
"allows": ["dy_enter_video", "dy_scroll_feed", "dy_like", "dy_collect", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "抖音瀑布流下滑加载更多"
|
||
},
|
||
"dy_enter_video": {
|
||
"platform": "douyin", "category": "browse", "label": "▶ 进入播放页",
|
||
"requires": ["browse_feed", "dy_scroll_feed", "dy_search", "rest"],
|
||
"allows": ["dy_like", "dy_collect", "dy_follow", "dy_comment_a", "dy_comment_b", "dy_scroll_feed", "dy_next_video", "dy_prev_video", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从推荐页点击卡片进入视频播放页"
|
||
},
|
||
"dy_like": {
|
||
"platform": "douyin", "category": "interact", "label": "👍 点赞",
|
||
"requires": ["dy_enter_video", "browse_feed"], "allows": ["dy_collect", "dy_follow", "dy_comment_a", "dy_comment_b", "dy_scroll_feed", "dy_next_video", "dy_prev_video", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "点赞当前视频(KeyZ)"
|
||
},
|
||
"dy_collect": {
|
||
"platform": "douyin", "category": "interact", "label": "⭐ 收藏",
|
||
"requires": ["dy_enter_video"], "allows": ["dy_like", "dy_follow", "dy_comment_a", "dy_comment_b", "dy_scroll_feed", "dy_next_video", "dy_prev_video", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "收藏当前视频到收藏夹"
|
||
},
|
||
"dy_follow": {
|
||
"platform": "douyin", "category": "interact", "label": "➕ 关注",
|
||
"requires": ["dy_enter_video"], "allows": ["dy_like", "dy_collect", "dy_comment_a", "dy_comment_b", "dy_scroll_feed", "dy_next_video", "dy_prev_video", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "关注当前视频作者"
|
||
},
|
||
"dy_comment_a": {
|
||
"platform": "douyin", "category": "interact", "label": "💬 评论(Path A)",
|
||
"requires": ["dy_enter_video"], "allows": ["dy_like", "dy_collect", "dy_follow", "dy_scroll_feed", "dy_next_video", "dy_prev_video", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "弹窗覆盖层评论 (KeyX→pbcopy→Enter)"
|
||
},
|
||
"dy_comment_b": {
|
||
"platform": "douyin", "category": "interact", "label": "💬 评论(Path B)",
|
||
"requires": ["dy_enter_video"], "allows": ["dy_like", "dy_collect", "dy_follow", "dy_scroll_feed", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "全屏视频页评论 (scroll→click→Enter)"
|
||
},
|
||
"dy_next_video": {
|
||
"platform": "douyin", "category": "interact", "label": "⏭ 下一个视频",
|
||
"requires": ["dy_enter_video", "dy_like", "dy_collect", "dy_follow", "dy_comment_a"],
|
||
"allows": ["dy_like", "dy_collect", "dy_follow", "dy_comment_a", "dy_comment_b", "dy_scroll_feed", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "切换到下一个视频"
|
||
},
|
||
"dy_prev_video": {
|
||
"platform": "douyin", "category": "interact", "label": "⏮ 上一个视频",
|
||
"requires": ["dy_enter_video"], "allows": ["dy_like", "dy_collect", "dy_follow", "dy_comment_a", "dy_comment_b", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "切换到上一个视频"
|
||
},
|
||
"dy_search": {
|
||
"platform": "douyin", "category": "interact", "label": "🔍 抖音搜索",
|
||
"requires": ["goto_home", "browse_feed", "rest", "go_back"], "allows": ["dy_enter_video", "dy_scroll_feed", "rest", "go_back"],
|
||
"can_be_first": True, "desc": "在抖音搜索关键词并打开搜索结果页"
|
||
},
|
||
|
||
# ── 主页信息读取类(导航+读取7项)──
|
||
"dy_goto_profile": {
|
||
"platform": "douyin", "category": "browse", "label": "👤 进入个人主页",
|
||
"requires": ["goto_home", "rest", "go_back"],
|
||
"allows": ["dy_read_nickname", "dy_read_douyin_id", "dy_read_following", "dy_read_fans", "dy_read_likes", "dy_read_posts", "dy_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "导航到当前账号的个人主页 /user/self"
|
||
},
|
||
"dy_read_nickname": {
|
||
"platform": "douyin", "category": "interact", "label": "📝 读取昵称",
|
||
"requires": ["dy_goto_profile"], "allows": ["dy_read_douyin_id", "dy_read_following", "dy_read_fans", "dy_read_likes", "dy_read_posts", "dy_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取账号昵称"
|
||
},
|
||
"dy_read_douyin_id": {
|
||
"platform": "douyin", "category": "interact", "label": "🔢 读取抖音号",
|
||
"requires": ["dy_goto_profile"], "allows": ["dy_read_nickname", "dy_read_following", "dy_read_fans", "dy_read_likes", "dy_read_posts", "dy_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取抖音号(数字ID)"
|
||
},
|
||
"dy_read_following": {
|
||
"platform": "douyin", "category": "interact", "label": "👥 读取关注数",
|
||
"requires": ["dy_goto_profile"], "allows": ["dy_read_nickname", "dy_read_douyin_id", "dy_read_fans", "dy_read_likes", "dy_read_posts", "dy_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取关注人数"
|
||
},
|
||
"dy_read_fans": {
|
||
"platform": "douyin", "category": "interact", "label": "👥 读取粉丝数",
|
||
"requires": ["dy_goto_profile"], "allows": ["dy_read_nickname", "dy_read_douyin_id", "dy_read_following", "dy_read_likes", "dy_read_posts", "dy_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取粉丝数"
|
||
},
|
||
"dy_read_likes": {
|
||
"platform": "douyin", "category": "interact", "label": "👍 读取获赞数",
|
||
"requires": ["dy_goto_profile"], "allows": ["dy_read_nickname", "dy_read_douyin_id", "dy_read_following", "dy_read_fans", "dy_read_posts", "dy_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取获赞总数"
|
||
},
|
||
"dy_read_posts": {
|
||
"platform": "douyin", "category": "interact", "label": "📹 读取作品数",
|
||
"requires": ["dy_goto_profile"], "allows": ["dy_read_nickname", "dy_read_douyin_id", "dy_read_following", "dy_read_fans", "dy_read_likes", "dy_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取作品(视频)数量"
|
||
},
|
||
"dy_read_bio": {
|
||
"platform": "douyin", "category": "interact", "label": "📄 读取简介",
|
||
"requires": ["dy_goto_profile"], "allows": ["dy_read_nickname", "dy_read_douyin_id", "dy_read_following", "dy_read_fans", "dy_read_likes", "dy_read_posts", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取个人简介"
|
||
},
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# 📕 小红书专用
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
"xhs_goto_home": {
|
||
"platform": "xiaohongshu", "category": "navigation", "label": "🏠 回到首页",
|
||
"requires": [], "allows": ["xhs_browse", "xhs_scroll_feed", "xhs_search", "xhs_goto_profile", "rest", "go_back"],
|
||
"can_be_first": True, "desc": "回到小红书首页,固定起点"
|
||
},
|
||
"xhs_browse": {
|
||
"platform": "xiaohongshu", "category": "browse", "label": "📕 瀑布流浏览",
|
||
"requires": ["xhs_goto_home", "rest", "go_back"], "allows": ["xhs_click_note", "xhs_scroll_feed", "xhs_search", "xhs_like", "rest", "go_back"],
|
||
"can_be_first": True, "desc": "小红书首页瀑布流浏览推荐笔记"
|
||
},
|
||
"xhs_scroll_feed": {
|
||
"platform": "xiaohongshu", "category": "browse", "label": "⬇ 下滑加载",
|
||
"requires": ["xhs_browse", "xhs_goto_home", "go_back", "rest"],
|
||
"allows": ["xhs_click_note", "xhs_scroll_feed", "xhs_like", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "小红书瀑布流下滑加载更多笔记"
|
||
},
|
||
"xhs_click_note": {
|
||
"platform": "xiaohongshu", "category": "browse", "label": "📕 点击笔记",
|
||
"requires": ["xhs_browse", "xhs_scroll_feed", "rest"], "allows": ["xhs_like", "xhs_comment", "rest", "go_back", "xhs_scroll_feed"],
|
||
"can_be_first": False, "desc": "小红书点击笔记卡片进入详情页"
|
||
},
|
||
"xhs_like": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "📕 点赞",
|
||
"requires": ["xhs_click_note"], "allows": ["xhs_comment", "rest", "go_back", "xhs_scroll_feed"],
|
||
"can_be_first": False, "desc": "小红书点赞当前笔记"
|
||
},
|
||
"xhs_comment": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "📕 评论",
|
||
"requires": ["xhs_click_note", "xhs_like"], "allows": ["rest", "go_back", "xhs_scroll_feed"],
|
||
"can_be_first": False, "desc": "小红书评论当前笔记"
|
||
},
|
||
"xhs_search": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "📕 小红书搜索",
|
||
"requires": ["xhs_goto_home", "xhs_browse", "rest", "go_back"], "allows": ["xhs_click_note", "xhs_scroll_feed", "rest", "go_back"],
|
||
"can_be_first": True, "desc": "在小红书搜索关键词"
|
||
},
|
||
"xhs_follow": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "📕 关注",
|
||
"requires": ["xhs_click_note"], "allows": ["xhs_like", "xhs_comment", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "小红书关注当前笔记作者"
|
||
},
|
||
"xhs_collect": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "📕 收藏",
|
||
"requires": ["xhs_click_note"], "allows": ["xhs_like", "xhs_comment", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "小红书收藏当前笔记"
|
||
},
|
||
|
||
# ── 主页信息读取类 ──
|
||
"xhs_goto_profile": {
|
||
"platform": "xiaohongshu", "category": "browse", "label": "👤 进入个人主页",
|
||
"requires": ["xhs_goto_home", "rest", "go_back"],
|
||
"allows": ["xhs_read_nickname", "xhs_read_user_id", "xhs_read_following", "xhs_read_fans", "xhs_read_likes", "xhs_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "点击底部导航「我」进入小红书个人主页"
|
||
},
|
||
"xhs_read_nickname": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "📝 读取昵称",
|
||
"requires": ["xhs_goto_profile"], "allows": ["xhs_read_user_id", "xhs_read_following", "xhs_read_fans", "xhs_read_likes", "xhs_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取用户昵称"
|
||
},
|
||
"xhs_read_user_id": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "🔢 读取小红书号",
|
||
"requires": ["xhs_goto_profile"], "allows": ["xhs_read_nickname", "xhs_read_following", "xhs_read_fans", "xhs_read_likes", "xhs_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取小红书号(数字ID)"
|
||
},
|
||
"xhs_read_following": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "👥 读取关注数",
|
||
"requires": ["xhs_goto_profile"], "allows": ["xhs_read_nickname", "xhs_read_user_id", "xhs_read_fans", "xhs_read_likes", "xhs_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取关注人数"
|
||
},
|
||
"xhs_read_fans": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "👥 读取粉丝数",
|
||
"requires": ["xhs_goto_profile"], "allows": ["xhs_read_nickname", "xhs_read_user_id", "xhs_read_following", "xhs_read_likes", "xhs_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取粉丝数"
|
||
},
|
||
"xhs_read_likes": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "👍 读取获赞数",
|
||
"requires": ["xhs_goto_profile"], "allows": ["xhs_read_nickname", "xhs_read_user_id", "xhs_read_following", "xhs_read_fans", "xhs_read_bio", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取获赞与收藏总数"
|
||
},
|
||
"xhs_read_bio": {
|
||
"platform": "xiaohongshu", "category": "interact", "label": "📄 读取简介",
|
||
"requires": ["xhs_goto_profile"], "allows": ["xhs_read_nickname", "xhs_read_user_id", "xhs_read_following", "xhs_read_fans", "xhs_read_likes", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "从个人主页读取个人简介"
|
||
},
|
||
# ═══════════════════════════════════════════════════
|
||
# ⚙️ 通用工具(所有平台可用)
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
"go_back": {
|
||
"platform": "通用", "category": "utility", "label": "⬅ 返回",
|
||
"requires": ["*"], "allows": ["*"],
|
||
"can_be_first": False, "desc": "浏览器后退,回到上一页"
|
||
},
|
||
"rest": {
|
||
"platform": "通用", "category": "utility", "label": "⏳ 休息",
|
||
"requires": [], "allows": ["*"],
|
||
"can_be_first": True, "desc": "随机休息 5~20秒,模拟真人操作间隔"
|
||
},
|
||
"account_check_status": {
|
||
"platform": "通用", "category": "utility", "label": "📊 检查登录状态",
|
||
"requires": ["goto_home", "xhs_goto_home", "rest"],
|
||
"allows": ["goto_home", "xhs_goto_home", "browse_feed", "xhs_browse", "rest", "go_back"],
|
||
"can_be_first": True, "desc": "检测当前账号登录状态是否正确(页面级验证)"
|
||
},
|
||
"account_read_info": {
|
||
"platform": "通用", "category": "utility", "label": "📋 读取账号信息",
|
||
"requires": ["goto_home", "xhs_goto_home", "account_check_status"],
|
||
"allows": ["goto_home", "xhs_goto_home", "rest", "go_back"],
|
||
"can_be_first": False, "desc": "读取账号基础信息(昵称/粉丝数/作品数等),输出到日志"
|
||
},
|
||
}
|
||
|
||
def list_atomic_ops(self) -> list[dict]:
|
||
"""列出所有可用原子操作(含依赖约束)"""
|
||
# 从 douyin_ops.py 和 ops/xhs_ops.py 提取异步函数
|
||
parsed_ops = []
|
||
import ast
|
||
|
||
scan_configs = [
|
||
("douyin_ops.py", "douyin"),
|
||
("ops/xhs_ops.py", "xiaohongshu"),
|
||
]
|
||
|
||
for rel_path, platform in scan_configs:
|
||
try:
|
||
ops_file = MATRIX_SCRIPTS / rel_path
|
||
if not ops_file.exists():
|
||
continue
|
||
tree = ast.parse(ops_file.read_text())
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.AsyncFunctionDef) and not node.name.startswith("_"):
|
||
doc = ast.get_docstring(node) or ""
|
||
name = node.name
|
||
if name not in self.OP_GRAPH:
|
||
parsed_ops.append({
|
||
"name": name,
|
||
"type": platform,
|
||
"doc": doc.split("\n")[0] if doc else "",
|
||
"source": ops_file.name,
|
||
"category": "auto",
|
||
"platform": platform,
|
||
"requires": [],
|
||
"allows": ["*"],
|
||
"can_be_first": True,
|
||
"label": name,
|
||
"desc": doc.split("\n")[0] if doc else "",
|
||
})
|
||
except:
|
||
pass
|
||
|
||
# 合并手动定义的标准操作
|
||
result = {op["name"]: op for op in parsed_ops}
|
||
for name, info in self.OP_GRAPH.items():
|
||
result[name] = {
|
||
"name": name,
|
||
"type": info["category"],
|
||
"doc": info["desc"],
|
||
"source": "graph",
|
||
"platform": info.get("platform", "通用"),
|
||
"category": info["category"],
|
||
"label": info["label"],
|
||
"desc": info["desc"],
|
||
"requires": info["requires"],
|
||
"allows": info["allows"],
|
||
"can_be_first": info["can_be_first"],
|
||
}
|
||
|
||
return sorted(result.values(), key=lambda x: (x.get("category", "z"), x["name"]))
|
||
|
||
def validate_blueprint_steps(self, steps: list[dict]) -> dict:
|
||
"""校验蓝图的步骤编排合法性
|
||
新增平台一致性检查:抖音操作不能和小红书操作混排
|
||
"""
|
||
errors = []
|
||
warnings = []
|
||
detected_platform = None
|
||
|
||
for i, step in enumerate(steps):
|
||
name = step.get("name", "")
|
||
info = self.OP_GRAPH.get(name)
|
||
if not info:
|
||
continue
|
||
|
||
step_platform = info.get("platform", "通用")
|
||
|
||
# 平台一致性
|
||
if step_platform != "通用":
|
||
if detected_platform is None:
|
||
detected_platform = step_platform
|
||
elif detected_platform != step_platform:
|
||
errors.append({
|
||
"step": i + 1, "op": name,
|
||
"msg": f'跨平台操作: 蓝图已用"{detected_platform}",步骤{i+1}"{info["label"]}"属于"{step_platform}"'
|
||
})
|
||
|
||
# 首步检查
|
||
if i == 0 and not info["can_be_first"]:
|
||
errors.append({"step": i + 1, "op": name, "msg": f'"{info["label"]}" 不能作为首步'})
|
||
|
||
# 前置约束
|
||
if i > 0 and info["requires"] and info["requires"] != ["*"]:
|
||
prev_names = [s.get("name", "") for s in steps[:i]]
|
||
if not any(r in prev_names for r in info["requires"] if r != "*"):
|
||
errors.append({"step": i + 1, "op": name, "msg": f'"{info["label"]}" 前置未满足: {info["requires"]}'})
|
||
|
||
# 后继允许
|
||
if i > 0:
|
||
prev_info = self.OP_GRAPH.get(steps[i - 1].get("name", ""))
|
||
if prev_info and prev_info["allows"] != ["*"] and name not in prev_info["allows"]:
|
||
errors.append({"step": i + 1, "op": name, "msg": f'上一步"{prev_info["label"]}"不能接"{info["label"]}"'})
|
||
|
||
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
|
||
|
||
def list_blueprints(self) -> list[dict]:
|
||
"""列出所有已保存蓝图"""
|
||
blueprints = []
|
||
if MATRIX_BLUEPRINTS.exists():
|
||
for f in sorted(MATRIX_BLUEPRINTS.iterdir()):
|
||
if f.suffix in (".yaml", ".yml", ".json"):
|
||
try:
|
||
bp = yaml.safe_load(f.read_text()) if f.suffix != ".json" else json.loads(f.read_text())
|
||
if isinstance(bp, dict):
|
||
blueprints.append({
|
||
"name": bp.get("name", bp.get("id", f.stem)),
|
||
"file": f.name,
|
||
"steps": bp.get("steps", bp.get("actions", [])),
|
||
"step_count": len(bp.get("steps", bp.get("actions", []))),
|
||
"type": f.name.split("_")[0],
|
||
"platform": bp.get("platform", "douyin"),
|
||
"description": bp.get("description", ""),
|
||
})
|
||
except:
|
||
pass
|
||
return blueprints
|
||
|
||
def save_blueprint(self, name: str, data: dict) -> dict:
|
||
"""保存蓝图(新建或覆盖)"""
|
||
blueprint = {
|
||
"name": name,
|
||
"id": name,
|
||
"version": "1.0",
|
||
"platform": data.get("platform", "douyin"),
|
||
"description": data.get("description", ""),
|
||
"steps": data.get("steps", []),
|
||
"actions": data.get("steps", []),
|
||
"created_at": datetime.now().isoformat(),
|
||
}
|
||
file_path = MATRIX_BLUEPRINTS / f"{name}.json"
|
||
file_path.write_text(json.dumps(blueprint, indent=2, ensure_ascii=False), encoding="utf-8")
|
||
return {"status": "ok", "file": str(file_path), "steps": len(blueprint["steps"])}
|
||
|
||
def delete_blueprint(self, name: str) -> dict:
|
||
"""删除蓝图"""
|
||
for suffix in [".json", ".yaml", ".yml"]:
|
||
f = MATRIX_BLUEPRINTS / f"{name}{suffix}"
|
||
if f.exists():
|
||
f.unlink()
|
||
return {"status": "ok", "deleted": str(f)}
|
||
raise ValueError(f"蓝图 {name} 不存在")
|
||
|
||
def execute_blueprint(self, blueprint_name: str, account_id: str) -> dict:
|
||
"""对指定账号执行蓝图"""
|
||
acct = self.get_account(account_id)
|
||
if not acct:
|
||
raise ValueError(f"账号 {account_id} 不存在")
|
||
|
||
# 查找蓝图文件
|
||
bp_file = None
|
||
for suffix in [".json", ".yaml", ".yml"]:
|
||
f = MATRIX_BLUEPRINTS / f"{blueprint_name}{suffix}"
|
||
if f.exists():
|
||
bp_file = f
|
||
break
|
||
if not bp_file:
|
||
raise ValueError(f"蓝图 {blueprint_name} 不存在")
|
||
|
||
log_file = f"/tmp/matrix_mgmt_bp_{blueprint_name}_{account_id}_{int(time.time())}.log"
|
||
cmd = f"cd {MATRIX_SCRIPTS} && {PYTHON} task_engine.py --blueprint {bp_file.stem} --account {account_id} > {log_file} 2>&1 &"
|
||
subprocess.Popen(cmd, shell=True)
|
||
return {"status": "started", "blueprint": blueprint_name, "account": account_id, "log": log_file}
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 导入导出
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def export_accounts(self, output_path: str = None) -> str:
|
||
"""导出账号配置 + Cookie 为 ZIP"""
|
||
if not output_path:
|
||
output_path = f"/tmp/matrix_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
||
|
||
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||
# 1. 账号配置
|
||
if MATRIX_ACCOUNTS_CFG.exists():
|
||
zf.write(MATRIX_ACCOUNTS_CFG, "config/accounts.yaml")
|
||
|
||
# 2. Cookie 文件 + 身份配置
|
||
for identity_dir in MATRIX_IDENTITIES.iterdir():
|
||
if not identity_dir.is_dir():
|
||
continue
|
||
# config.yaml
|
||
cfg = identity_dir / "config.yaml"
|
||
if cfg.exists():
|
||
zf.write(cfg, f"identities/{identity_dir.name}/config.yaml")
|
||
# fingerprint.pkl
|
||
fp = identity_dir / "fingerprint.pkl"
|
||
if fp.exists():
|
||
zf.write(fp, f"identities/{identity_dir.name}/fingerprint.pkl")
|
||
# cookies.sqlite
|
||
ck = identity_dir / "user_data" / "cookies.sqlite"
|
||
if ck.exists():
|
||
zf.write(ck, f"identities/{identity_dir.name}/cookies.sqlite")
|
||
|
||
return output_path
|
||
|
||
def import_accounts(self, zip_path: str, overwrite: bool = False) -> dict:
|
||
"""从 ZIP 导入账号配置 + Cookie"""
|
||
result = {"accounts_imported": 0, "identities_imported": 0, "warnings": []}
|
||
|
||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||
# 1. 导入账号配置
|
||
if "config/accounts.yaml" in zf.namelist():
|
||
target = MATRIX_ACCOUNTS_CFG
|
||
if target.exists() and not overwrite:
|
||
result["warnings"].append("accounts.yaml 已存在(使用 overwrite=True 覆盖)")
|
||
else:
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
zf.extract("config/accounts.yaml", target.parent)
|
||
result["accounts_imported"] = 1
|
||
|
||
# 2. 导入身份和 Cookie
|
||
for name in zf.namelist():
|
||
if name.startswith("identities/") and name.endswith("cookies.sqlite"):
|
||
parts = name.split("/")
|
||
if len(parts) >= 3:
|
||
identity_name = parts[1]
|
||
target_dir = MATRIX_IDENTITIES / identity_name / "user_data"
|
||
target_dir.mkdir(parents=True, exist_ok=True)
|
||
zf.extract(name, target_dir.parent)
|
||
result["identities_imported"] += 1
|
||
|
||
elif name.startswith("identities/") and name.endswith(("config.yaml", "fingerprint.pkl")):
|
||
target_path = MATRIX_IDENTITIES / "/".join(name.split("/")[1:])
|
||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||
zf.extract(name, MATRIX_IDENTITIES.parent)
|
||
if name.endswith("config.yaml"):
|
||
result["identities_imported"] = result.get("identities_imported", 0) + 0.5
|
||
|
||
return result
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 备份恢复
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def list_backups(self) -> list[dict]:
|
||
"""列出所有可用的备份快照"""
|
||
backups = []
|
||
backup_root = MATRIX_BACKUPS / "cookies"
|
||
if not backup_root.exists():
|
||
return backups
|
||
for identity_dir in sorted(backup_root.iterdir()):
|
||
if not identity_dir.is_dir():
|
||
continue
|
||
files = sorted(identity_dir.glob("*.bak"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||
for f in files:
|
||
# 文件名格式: cookies.{timestamp}.{platform}.{label}.bak
|
||
parts = f.stem.split(".")
|
||
ts_str = parts[1] if len(parts) > 1 else "00000000_000000"
|
||
platform = parts[2] if len(parts) > 2 else "unknown"
|
||
label = ".".join(parts[3:]) if len(parts) > 3 else ""
|
||
try:
|
||
ts = datetime.strptime(ts_str, "%Y%m%d_%H%M%S")
|
||
time_str = ts.strftime("%m-%d %H:%M")
|
||
except:
|
||
time_str = ts_str
|
||
backups.append({
|
||
"identity": identity_dir.name,
|
||
"file": f.name,
|
||
"path": str(f),
|
||
"size_kb": round(f.stat().st_size / 1024, 1),
|
||
"timestamp": ts_str,
|
||
"time_str": time_str,
|
||
"platform": platform,
|
||
"label": label,
|
||
})
|
||
return backups
|
||
|
||
def create_backup(self, label: str = "manual") -> dict:
|
||
"""创建全量备份(调用 cookie_manager 的 backup_all_identities)"""
|
||
try:
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location(
|
||
"cookie_manager",
|
||
MATRIX_SCRIPTS / "matrix_modules" / "utils" / "cookie_manager.py"
|
||
)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(mod)
|
||
result = mod.backup_all_identities(platform="dashboard", label=label)
|
||
count = sum(1 for v in result.values() if v)
|
||
return {"status": "ok", "backup_count": count, "details": result}
|
||
except Exception as e:
|
||
# fallback: 手动复制 cookie 文件
|
||
count = 0
|
||
for identity_dir in MATRIX_IDENTITIES.iterdir():
|
||
if not identity_dir.is_dir():
|
||
continue
|
||
cookie = identity_dir / "user_data" / "cookies.sqlite"
|
||
if cookie.exists():
|
||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
backup_dir = MATRIX_BACKUPS / "cookies" / identity_dir.name
|
||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||
import shutil
|
||
shutil.copy2(cookie, backup_dir / f"cookies.{ts}.dashboard.{label}.bak")
|
||
count += 1
|
||
return {"status": "ok", "backup_count": count, "method": "manual_copy"}
|
||
|
||
def restore_backup(self, identity: str, backup_path: str) -> dict:
|
||
"""从备份文件恢复指定身份的 Cookie"""
|
||
target_cookie = MATRIX_IDENTITIES / identity / "user_data" / "cookies.sqlite"
|
||
backup_file = Path(backup_path)
|
||
if not backup_file.exists():
|
||
raise ValueError(f"备份文件不存在: {backup_path}")
|
||
if not target_cookie.parent.exists():
|
||
raise ValueError(f"身份目录不存在: {identity}")
|
||
|
||
# 先创建当前状态的备份(安全保护)
|
||
if target_cookie.exists():
|
||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
safe_bak = MATRIX_BACKUPS / "cookies" / identity / f"cookies.{ts}.pre_restore.bak"
|
||
safe_bak.parent.mkdir(parents=True, exist_ok=True)
|
||
import shutil
|
||
shutil.copy2(target_cookie, safe_bak)
|
||
|
||
# 恢复
|
||
import shutil
|
||
shutil.copy2(backup_file, target_cookie)
|
||
return {
|
||
"status": "ok",
|
||
"identity": identity,
|
||
"restored_from": str(backup_file),
|
||
"size_kb": round(backup_file.stat().st_size / 1024, 1),
|
||
}
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 跨机注册表: Registry (L0) + Override (L1) 双源读取
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
REGISTRY_PATH = MATRIX_CODE / "accounts_registry.yaml"
|
||
OVERRIDE_PATH = MATRIX_LOCAL / "config" / "accounts.override.yaml"
|
||
LEGACY_PATH = MATRIX_LOCAL / "config" / "accounts.yaml"
|
||
CROSS_MACHINE_DIR = AGENT_SYNC / "04_memory" / "cross_machine" / "data" / "matrix"
|
||
|
||
# ── WPRA v2.0: 写分区·读聚合路径 ──
|
||
MACHINES_DIR = AGENT_SYNC / "04_memory" / "cross_machine" / "machines"
|
||
|
||
def _local_uid(self) -> str:
|
||
"""读取本机 machine_uid"""
|
||
uid_file = AGENT_LOCAL / "identity" / "machine_uid"
|
||
if uid_file.exists():
|
||
return uid_file.read_text().strip()
|
||
return self._local_hostname() # 降级
|
||
|
||
def _my_machine_dir(self) -> Path:
|
||
"""本机 WPRA 命名空间目录"""
|
||
d = self.MACHINES_DIR / self._local_uid()
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
def _read_all_machines_accounts(self) -> list[dict]:
|
||
"""WPRA: 遍历所有机器的 accounts.yaml,聚合完整账号注册表
|
||
|
||
替代旧的 accounts_registry.yaml 单文件。
|
||
每台机器只写自己的 accounts.yaml,不存在则跳过。
|
||
"""
|
||
all_accounts = []
|
||
if not self.MACHINES_DIR.exists():
|
||
return all_accounts
|
||
|
||
for machine_dir in sorted(self.MACHINES_DIR.iterdir()):
|
||
if not machine_dir.is_dir():
|
||
continue
|
||
acct_file = machine_dir / "accounts.yaml"
|
||
if not acct_file.exists():
|
||
continue
|
||
try:
|
||
data = yaml.safe_load(acct_file.read_text()) or {}
|
||
accounts = data.get("accounts", [])
|
||
source_uid = data.get("machine_uid", machine_dir.name)
|
||
source_name = data.get("machine_name", source_uid[:8])
|
||
for acct in accounts:
|
||
acct["_source_machine_uid"] = source_uid
|
||
acct["_source_machine_name"] = source_name
|
||
acct["_source_machine_host"] = data.get("machine_name", source_uid[:8])
|
||
# 兼容旧字段名
|
||
if "assigned_machine" not in acct:
|
||
acct["assigned_machine"] = source_name
|
||
all_accounts.append(acct)
|
||
except Exception as e:
|
||
print(f" 跳过 {machine_dir.name}/accounts.yaml: {e}")
|
||
continue
|
||
|
||
return all_accounts
|
||
|
||
def _write_self_accounts(self):
|
||
"""WPRA: 将本机管理的账号写入 machines/{uid}/accounts.yaml
|
||
|
||
只写 assigned_machine == 本机 的账号,不碰其他机器的。
|
||
每台机器只写自己的文件,永不冲突。
|
||
"""
|
||
# 从 override 读取本机账号
|
||
hostname, override = self._read_override()
|
||
override_map = {a["id"]: a for a in override}
|
||
local_hostname = self._local_hostname()
|
||
|
||
# 从 registry 获取完整账号列表,筛选属于本机的
|
||
registry = self._read_registry()
|
||
my_accounts = []
|
||
for acct in registry:
|
||
am = acct.get("assigned_machine", "")
|
||
is_local = (am == local_hostname) or (acct["id"] in override_map)
|
||
if not is_local:
|
||
continue
|
||
ovr = override_map.get(acct["id"], {})
|
||
merged = {
|
||
"id": acct["id"],
|
||
"platform": acct.get("platform", ""),
|
||
"phone_mask": ovr.get("phone", acct.get("phone_mask", "")),
|
||
"assigned_machine": local_hostname,
|
||
"identity_hint": ovr.get("identity_hint", acct.get("identity_hint", acct["id"])),
|
||
"enabled": ovr.get("enabled", acct.get("enabled", True)),
|
||
"notes": ovr.get("notes", acct.get("notes", "")),
|
||
}
|
||
my_accounts.append(merged)
|
||
|
||
# 兜底: 如果 registry 不存在(accounts_registry.yaml 已废弃),
|
||
# 直接用 override 作为本机账号列表
|
||
if not my_accounts and override_map:
|
||
for oid, ovr_acct in override_map.items():
|
||
identity_hint = ovr_acct.get("identity_dir", ovr_acct.get("identity_hint", oid)).replace("identities/", "")
|
||
my_accounts.append({
|
||
"id": oid,
|
||
"platform": ovr_acct.get("platform", "douyin"),
|
||
"phone_mask": ovr_acct.get("phone", ""),
|
||
"assigned_machine": local_hostname,
|
||
"identity_hint": identity_hint,
|
||
"enabled": ovr_acct.get("enabled", True),
|
||
"notes": ovr_acct.get("notes", ""),
|
||
})
|
||
|
||
# 兜底: 扫描本地 accounts.yaml,把本机有身份目录的账号都写进去
|
||
# 每台机器独立声明,不查其他机器的数据(WPRA 读聚合时再处理重复)
|
||
if self.LEGACY_PATH.exists():
|
||
try:
|
||
legacy_data = yaml.safe_load(self.LEGACY_PATH.read_text()) or {}
|
||
legacy_accounts = legacy_data.get("accounts", [])
|
||
existing_ids = {a["id"] for a in my_accounts}
|
||
for acct in legacy_accounts:
|
||
aid = acct.get("id", "")
|
||
if not aid or aid in existing_ids:
|
||
continue
|
||
existing_ids.add(aid)
|
||
my_accounts.append({
|
||
"id": aid,
|
||
"platform": acct.get("platform", ""),
|
||
"phone_mask": acct.get("phone", acct.get("phone_mask", "")),
|
||
"assigned_machine": local_hostname,
|
||
"identity_hint": acct.get("identity_dir", aid).replace("identities/", ""),
|
||
"enabled": acct.get("enabled", True),
|
||
"notes": acct.get("notes", "本地账号 (来自 legacy accounts.yaml)"),
|
||
})
|
||
except Exception as e:
|
||
print(f" 读取本地 accounts.yaml 兜底失败: {e}")
|
||
|
||
# WPRA 格式写入
|
||
output = {
|
||
"schema_version": "2.0",
|
||
"file_schema": "accounts-v2",
|
||
"machine_uid": self._local_uid(),
|
||
"machine_name": local_hostname,
|
||
"updated_at": datetime.now().isoformat(),
|
||
"accounts": my_accounts,
|
||
}
|
||
target = self._my_machine_dir() / "accounts.yaml"
|
||
target.write_text(
|
||
yaml.dump(output, default_flow_style=False, allow_unicode=True, sort_keys=False),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
def _read_registry(self) -> list:
|
||
"""读取 L0 注册表 (Gitee 同步, 所有账号定义)"""
|
||
if not self.REGISTRY_PATH.exists():
|
||
return []
|
||
try:
|
||
data = yaml.safe_load(self.REGISTRY_PATH.read_text()) or {}
|
||
return data.get("accounts", [])
|
||
except Exception as e:
|
||
print(f"读取 registry 失败: {e}")
|
||
return []
|
||
|
||
def _read_override(self) -> tuple[str, list]:
|
||
"""读取 L1 覆写 (agent-local, 本机敏感字段)
|
||
返回 (hostname, accounts_list)
|
||
"""
|
||
path = self.OVERRIDE_PATH
|
||
if not path.exists():
|
||
return ("", [])
|
||
try:
|
||
data = yaml.safe_load(path.read_text()) or {}
|
||
return (data.get("hostname", ""), data.get("accounts", []))
|
||
except:
|
||
return ("", [])
|
||
|
||
def _local_hostname(self) -> str:
|
||
"""优先读取缓存的 hostname, fallback os.uname"""
|
||
hn_file = AGENT_LOCAL / "identity" / "cached_hostname"
|
||
if hn_file.exists():
|
||
return hn_file.read_text().strip()
|
||
return os.uname().nodename
|
||
|
||
def list_accounts(self) -> list[dict]:
|
||
"""合并 Registry + Override 输出完整账号列表
|
||
|
||
WPRA v2.0: 优先读取 machines/*/accounts.yaml (写分区数据)
|
||
如果不存在,降级到旧的 accounts_registry.yaml (向后兼容)
|
||
同时检查本机身份目录和登录状态
|
||
|
||
去重规则: 同一 account_id 出现在多台机器时:
|
||
1. 优先保留 assigned_machine == source_machine 的权威声明
|
||
2. 否则保留本地机器的版本
|
||
3. 否则保留最新时间戳的版本
|
||
"""
|
||
# ── WPRA v2.0: 优先读取所有机器的 accounts.yaml ──
|
||
wpra_accounts = self._read_all_machines_accounts()
|
||
if wpra_accounts:
|
||
registry = wpra_accounts
|
||
else:
|
||
# 降级: 旧 accounts_registry.yaml
|
||
registry = self._read_registry()
|
||
hostname, override = self._read_override()
|
||
override_map = {a["id"]: a for a in override}
|
||
local_hostname = self._local_hostname()
|
||
|
||
# ── 去重: 同一 ID 多机器时只保留权威版本 ──
|
||
deduped = {}
|
||
for acct in registry:
|
||
aid = acct["id"]
|
||
src_machine = acct.get("_source_machine_name", "") or acct.get("assigned_machine", "")
|
||
am = acct.get("assigned_machine", "")
|
||
|
||
if aid not in deduped:
|
||
deduped[aid] = acct
|
||
continue
|
||
|
||
# 冲突! 同一 ID 出现在多台机器
|
||
existing = deduped[aid]
|
||
existing_src = existing.get("_source_machine_name", "") or existing.get("assigned_machine", "")
|
||
existing_am = existing.get("assigned_machine", "")
|
||
|
||
# 规则1: 权威声明优先 (assigned_machine == source)
|
||
current_authoritative = (am == src_machine)
|
||
existing_authoritative = (existing_am == existing_src)
|
||
|
||
if current_authoritative and not existing_authoritative:
|
||
deduped[aid] = acct
|
||
elif current_authoritative == existing_authoritative:
|
||
# 规则2: 本机版本优先
|
||
if src_machine == local_hostname:
|
||
deduped[aid] = acct
|
||
elif existing_src == local_hostname:
|
||
pass # keep existing
|
||
else:
|
||
# 规则3: 最新时间戳
|
||
cur_ts = acct.get("updated_at", "")
|
||
ext_ts = existing.get("updated_at", "")
|
||
if cur_ts > ext_ts:
|
||
deduped[aid] = acct
|
||
|
||
# ── 补充: override 中有但 wpra/registry 中没有的账号(新创建)──
|
||
for oid, ovr_acct in override_map.items():
|
||
if oid not in deduped:
|
||
deduped[oid] = ovr_acct
|
||
|
||
# 是否为当前机器:
|
||
# 1) assigned_machine == 本机
|
||
# 2) override 中有配置
|
||
# 3) 同时在本地有身份目录 + Cookie (兜底)
|
||
def is_local(acct: dict) -> bool:
|
||
am = acct.get("assigned_machine", "")
|
||
if am == local_hostname:
|
||
return True
|
||
if acct["id"] in override_map:
|
||
# ORACLE 如果已将本账号分配给其他机器,以 ORACLE 为准
|
||
if am and am != local_hostname:
|
||
return False
|
||
return True
|
||
# 兜底: 仅当其他机器都没声明此 ID 时才用身份目录判断
|
||
# (避免 xhs_01 被 5kecheng 声明了却在本机显示为 local)
|
||
for other_aid, other_amt in _all_assignments.items():
|
||
if other_aid == acct["id"] and other_amt != local_hostname and other_amt:
|
||
return False
|
||
# 检查本地身份目录
|
||
candidates = [acct["id"],
|
||
acct.get("identity_hint", ""),
|
||
acct.get("identity_dir", "").replace("identities/", "")]
|
||
for hint in candidates:
|
||
if not hint:
|
||
continue
|
||
if self._identity_exists_by_hint(hint):
|
||
status = self._check_login_status_by_hint(hint)
|
||
if status == "logged_in":
|
||
return True
|
||
return False
|
||
|
||
# ── ORACLE.yaml 权威源: 覆盖 owner_machine ──
|
||
try:
|
||
ORACLE_PATH = AGENT_SYNC / "ORACLE.yaml"
|
||
if ORACLE_PATH.exists():
|
||
import yaml
|
||
oracle = yaml.safe_load(ORACLE_PATH.read_text())
|
||
oracle_map = {}
|
||
for entry in oracle.get("accounts", []):
|
||
machine = entry.get("assigned_machine", "") or entry.get("machine", "")
|
||
for plat, acct_id in entry.get("platforms", {}).items():
|
||
oracle_map[acct_id] = machine
|
||
for acct in deduped.values():
|
||
oid = acct["id"]
|
||
if oid in oracle_map:
|
||
acct["assigned_machine"] = oracle_map[oid]
|
||
acct["owner_machine"] = oracle_map[oid]
|
||
except Exception:
|
||
pass
|
||
|
||
# 构建所有机器的 assignment 映射 (用于 is_local 兜底判断)
|
||
_all_assignments = {a["id"]: a.get("assigned_machine", "") for a in deduped.values()}
|
||
|
||
result = []
|
||
for acct in deduped.values():
|
||
aid = acct["id"]
|
||
ovr = override_map.get(aid, {})
|
||
|
||
# 合并字段: Registry 为基础, Override 覆写
|
||
merged = dict(acct)
|
||
merged["phone"] = str(ovr.get("phone", acct.get("phone_mask", "")) or "")
|
||
merged["phone_mask"] = str(acct.get("phone_mask", ""))
|
||
merged["proxy"] = ovr.get("proxy", acct.get("proxy", None))
|
||
merged["enabled"] = ovr.get("enabled", acct.get("enabled", True))
|
||
merged["is_local"] = is_local(acct)
|
||
merged["owner_machine"] = acct.get("assigned_machine", "")
|
||
merged["tags"] = ovr.get("tags", acct.get("tags", []))
|
||
|
||
# identity_dir 解析: 优先 override, 再 registry hint
|
||
identity_hint = acct.get("identity_hint", aid)
|
||
if ovr.get("identity_dir"):
|
||
merged["identity_dir"] = ovr["identity_dir"]
|
||
else:
|
||
merged["identity_dir"] = f"identities/{identity_hint}"
|
||
|
||
# 状态
|
||
if not merged["enabled"]:
|
||
merged["_status"] = "disabled"
|
||
elif not merged["is_local"]:
|
||
merged["_status"] = "remote"
|
||
else:
|
||
merged["_status"] = self._check_login_status_by_hint(identity_hint)
|
||
# 兜底: 如果 registry 的 identity_hint 不匹配本地目录,
|
||
# 用 account id 作为 hint 重试 (如 xhs_01)
|
||
if merged["_status"] in ("no_cookie", "no_identity") and identity_hint != aid:
|
||
alt_status = self._check_login_status_by_hint(aid)
|
||
if alt_status == "logged_in":
|
||
merged["_status"] = alt_status
|
||
merged["identity_hint"] = aid
|
||
merged["identity_dir"] = f"identities/{aid}"
|
||
# 上次执行日志详情(不读 SQLite,零锁竞争)
|
||
merged["_log_detail"] = self._get_log_detail(identity_hint)
|
||
if merged["_status"] == "no_cookie" and identity_hint != aid:
|
||
merged["_log_detail"] = self._get_log_detail(aid)
|
||
merged["_identity_dir_exists"] = self._identity_exists_by_hint(identity_hint) or self._identity_exists_by_hint(aid)
|
||
|
||
result.append(merged)
|
||
|
||
# 兜底: 合并本地 accounts.yaml 中全所未有的孤儿账号
|
||
# 避免 xhs_01 这种已经被其他机器声明的情况重复
|
||
result_ids = {a["id"] for a in result}
|
||
if self.LEGACY_PATH.exists():
|
||
try:
|
||
legacy_data = yaml.safe_load(self.LEGACY_PATH.read_text()) or {}
|
||
for acct in legacy_data.get("accounts", []):
|
||
aid = acct.get("id", "")
|
||
if not aid or aid in result_ids:
|
||
continue
|
||
result_ids.add(aid)
|
||
identity_hint = acct.get("identity_dir", aid).replace("identities/", "")
|
||
merged = {
|
||
"id": aid,
|
||
"platform": acct.get("platform", ""),
|
||
"phone": acct.get("phone", ""),
|
||
"phone_mask": acct.get("phone_mask", ""),
|
||
"enabled": acct.get("enabled", True),
|
||
"notes": acct.get("notes", "本地未注册"),
|
||
"is_local": True,
|
||
"owner_machine": local_hostname,
|
||
"identity_hint": identity_hint,
|
||
"identity_dir": acct.get("identity_dir", f"identities/{identity_hint}"),
|
||
"_status": self._check_login_status_by_hint(identity_hint),
|
||
"_identity_dir_exists": self._identity_exists_by_hint(identity_hint),
|
||
"_source": "legacy_local",
|
||
}
|
||
result.append(merged)
|
||
except Exception:
|
||
pass
|
||
|
||
return result
|
||
|
||
def _check_login_status_by_hint(self, hint: str) -> str:
|
||
"""通过 identity_hint 检查账号状态
|
||
|
||
判断逻辑(按优先级):
|
||
1. 读 guardd 任务日志 → 根据上次执行结果判断
|
||
2. 无日志时 → 检查 cookies.sqlite 文件存在性兜底
|
||
|
||
返回状态(兼容前端 STATUS_CFG):
|
||
logged_in → 🟢 已登录(日志成功 或 有 cookie 文件兜底)
|
||
need_login → 🔴 需登录(日志显示失败 或 cookie 过期)
|
||
no_cookie → 🔴 无Cookie(无日志无文件)
|
||
no_identity → ⚪ 未配置
|
||
running → ⏳ 执行中
|
||
sms_skip → 📱 短信待验证
|
||
"""
|
||
if not hint:
|
||
return "no_identity"
|
||
|
||
# 1. 先查日志(优先级最高)
|
||
log = self._get_log_detail(hint)
|
||
if log.get("exists"):
|
||
ls = log.get("last_status", "unknown")
|
||
if ls == "success":
|
||
return "logged_in"
|
||
elif ls == "sms_skip":
|
||
return "sms_skip"
|
||
elif ls == "failed":
|
||
# 日志显示失败 → 看 cookie 还在不在
|
||
cookie_path = MATRIX_IDENTITIES / hint / "user_data" / "cookies.sqlite"
|
||
if cookie_path.exists() and cookie_path.stat().st_size >= 100:
|
||
return "need_login" # 有 cookie 但上次失败了 → 需重新登录
|
||
return "no_cookie" # 没 cookie → 无Cookie
|
||
elif ls == "running":
|
||
return "running"
|
||
else:
|
||
# unknown → 兜底到文件检测
|
||
pass
|
||
|
||
# 2. 无日志或日志状态不明 → 文件存在性检测
|
||
cookie_path = MATRIX_IDENTITIES / hint / "user_data" / "cookies.sqlite"
|
||
if cookie_path.exists() and cookie_path.stat().st_size >= 100:
|
||
return "logged_in"
|
||
return "no_cookie"
|
||
|
||
def _get_log_detail(self, hint: str) -> dict:
|
||
"""获取该账号上次执行日志的详细信息(供前端展示)
|
||
|
||
Returns:
|
||
{"exists": true/false, "last_status": "success"|"sms_skip"|"failed"|"unknown",
|
||
"last_time": "2026-07-12 20:08", "last_cmd": "nurture"}
|
||
"""
|
||
result = {"exists": False, "last_status": "unknown", "last_time": "", "last_cmd": ""}
|
||
log_dir = Path.home() / "workbuddy-agent-os" / "agent-local" / "runtime" / "guardd" / "tasks"
|
||
if not log_dir.exists():
|
||
return result
|
||
# 缓存日志文件列表(只 stat 一次,避免重复 syscall)
|
||
if not hasattr(self, "_log_files_cache"):
|
||
self._log_files_cache = {}
|
||
try:
|
||
for f in log_dir.iterdir():
|
||
if f.name.endswith(".log"):
|
||
try:
|
||
self._log_files_cache[f.name] = f.stat().st_mtime
|
||
except OSError:
|
||
pass
|
||
except PermissionError:
|
||
return result
|
||
candidate = None
|
||
for fname, mtime in self._log_files_cache.items():
|
||
if hint not in fname:
|
||
continue
|
||
if candidate is None or mtime > candidate[0]:
|
||
candidate = (mtime, fname)
|
||
if not candidate:
|
||
return result
|
||
mtime, fname = candidate
|
||
result["exists"] = True
|
||
result["last_time"] = datetime.fromtimestamp(mtime).strftime("%m-%d %H:%M")
|
||
# 从文件名取 cmd_type
|
||
parts = Path(fname).stem.split("_")
|
||
result["last_cmd"] = parts[0] if parts else ""
|
||
try:
|
||
text = (log_dir / fname).read_text(encoding="utf-8", errors="replace")
|
||
# 先检测短信触发(优先级最高:多条日志可能同时有 ✅ 和 短信验证)
|
||
if any(kw in text for kw in ["短信验证", "auto_verify 返回 False", "需手动登录", "sms_login", "SmsRecovery"]):
|
||
result["last_status"] = "sms_skip"
|
||
# 再检测完成状态
|
||
elif "✅" in text and "🛑 浏览器已关闭" in text:
|
||
if "skip_sms" in text or "跳过短信" in text:
|
||
result["last_status"] = "sms_skip"
|
||
else:
|
||
result["last_status"] = "success"
|
||
# 失败
|
||
elif "❌" in text or "Error" in text or "failed" in text.lower():
|
||
result["last_status"] = "failed"
|
||
# 兜底
|
||
else:
|
||
result["last_status"] = "running"
|
||
except Exception:
|
||
pass
|
||
return result
|
||
|
||
def _identity_exists_by_hint(self, hint: str) -> bool:
|
||
if not hint:
|
||
return False
|
||
return (MATRIX_IDENTITIES / hint).exists()
|
||
|
||
# ── 向后兼容(保留对旧 accounts.yaml 的支持)──
|
||
|
||
def _read_accounts_yaml(self) -> list:
|
||
"""向后兼容: 合并 override + 旧版 accounts.yaml(override 优先)"""
|
||
# 先读 override(最完整,包含本机所有账号)
|
||
try:
|
||
if self.OVERRIDE_PATH.exists():
|
||
data = yaml.safe_load(self.OVERRIDE_PATH.read_text()) or {}
|
||
override_accounts = data.get("accounts", [])
|
||
override_ids = {a["id"] for a in override_accounts if "id" in a}
|
||
else:
|
||
override_accounts, override_ids = [], set()
|
||
except:
|
||
override_accounts, override_ids = [], set()
|
||
|
||
# 再读 legacy,补上没有的
|
||
if self.LEGACY_PATH.exists():
|
||
try:
|
||
legacy_data = yaml.safe_load(self.LEGACY_PATH.read_text()) or {}
|
||
for acct in legacy_data.get("accounts", []):
|
||
if acct.get("id") and acct["id"] not in override_ids:
|
||
override_accounts.append(acct)
|
||
override_ids.add(acct["id"])
|
||
except:
|
||
pass
|
||
|
||
if override_accounts:
|
||
return override_accounts
|
||
# fallback: registry + override 合成
|
||
return self.list_accounts()
|
||
|
||
def _write_accounts_yaml(self, accounts: list):
|
||
"""向后兼容: 写回 override (不再是 accounts.yaml)"""
|
||
if self.OVERRIDE_PATH.exists():
|
||
existing = yaml.safe_load(self.OVERRIDE_PATH.read_text()) or {}
|
||
else:
|
||
existing = {"version": "1.0", "hostname": self._local_hostname()}
|
||
existing["accounts"] = accounts
|
||
self.OVERRIDE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
self.OVERRIDE_PATH.write_text(
|
||
yaml.dump(existing, default_flow_style=False, allow_unicode=True, sort_keys=False),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
def _check_login_status(self, acct: dict) -> str:
|
||
return self._check_login_status_by_hint(
|
||
acct.get("identity_hint", acct.get("identity_dir", acct["id"])).replace("identities/", "")
|
||
)
|
||
|
||
def _identity_exists(self, acct: dict) -> bool:
|
||
return self._identity_exists_by_hint(
|
||
acct.get("identity_hint", acct.get("identity_dir", acct["id"])).replace("identities/", "")
|
||
)
|
||
|
||
def _count_backups(self, acct: dict) -> int:
|
||
hint = acct.get("identity_hint", acct.get("identity_dir", acct["id"])).replace("identities/", "")
|
||
if not hint:
|
||
return 0
|
||
backup_dir = MATRIX_BACKUPS / "cookies" / hint
|
||
if backup_dir.exists():
|
||
return len(list(backup_dir.glob("*.bak")))
|
||
return 0
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# L2 运行时状态推送到 cross_machine (供 Dashboard 聚合)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def publish_status(self) -> dict:
|
||
"""将本机各账号状态写入 cross_machine/data/matrix/{uid}.json
|
||
供其他机器的 Dashboard 读取展示
|
||
"""
|
||
accounts = self.list_accounts()
|
||
local = [a for a in accounts if a.get("is_local")]
|
||
|
||
status = {
|
||
"hostname": self._local_hostname(),
|
||
"timestamp": datetime.now().isoformat(),
|
||
"total_accounts": len(accounts),
|
||
"total_local": len(local),
|
||
"accounts": {},
|
||
}
|
||
for a in accounts:
|
||
status["accounts"][a["id"]] = {
|
||
"platform": a.get("platform", ""),
|
||
"phone_mask": a.get("phone_mask", ""),
|
||
"owner_machine": a.get("owner_machine", ""),
|
||
"is_local": a.get("is_local", False),
|
||
"status": a.get("_status", "unknown"),
|
||
"enabled": a.get("enabled", False),
|
||
}
|
||
|
||
# 写入 cross_machine
|
||
import json
|
||
uid_file = AGENT_LOCAL / "identity" / "machine_uid"
|
||
uid = uid_file.read_text().strip() if uid_file.exists() else "unknown"
|
||
self.CROSS_MACHINE_DIR.mkdir(parents=True, exist_ok=True)
|
||
(self.CROSS_MACHINE_DIR / f"{uid}.json").write_text(
|
||
json.dumps(status, indent=2, ensure_ascii=False), encoding="utf-8"
|
||
)
|
||
|
||
# ── WPRA v2.0: 同步写入 machines/{uid}/accounts.yaml ──
|
||
self._write_self_accounts()
|
||
|
||
return status
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 系统信息
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def system_info(self) -> dict:
|
||
"""返回系统概览信息"""
|
||
accounts = self._read_accounts_yaml()
|
||
enabled = sum(1 for a in accounts if a.get("enabled", False))
|
||
logged_in = sum(1 for a in accounts if self._check_login_status(a) == "logged_in")
|
||
identity_count = len([d for d in MATRIX_IDENTITIES.iterdir() if d.is_dir()])
|
||
bp_count = len([f for f in MATRIX_BLUEPRINTS.iterdir() if f.suffix in (".json", ".yaml")])
|
||
|
||
return {
|
||
"total_accounts": len(accounts),
|
||
"enabled_accounts": enabled,
|
||
"logged_in_accounts": logged_in,
|
||
"identity_dirs": identity_count,
|
||
"blueprints": bp_count,
|
||
"matrix_version": "4.2",
|
||
"python": PYTHON,
|
||
"matrix_cli": MATRIX_CLI,
|
||
"data_root": str(MATRIX_LOCAL),
|
||
"code_root": str(MATRIX_CODE),
|
||
}
|