Files
MediaCrawler/mac-agent-os-main/05_tools/07_matrix/scripts/matrix_mgmt.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

1572 lines
75 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.
#!/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),
}