移植自 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 带网关原话 / 正常返回)。
1840 lines
76 KiB
Python
1840 lines
76 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
cli.py — mc 命令解析与路由
|
||
|
||
用法:
|
||
mc run --accounts A,B --blueprints X --rounds 10 [options]
|
||
mc account list|create|login|status|export
|
||
mc blueprint list|create|edit|delete
|
||
mc corpus list|add|select
|
||
mc proxy list|test|set
|
||
mc sms config|test
|
||
mc status all|accounts|browsers
|
||
mc record start|stop|export (预留)
|
||
|
||
全局选项:
|
||
--log FILE 日志输出路径
|
||
--json 输出 JSON (供程序调用)
|
||
-v, --verbose 详细输出
|
||
"""
|
||
import argparse
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
# ── 路径 ──
|
||
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
||
TOOL_DIR = SCRIPTS_DIR.parent
|
||
AGENT_SYNC = Path(os.environ.get("AGENT_SYNC", str(Path.home() / "workbuddy-agent-os" / "agent-sync")))
|
||
AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(Path.home() / "workbuddy-agent-os" / "agent-local")))
|
||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||
|
||
__version__ = "1.0.0"
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 帮助
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def print_banner():
|
||
print(f"╔══════════════════════════════════════╗")
|
||
print(f"║ Matrix Console v{__version__} ║")
|
||
print(f"║ 统一命令入口 · 批量执行 · 稳定可靠 ║")
|
||
print(f"╚══════════════════════════════════════╝")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 工具函数
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def setup_log(path: str = None):
|
||
"""设置日志文件"""
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s [%(name)s] %(message)s',
|
||
datefmt='%H:%M:%S',
|
||
force=True,
|
||
)
|
||
if path:
|
||
fh = logging.FileHandler(path, mode='a', encoding='utf-8')
|
||
fh.setFormatter(logging.Formatter('[%(asctime)s] %(message)s', datefmt='%H:%M:%S'))
|
||
fh.setLevel(logging.INFO)
|
||
logging.getLogger().addHandler(fh)
|
||
|
||
def log(msg: str):
|
||
"""统一日志输出"""
|
||
ts = datetime.now().strftime("%H:%M:%S")
|
||
print(f"[{ts}] {msg}", flush=True)
|
||
|
||
import logging
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Account 域
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_account(args):
|
||
"""账号管理"""
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
|
||
if args.action == "list":
|
||
accounts = mgr.list_accounts()
|
||
print(f"\n📋 账号列表 ({len(accounts)}):")
|
||
print(f"{'ID':<16} {'平台':<12} {'状态':<12} {'手机':<12} {'本机':<6}")
|
||
print("-" * 60)
|
||
for a in accounts:
|
||
status = "🟢 已登录" if a.get("_status") == "logged_in" else \
|
||
"🔴 离线" if a.get("_status") == "remote" else \
|
||
"⏸ 停用" if a.get("_status") == "disabled" else \
|
||
f"⚠️ {a.get('_status','?')}"
|
||
local = "本机" if a.get("is_local") else "远程"
|
||
phone = (a.get("phone") or a.get("phone_mask") or "-")[:11]
|
||
print(f" {a['id']:<14} {a.get('platform','?'):<10} {status:<12} {phone:<12} {local:<6}")
|
||
|
||
elif args.action == "login":
|
||
from login_identity import login_identity
|
||
asyncio.run(login_identity(args.name))
|
||
log(f"✅ {args.name} 登录完成")
|
||
|
||
elif args.action == "create":
|
||
from create_identity import create_identity
|
||
create_identity(name=args.name, platform=args.platform)
|
||
log(f"✅ 身份已创建: {args.name}")
|
||
|
||
elif args.action == "status":
|
||
if args.name:
|
||
log(f"检查 {args.name} 登录状态...")
|
||
print(json.dumps(mgr.check_login_status(args.name), indent=2, ensure_ascii=False))
|
||
else:
|
||
cmd_account(argparse.Namespace(action="list", name=None, json=args.json, verbose=args.verbose))
|
||
|
||
elif args.action == "export":
|
||
result = mgr.export_accounts()
|
||
log(f"📦 导出完成: {result['path']} ({result['size_kb']}KB)")
|
||
|
||
elif args.action == "import":
|
||
result = mgr.import_accounts(args.path or "")
|
||
log(f"📥 导入完成: 配置{result.get('accounts_imported',0)} 身份{result.get('identities_imported',0)}")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Run 域 (批量执行引擎)
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_run(args):
|
||
"""批量执行引擎"""
|
||
from mc.run import BatchRunner
|
||
setup_log(args.log)
|
||
|
||
# 解析参数
|
||
accounts = [a.strip() for a in args.accounts.split(",") if a.strip()]
|
||
blueprints = [b.strip() for b in args.blueprints.split(",") if b.strip()]
|
||
corpus = [c.strip() for c in (args.corpus or "").split(",") if c.strip()] if args.corpus else []
|
||
mix_mode = args.mix # True = 混合随机模式
|
||
|
||
log(f"🚀 mc run 启动")
|
||
log(f" 账号: {accounts}")
|
||
log(f" 蓝图: {blueprints}")
|
||
log(f" 轮数: {args.rounds} | 间隔: {args.interval}s | 模式: {'混合随机' if mix_mode else '顺序'}")
|
||
if corpus:
|
||
log(f" 语料: {corpus}")
|
||
if args.daemon:
|
||
log(f" 后台运行: 是")
|
||
log(f" 引擎: {args.engine}")
|
||
|
||
runner = BatchRunner(
|
||
accounts=accounts,
|
||
blueprints=blueprints,
|
||
rounds=args.rounds,
|
||
interval_range=tuple(int(x) for x in args.interval.split("-")),
|
||
mix=mix_mode,
|
||
corpus=corpus,
|
||
engine=args.engine,
|
||
daemon=args.daemon,
|
||
stagger=getattr(args, 'stagger', '15-30'),
|
||
keep_open=getattr(args, 'keep', False),
|
||
max_browsers=getattr(args, 'max_browsers', 3),
|
||
url=getattr(args, 'url', ''),
|
||
direction=getattr(args, 'direction', ''),
|
||
comment_text=getattr(args, 'comment_text', ''),
|
||
reply_text=getattr(args, 'reply_text', ''),
|
||
)
|
||
|
||
try:
|
||
report = asyncio.run(runner.run())
|
||
log(f"\n📊 执行完成")
|
||
log(f" 总步骤: {report.get('total_steps', 0)}")
|
||
log(f" 成功: {report.get('success', 0)}")
|
||
log(f" 失败: {report.get('failed', 0)}")
|
||
log(f" 耗时: {report.get('duration', 0):.1f}s")
|
||
if args.json:
|
||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||
except KeyboardInterrupt:
|
||
log("\n⏹ 用户中断")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Blueprint 域
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_blueprint(args):
|
||
"""蓝图管理"""
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
|
||
if args.action == "list":
|
||
bps = mgr.list_blueprints()
|
||
print(f"\n📋 蓝图 ({len(bps)}):")
|
||
for b in bps:
|
||
print(f" • {b['name']:25s} ({b['step_count']}步) {b.get('platform','douyin'):12s} {b.get('description','')[:30]}")
|
||
elif args.action == "show":
|
||
bps = mgr.list_blueprints()
|
||
bp = next((b for b in bps if b['name'] == args.name), None)
|
||
if bp:
|
||
print(json.dumps(bp, indent=2, ensure_ascii=False))
|
||
else:
|
||
print(f"❌ 蓝图 {args.name} 不存在")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Task 域
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_task(args):
|
||
"""智能任务调度"""
|
||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||
|
||
if args.list:
|
||
print("""
|
||
可用任务类型:
|
||
comment — 定向评论:给链接+方向 → 打开视频 → 评论
|
||
必填: --url 视频链接
|
||
选填: --direction 评论方向 (正面/提问/共鸣/感慨)
|
||
--comment 指定评论内容
|
||
--account 指定账号
|
||
|
||
search — 搜索浏览:给关键词 → 搜索 → 随机浏览/点赞
|
||
必填: --keyword 搜索关键词
|
||
选填: --rounds 浏览几个视频
|
||
|
||
collect — 信息采集:给博主名 → 搜索 → 采集主页信息
|
||
必填: --keyword 博主名称
|
||
|
||
reply — 作者回复:打开自己视频 → 读评论 → 回复
|
||
必填: --account 你的账号
|
||
选填: --comment 回复内容(默认自动生成)
|
||
|
||
示例:
|
||
mc task comment --url https://v.douyin.com/xxx --direction 正面
|
||
mc task search --keyword 美食探店 --rounds 5
|
||
mc task collect --keyword 张三说科技
|
||
mc task reply --account douyin_test
|
||
""")
|
||
return
|
||
|
||
if not args.task_type:
|
||
print("请指定任务类型: comment / search / collect / reply")
|
||
print("查看帮助: mc task --list")
|
||
return
|
||
|
||
from mc.task import Task, run_task
|
||
import asyncio
|
||
|
||
task = Task(
|
||
type=args.task_type,
|
||
url=args.url,
|
||
keyword=args.keyword,
|
||
direction=args.direction,
|
||
comment_text=args.comment,
|
||
account=args.account,
|
||
rounds=args.rounds,
|
||
)
|
||
|
||
errors = task.validate()
|
||
if errors:
|
||
print("❌ 参数错误:")
|
||
for e in errors:
|
||
print(f" - {e}")
|
||
print("\n查看帮助: mc task --list")
|
||
return
|
||
|
||
task.auto_fill()
|
||
print(f"\n📋 任务: {task.summary()}")
|
||
print(f" 蓝图: {task.blueprint}")
|
||
print(f" 参数: {task.to_task_params()}")
|
||
|
||
if not args.yes:
|
||
confirm = input("\n⏎ 确认执行?(Y/n): ").strip().lower()
|
||
if confirm not in ("", "y", "yes"):
|
||
print("已取消")
|
||
return
|
||
|
||
result = asyncio.run(run_task(task))
|
||
print(f"\n{'='*50}")
|
||
if result.get("status") == "error":
|
||
print(f"❌ 失败: {result.get('errors', result.get('error', 'unknown'))}")
|
||
else:
|
||
print(f"✅ 完成: {result.get('success', 0)}/{result.get('total_steps', 0)} 步成功")
|
||
if result.get("failed"):
|
||
print(f"⚠️ {result['failed']} 步失败")
|
||
print(f"耗时: {result.get('duration', 0)}s")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Schedule 域
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_schedule(args):
|
||
"""定时任务管理"""
|
||
if args.action == "list":
|
||
from mc.scheduler import cmd_schedule_list
|
||
cmd_schedule_list()
|
||
elif args.action == "add":
|
||
from mc.scheduler import cmd_schedule_add
|
||
cmd_schedule_add(args.id, args.account, args.blueprint, args.time,
|
||
args.rounds, getattr(args, 'days', '1,2,3,4,5,6,7'), args.args)
|
||
elif args.action == "remove":
|
||
from mc.scheduler import cmd_schedule_remove
|
||
cmd_schedule_remove(args.id)
|
||
elif args.action == "history":
|
||
from mc.scheduler import cmd_schedule_history
|
||
cmd_schedule_history(getattr(args, 'id', ''))
|
||
elif args.action == "start":
|
||
import asyncio
|
||
import logging
|
||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||
from mc.scheduler import scheduler_loop
|
||
print("🕐 定时调度器启动中...")
|
||
asyncio.run(scheduler_loop())
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Corpus 域
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_corpus(args):
|
||
"""语料库管理"""
|
||
from mc.corpus import CorpusManager
|
||
cm = CorpusManager()
|
||
|
||
if args.action == "list":
|
||
cats = cm.list_categories()
|
||
print(f"\n📚 语料库分类:")
|
||
print(f"{'平台':<12} {'分类':<16} {'权重':<6} {'条数':<6} {'启用':<6}")
|
||
print("-" * 50)
|
||
for c in cats:
|
||
print(f" {c['platform']:<10} {c['name']:<14} {c['weight']:<5} {c['count']:<5} {'✅' if c['enabled'] else '⏸'}")
|
||
|
||
elif args.action == "add":
|
||
cm.add_comment(args.category, args.text)
|
||
log(f"✅ 已添加评论到 [{args.category}]: {args.text}")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Proxy 域
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_proxy(args):
|
||
"""代理管理"""
|
||
from mc.proxy import ProxyCLI
|
||
pcli = ProxyCLI()
|
||
|
||
if args.action == "list":
|
||
proxies = pcli.list_all()
|
||
print(f"\n🌐 代理列表:")
|
||
print(f"{'账号':<16} {'代理':<36} {'状态':<10}")
|
||
print("-" * 65)
|
||
for p in proxies:
|
||
status = "🟢" if p.get("has_proxy") else "⏸"
|
||
proxy_str = str(p.get("proxy") or "none")[:34]
|
||
print(f" {p['account']:<14} {proxy_str:<34} {status:<10}")
|
||
|
||
elif args.action == "test":
|
||
if args.proxy:
|
||
r = pcli.test(args.proxy)
|
||
if r.get("status") == "ok":
|
||
log(f"✅ 代理正常: {r.get('ip','')} ({r.get('elapsed','')})")
|
||
else:
|
||
log(f"❌ 代理失败: {r.get('error','')}")
|
||
else:
|
||
log(" 请指定 --proxy 'socks5://127.0.0.1:7890'")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# SMS 域
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_sms(args):
|
||
"""短信管理"""
|
||
if args.action == "config":
|
||
from dashboard_plugins.sms_proxy_api import SMSManager
|
||
cfg = SMSManager.get_config()
|
||
print(f"\n📱 SMS 配置:")
|
||
print(json.dumps(cfg, indent=2, ensure_ascii=False))
|
||
|
||
elif args.action == "test":
|
||
from dashboard_plugins.sms_proxy_api import SMSManager
|
||
r = SMSManager.test_receive(args.phone or "")
|
||
if r.get("status") == "ok":
|
||
log(f"✅ 短信API正常: {r.get('total',0)} 条消息")
|
||
for m in r.get("messages", [])[:3]:
|
||
log(f" {m.get('time','')} {m.get('content','')[:50]}")
|
||
else:
|
||
log(f"❌ {r.get('error','')}")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Status 域
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_status(args):
|
||
"""状态检查"""
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
|
||
# 读取主页采集信息
|
||
hp_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "homepage_info.json"
|
||
hp_data = {}
|
||
if hp_path.exists():
|
||
try:
|
||
hp_data = json.loads(hp_path.read_text())
|
||
except: pass
|
||
|
||
info = mgr.system_info()
|
||
|
||
if args.json:
|
||
# JSON 输出: 整合系统信息 + 采集信息 + 账号列表
|
||
result = {
|
||
"system": info,
|
||
"collected_at": hp_data.get("collected_at", ""),
|
||
"accounts": mgr.list_accounts(),
|
||
"homepage": hp_data.get("results", []),
|
||
}
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
# 文本输出
|
||
if args.action in ("all", "accounts"):
|
||
print(f"\n📊 系统状态:")
|
||
print(f" 总账号: {info['total_accounts']}")
|
||
print(f" 已启用: {info['enabled_accounts']}")
|
||
print(f" 已登录: {info['logged_in_accounts']}")
|
||
print(f" 身份目录: {info['identity_dirs']}")
|
||
print(f" 蓝图: {info['blueprints']}")
|
||
if hp_data.get("collected_at"):
|
||
print(f" 最后采集: {hp_data['collected_at'][:19]}")
|
||
|
||
if args.action in ("all", "browsers"):
|
||
import subprocess
|
||
result = subprocess.run(["pgrep", "-fl", "camoufox|firefox"], capture_output=True, text=True)
|
||
procs = [l for l in result.stdout.split("\n") if l.strip()]
|
||
print(f"\n🌐 浏览器进程 ({len(procs)}):")
|
||
for p in procs:
|
||
print(f" {p[:80]}")
|
||
|
||
if args.action == "log":
|
||
log_path = args.log_file or "/tmp/mc_run.log"
|
||
if os.path.exists(log_path):
|
||
with open(log_path) as f:
|
||
lines = f.readlines()
|
||
print(f"\n📝 日志 ({log_path}):")
|
||
for l in lines[-30:]:
|
||
print(l.rstrip())
|
||
else:
|
||
print(f"❌ 日志文件不存在: {log_path}")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Record 域 — 原子操作录制
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_record(args):
|
||
"""操作录制入口"""
|
||
action = args.record_action if hasattr(args, 'record_action') else ""
|
||
|
||
if action == "start":
|
||
_cmd_record_start(args)
|
||
elif action == "analyze":
|
||
_cmd_record_analyze(args)
|
||
elif action == "export":
|
||
_cmd_record_export(args)
|
||
elif action == "delete":
|
||
_cmd_record_delete(args)
|
||
elif action == "list":
|
||
_cmd_record_list()
|
||
else:
|
||
log(f" 可用: mc record start, mc record analyze <path>, mc record export <path>, mc record list")
|
||
|
||
|
||
def _cmd_record_start(args):
|
||
account = getattr(args, 'account', None) or "douyin_01"
|
||
platform = getattr(args, 'platform', None) or "auto"
|
||
# "auto" 时从账号解析平台
|
||
if platform == "auto":
|
||
try:
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
for a in mgr.list_accounts():
|
||
if a["id"] == account:
|
||
p = a.get("platform", "")
|
||
if p in ("douyin", "xiaohongshu"):
|
||
platform = p
|
||
break
|
||
except: pass
|
||
log(f"🎬 开始录制 account={account} platform={platform}")
|
||
import asyncio
|
||
from mc.recorder import _run_interactive
|
||
try:
|
||
asyncio.run(_run_interactive(account, platform))
|
||
except KeyboardInterrupt:
|
||
log("\n👋 录制已中断")
|
||
except Exception as e:
|
||
log(f"❌ 录制异常: {e}")
|
||
|
||
|
||
def _cmd_record_analyze(args):
|
||
path = getattr(args, 'recording_path', None)
|
||
if not path:
|
||
log("❌ 用法: mc record analyze <录制包路径>")
|
||
return
|
||
from mc.analyzer import analyze_recording_file
|
||
log(f"🔍 分析 {path}")
|
||
result = analyze_recording_file(path)
|
||
for i, a in enumerate(result.get("actions", [])):
|
||
at = a.get("action_type", "?")
|
||
desc = a.get("action_desc", "")
|
||
log(f" {'✅' if at != 'unknown' else '❌'} 步骤{i+1}: [{at}] {desc}")
|
||
if getattr(args, 'json', False):
|
||
import json; print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
|
||
|
||
def _cmd_record_export(args):
|
||
path = getattr(args, 'recording_path', None)
|
||
if not path:
|
||
log("❌ 用法: mc record export <录制包路径>")
|
||
return
|
||
from mc.exporter import export_recording
|
||
log(f"📦 导出 {path}")
|
||
result = export_recording(path)
|
||
if result.get("saved_blueprint"):
|
||
log(f" ✅ 蓝图: {result['saved_blueprint']}")
|
||
if result.get("saved_code"):
|
||
log(f" ✅ 代码: {result['saved_code']}")
|
||
seq = result.get("summary", {}).get("action_sequence", [])
|
||
log(f" 操作序列: {' → '.join(seq)}")
|
||
|
||
|
||
def _cmd_record_delete(args):
|
||
path = getattr(args, 'recording_path', None)
|
||
if not path:
|
||
log("❌ 用法: mc record delete <录制包路径>")
|
||
return
|
||
from mc.recorder import RecordingSession
|
||
if RecordingSession.delete_recording(path):
|
||
log(f"✅ 已删除: {path}")
|
||
else:
|
||
log(f"❌ 删除失败: {path}")
|
||
|
||
|
||
def _cmd_record_list():
|
||
from mc.recorder import RecordingSession
|
||
rs = RecordingSession.list_recordings()
|
||
if not rs:
|
||
log("📭 没有录制包")
|
||
return
|
||
for r in rs:
|
||
log(f" 📹 {r['account']:15s} {r['steps']:2d}步 {r['duration']:5.0f}s {r.get('created','')[:16]} ({r['file']})")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Op 域 — 原子操作注册/删除
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_op(args):
|
||
"""原子操作管理"""
|
||
action = args.op_action if hasattr(args, 'op_action') else ""
|
||
|
||
if action == "register":
|
||
_cmd_op_register(args)
|
||
elif action == "delete":
|
||
_cmd_op_delete(args)
|
||
elif action == "list":
|
||
_cmd_op_list(args)
|
||
|
||
|
||
def _cmd_op_register(args):
|
||
"""注册蓝图为原子操作"""
|
||
name = getattr(args, 'name', '')
|
||
source = getattr(args, 'source', 'recorded')
|
||
blueprint_name = getattr(args, 'from_blueprint', '')
|
||
|
||
if not name:
|
||
log("❌ 用法: mc op register --name <操作名> --from <蓝点名> [--source recorded|manual]")
|
||
return
|
||
|
||
log(f"📝 注册原子操作: {name} (source={source})")
|
||
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
|
||
# 如果指定了蓝图,先验证蓝图存在
|
||
if blueprint_name:
|
||
bps = mgr.list_blueprints()
|
||
bp = next((b for b in bps if b['name'] == blueprint_name), None)
|
||
if not bp:
|
||
log(f"❌ 蓝图 {blueprint_name} 不存在")
|
||
return
|
||
|
||
# 注册到 OP_GRAPH
|
||
try:
|
||
# 构建 OP_GRAPH 条目
|
||
entry = {
|
||
"name": name,
|
||
"platform": getattr(args, 'platform', '通用'),
|
||
"category": "custom",
|
||
"label": f"🎬 {name}",
|
||
"requires": [],
|
||
"allows": ["rest", "go_back"],
|
||
"can_be_first": True,
|
||
"desc": f"原子操作 (source={source})",
|
||
"_source": source,
|
||
}
|
||
# 通过 MatrixManager 注册
|
||
result = mgr.register_atomic_op(entry)
|
||
log(f"✅ 已注册: {name}")
|
||
except Exception as e:
|
||
log(f"❌ 注册失败: {e}")
|
||
|
||
|
||
def _cmd_op_delete(args):
|
||
"""删除原子操作"""
|
||
name = getattr(args, 'name', '')
|
||
if not name:
|
||
log("❌ 用法: mc op delete --name <操作名>")
|
||
return
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
try:
|
||
mgr.delete_atomic_op(name)
|
||
log(f"✅ 已删除: {name}")
|
||
except Exception as e:
|
||
log(f"❌ 删除失败: {e}")
|
||
|
||
|
||
def _cmd_op_list(args):
|
||
"""列出所有原子操作"""
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
ops = mgr.list_atomic_ops()
|
||
log(f"📋 原子操作 ({len(ops)}):")
|
||
for op in ops:
|
||
source = op.get("_source", "manual")
|
||
source_tag = "🤖" if source == "recorded" else "✋"
|
||
log(f" {source_tag} {op['name']:25s} {op.get('platform','?'):12s} {op.get('desc','')[:30]}")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 采集命令
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_collect(args):
|
||
"""mc collect — 主页信息采集(通过 mc run + 蓝图)"""
|
||
import asyncio
|
||
|
||
# 解析账号
|
||
account_ids = []
|
||
if args.all:
|
||
# 采集所有启用的账号
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
for a in mgr.list_accounts():
|
||
if a.get("enabled") != False:
|
||
account_ids.append(a["id"])
|
||
if not account_ids:
|
||
print("⚠️ 没有找到启用的账号")
|
||
return
|
||
log(f"📋 采集所有 {len(account_ids)} 个账号")
|
||
elif args.phone:
|
||
# 按手机号查找账号ID
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
for a in mgr.list_accounts():
|
||
if a.get("phone") == args.phone and a.get("platform") == "douyin":
|
||
account_ids.append(a["id"])
|
||
if not account_ids:
|
||
print(f"⚠️ 手机号 {args.phone} 未找到对应账号")
|
||
return
|
||
elif args.account:
|
||
account_ids = [args.account]
|
||
elif args.status:
|
||
# 查看采集状态
|
||
status_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "collect_progress.json"
|
||
if status_path.exists():
|
||
print(json.dumps(json.loads(status_path.read_text()), indent=2, ensure_ascii=False))
|
||
else:
|
||
print("⏸️ 当前无采集任务")
|
||
return
|
||
else:
|
||
print("⚠️ 请指定 --all(全部)/ --phone(手机号)/ --account(账号ID)")
|
||
print(" 或使用: mc run --accounts=A,B --blueprints=douyin_read_profile --rounds=1")
|
||
return
|
||
|
||
from mc.run import BatchRunner
|
||
runner = BatchRunner(
|
||
accounts=account_ids,
|
||
blueprints=["douyin_read_profile"],
|
||
rounds=1,
|
||
interval_range=(5, 10),
|
||
)
|
||
report_dict = asyncio.run(runner.run())
|
||
if args.json:
|
||
print(json.dumps(report_dict, ensure_ascii=False, indent=2))
|
||
else:
|
||
print(f"✅ 采集完成: 成功{report_dict.get('success',0)} 失败{report_dict.get('failed',0)} 耗时{report_dict.get('duration',0):.0f}s")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 蒸馏命令
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_distill(args):
|
||
"""mc distill — 内容蒸馏(元数据提取 + 关键词索引)"""
|
||
import json
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
|
||
# 解析路径
|
||
source_dir = Path(args.source)
|
||
if not source_dir.is_absolute():
|
||
source_dir = AGENT_SYNC / source_dir
|
||
if not source_dir.exists():
|
||
print(f"❌ 源目录不存在: {source_dir}")
|
||
return
|
||
|
||
output_dir = Path(args.output) if args.output else AGENT_LOCAL / "memory" / "long_term"
|
||
if not output_dir.is_absolute():
|
||
output_dir = AGENT_LOCAL / output_dir
|
||
|
||
mode = args.mode
|
||
batch_size = args.batch_size
|
||
|
||
print(f"📋 蒸馏模式: {mode}")
|
||
print(f"📂 源目录: {source_dir}")
|
||
print(f"📂 输出目录: {output_dir}")
|
||
|
||
# 扫描所有 .md 文件
|
||
files = sorted(source_dir.glob("*.md"))
|
||
print(f"📄 找到 {len(files)} 篇投稿")
|
||
|
||
if mode == "extract":
|
||
# ── extract 模式:提取元数据 + 关键词索引 ──
|
||
entries = []
|
||
for f in files:
|
||
name = f.stem # e.g. 20260518_yuan_benchu_经济分析_008
|
||
parts = name.split("_")
|
||
date_str = parts[0] if len(parts) > 0 else ""
|
||
author = parts[1] if len(parts) > 1 else "unknown"
|
||
category = parts[-2] if len(parts) >= 4 else "unknown"
|
||
seq = parts[-1] if len(parts) >= 4 else ""
|
||
|
||
# 格式化日期
|
||
try:
|
||
dt = datetime.strptime(date_str, "%Y%m%d")
|
||
formatted_date = dt.strftime("%Y-%m-%d")
|
||
except:
|
||
formatted_date = date_str
|
||
|
||
# 读取 front matter 和正文
|
||
content = f.read_text(encoding="utf-8")
|
||
lines = content.split("\n")
|
||
|
||
# 提取 front matter 中的标签
|
||
keywords = []
|
||
in_front = False
|
||
for line in lines:
|
||
if line.startswith("---"):
|
||
in_front = not in_front
|
||
continue
|
||
if in_front:
|
||
if "tag" in line.lower() or "标签" in line:
|
||
val = line.split(":", 1)[-1].strip().strip('"').strip("'")
|
||
for kw in val.replace("#", "").replace(",", ",").split(","):
|
||
kw = kw.strip()
|
||
if kw:
|
||
keywords.append(kw)
|
||
elif line.strip().startswith("> **标签**"):
|
||
# Markdown 正文标签行:> **标签**: #tag1 #tag2
|
||
tag_line = line.split("**:", 1)[-1] if "**: " in line else line.split("**:", 1)[-1]
|
||
for tag in tag_line.replace("#", "").split():
|
||
tag = tag.strip()
|
||
if tag and tag not in keywords:
|
||
keywords.append(tag)
|
||
|
||
# 计算字数(不含 front matter)
|
||
body_start = 0
|
||
fm_count = 0
|
||
for line in lines:
|
||
if line.startswith("---"):
|
||
fm_count += 1
|
||
if fm_count == 2:
|
||
body_start = lines.index(line) + 1
|
||
break
|
||
body = "\n".join(lines[body_start:])
|
||
char_count = len(body.strip())
|
||
word_count = len(body.strip().replace(" ", ""))
|
||
|
||
entry = {
|
||
"file": f.name,
|
||
"date": formatted_date,
|
||
"author": author,
|
||
"category": category,
|
||
"seq": seq,
|
||
"keywords": keywords,
|
||
"char_count": char_count,
|
||
"line_count": len(lines),
|
||
"summary": body[:80].strip().replace("\n", " ") + "..." if len(body) > 80 else body.strip(),
|
||
}
|
||
entries.append(entry)
|
||
|
||
# 按分类统计
|
||
categories = {}
|
||
for e in entries:
|
||
cat = e["category"]
|
||
if cat not in categories:
|
||
categories[cat] = {"count": 0, "total_chars": 0, "tags": set()}
|
||
categories[cat]["count"] += 1
|
||
categories[cat]["total_chars"] += e["char_count"]
|
||
for kw in e["keywords"]:
|
||
categories[cat]["tags"].add(kw)
|
||
|
||
# 输出 JSON
|
||
result = {
|
||
"version": "1.0.0",
|
||
"last_updated": datetime.now().isoformat(),
|
||
"source_dir": str(source_dir),
|
||
"total_files": len(files),
|
||
"total_chars": sum(e["char_count"] for e in entries),
|
||
"categories": {k: {
|
||
"count": v["count"],
|
||
"total_chars": v["total_chars"],
|
||
"tags": sorted(v["tags"]),
|
||
} for k, v in sorted(categories.items())},
|
||
"entries": entries,
|
||
}
|
||
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
output_path = output_dir / f"keyword_index_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||
output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
if args.json:
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
else:
|
||
print(f"✅ 提取完成: {len(entries)} 篇, {len(categories)} 分类")
|
||
for cat, info in sorted(categories.items()):
|
||
print(f" {cat}: {info['count']}篇, {info['total_chars']}字符, {len(info['tags'])}个标签")
|
||
print(f"📄 输出: {output_path}")
|
||
|
||
elif mode == "llm":
|
||
# ── llm 模式:按分类生成摘要报告 ──
|
||
# 先提取元数据
|
||
categories = {}
|
||
for f in files:
|
||
name = f.stem
|
||
parts = name.split("_")
|
||
category = parts[-2] if len(parts) >= 4 else "unknown"
|
||
if category not in categories:
|
||
categories[category] = []
|
||
categories[category].append(f)
|
||
|
||
print(f"\n📊 分类分布(共 {len(categories)} 类):")
|
||
for cat, cat_files in sorted(categories.items()):
|
||
print(f" {cat}: {len(cat_files)}篇")
|
||
if args.json:
|
||
for f in cat_files[:10]:
|
||
print(f" - {f.name}")
|
||
|
||
total_batches = (len(files) + batch_size - 1) // batch_size
|
||
print(f"\n📦 批次数: {total_batches}(batch_size={batch_size})")
|
||
|
||
# 分组输出(每批一个 JSON)
|
||
batches = []
|
||
for i in range(0, len(files), batch_size):
|
||
batch_files = files[i:i + batch_size]
|
||
batch = []
|
||
for f in batch_files:
|
||
content = f.read_text(encoding="utf-8")
|
||
name = f.stem
|
||
parts = name.split("_")
|
||
batch.append({
|
||
"file": f.name,
|
||
"date": parts[0] if len(parts) > 0 else "",
|
||
"category": parts[-2] if len(parts) >= 4 else "",
|
||
"char_count": len(content),
|
||
"preview": content[:200],
|
||
})
|
||
batches.append(batch)
|
||
|
||
# 输出摘要报告
|
||
report = {
|
||
"version": "1.0.0",
|
||
"generated": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
||
"source_dir": str(source_dir),
|
||
"mode": "llm",
|
||
"total_files": len(files),
|
||
"total_batches": total_batches,
|
||
"categories": {cat: len(cat_files) for cat, cat_files in sorted(categories.items())},
|
||
"batches": batches,
|
||
"note": "AI 摘要生成需要 LLM 集成,当前为元数据聚合版本。",
|
||
}
|
||
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
output_path = output_dir / f"submission_report_{datetime.now().strftime('%Y%m%d')}.json"
|
||
output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
if args.json:
|
||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||
else:
|
||
print(f"\n📄 报告输出: {output_path}")
|
||
print("⚠️ 注意: AI 摘要生成需要 LLM 集成。当前为元数据聚合版本。")
|
||
|
||
print("✅ 蒸馏完成")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 互动命令
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_interact(args):
|
||
"""mc interact — 评论互动编排"""
|
||
from mc.interact import InteractOrchestrator
|
||
|
||
accounts = [a.strip() for a in args.accounts.split(",") if a.strip()]
|
||
interval_parts = args.interval.split("-")
|
||
interval = {"min": int(interval_parts[0]), "max": int(interval_parts[1])}
|
||
|
||
orchestrator = InteractOrchestrator(
|
||
accounts=[{"id": a, "platform": "douyin", "machine": ""} for a in accounts],
|
||
params={
|
||
"url": args.url,
|
||
"strategy": args.strategy,
|
||
"direction": args.direction,
|
||
"corpus": args.corpus,
|
||
"blueprint": args.blueprint,
|
||
"rounds": 1,
|
||
"interval": interval,
|
||
"dry_run": args.dry_run,
|
||
},
|
||
)
|
||
|
||
if args.dry_run:
|
||
plan = orchestrator.plan()
|
||
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
||
return
|
||
|
||
import asyncio
|
||
result = asyncio.run(orchestrator.run())
|
||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 登录命令
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_login(args):
|
||
"""mc login — 打开浏览器登录账号"""
|
||
import subprocess
|
||
login_script = SCRIPTS_DIR / "login_identity.py"
|
||
|
||
if args.account:
|
||
target = args.account
|
||
elif args.phone:
|
||
# 从账号注册表查找
|
||
try:
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
for a in mgr.list_accounts():
|
||
if a.get("phone") == args.phone:
|
||
target = a.get("identity_dir", a["id"]).replace("identities/", "")
|
||
break
|
||
else:
|
||
print(f"❌ 手机号 {args.phone} 未找到")
|
||
return
|
||
except:
|
||
target = args.phone
|
||
else:
|
||
print("❌ 请指定 --account 或 --phone")
|
||
return
|
||
|
||
platform = args.platform
|
||
cmd = [sys.executable, str(login_script), target, "--platform", platform]
|
||
print(f"🚀 打开登录: {' '.join(cmd)}")
|
||
subprocess.Popen(cmd, cwd=str(SCRIPTS_DIR))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 智能登录路由
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def cmd_smart_login(args):
|
||
"""mc smart-login — 智能全自动登录(自动匹配平台 + 前置检测)"""
|
||
account_id = args.account_id
|
||
phone = args.phone
|
||
timeout = args.timeout
|
||
skip_check = args.skip_check
|
||
|
||
# ── 前置检测: 从账号信息获取 platform 和 phone ──
|
||
platform = "douyin" # 默认
|
||
try:
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
for a in mgr.list_accounts():
|
||
if a["id"] == account_id:
|
||
p = a.get("platform", "")
|
||
if p in ("douyin", "xiaohongshu"):
|
||
platform = p
|
||
if not phone:
|
||
phone = a.get("phone", "")
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
print(f"{'='*55}")
|
||
print(f" 🔐 智能登录: {account_id}")
|
||
print(f" 平台: {platform}")
|
||
print(f" 手机: {phone or '从配置读取'}")
|
||
print(f"{'='*55}")
|
||
|
||
# ── 前置检测: 检查是否已登录(非强制登录时)──
|
||
if not skip_check:
|
||
try:
|
||
# 检查本地 cookie 状态
|
||
from pathlib import Path
|
||
home = Path.home()
|
||
identities_root = home / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "identities"
|
||
|
||
# 查找该账号的 identity_dir
|
||
acct_identity = account_id
|
||
try:
|
||
for a in mgr.list_accounts():
|
||
if a["id"] == account_id:
|
||
acct_identity = (a.get("identity_dir") or a.get("identity_hint") or account_id).replace("identities/", "")
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
identity_path = identities_root / acct_identity / "user_data"
|
||
cookie_file = identity_path / "cookies.sqlite"
|
||
|
||
if cookie_file.exists():
|
||
import sqlite3, time
|
||
try:
|
||
conn = sqlite3.connect(str(cookie_file), timeout=3)
|
||
now = int(time.time())
|
||
# 查 session cookie 数量 + 检查是否有未过期的
|
||
valid = conn.execute(
|
||
"SELECT count(*) FROM moz_cookies WHERE name LIKE '%session%' AND expiry > ?",
|
||
(now,)
|
||
).fetchone()[0]
|
||
total = conn.execute(
|
||
"SELECT count(*) FROM moz_cookies WHERE name LIKE '%session%'"
|
||
).fetchone()[0]
|
||
expired = total - valid
|
||
conn.close()
|
||
|
||
if valid > 0:
|
||
print(f"\n ✅ 已检测到有效登录态 (session cookie {valid}/{total} 有效)")
|
||
if expired > 0:
|
||
print(f" ⚠️ 有 {expired} 个已过期 cookie 被忽略")
|
||
return
|
||
elif total > 0 and valid == 0:
|
||
print(f"\n ⚠️ 检测到 {total} 个 session cookie 但全部过期,重新登录")
|
||
# 继续执行登录流程
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 根据平台选择登录方式 ──
|
||
if platform == "xiaohongshu":
|
||
# 小红书:全自动 SMS 登录
|
||
xhs_script = SCRIPTS_DIR / "matrix_modules" / "account" / "xiaohongshu_login.py"
|
||
if not xhs_script.exists():
|
||
print(f"❌ 小红书登录脚本不存在: {xhs_script}")
|
||
return
|
||
print(f"\n📕 小红书全自动登录: {account_id}")
|
||
import subprocess
|
||
xhs_args = [sys.executable, str(xhs_script), account_id, "--force"]
|
||
if phone:
|
||
xhs_args.extend(["--phone", phone])
|
||
subprocess.run(xhs_args, cwd=str(SCRIPTS_DIR))
|
||
else:
|
||
# 抖音:login_identity.py 开浏览器手动登录
|
||
login_script = SCRIPTS_DIR / "login_identity.py"
|
||
if not login_script.exists():
|
||
print(f"❌ 登录脚本不存在: {login_script}")
|
||
return
|
||
print(f"\n🚀 打开浏览器: {account_id}")
|
||
import subprocess
|
||
subprocess.run([sys.executable, str(login_script), account_id], cwd=str(SCRIPTS_DIR))
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 平台插件命令路由
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_platform(args, plat_name, plat_inst):
|
||
"""路由 mc [platform] [action] 到插件实现"""
|
||
action = args.action
|
||
account = args.account or "default"
|
||
|
||
# 参数映射
|
||
kwargs = {"account_name": account}
|
||
if action == "publish":
|
||
kwargs["file_path"] = args.file
|
||
kwargs["title"] = args.title
|
||
kwargs["desc"] = args.desc
|
||
if action == "nurture":
|
||
kwargs["blueprint"] = args.blueprint
|
||
|
||
# 调用插件方法
|
||
method = getattr(plat_inst, action, None)
|
||
if not method:
|
||
print(f"❌ {plat_name} 不支持 {action}")
|
||
return
|
||
|
||
result = method(**kwargs)
|
||
|
||
if args.json:
|
||
print(json.dumps(result, ensure_ascii=False))
|
||
else:
|
||
status = result.get("status", "error")
|
||
icon = "✅" if status == "ok" else "❌"
|
||
msg = result.get("message") or result.get("output", "")[:100] or status
|
||
print(f"{icon} [{plat_name}] {action}: {msg}")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 远程多机命令
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def _lookup_machines():
|
||
"""读取机器注册表, 返回 {hostname: {ip, user, ...}}
|
||
|
||
数据来源 (优先级):
|
||
1. agent-local/runtime/machines.json (手动注册)
|
||
2. ORACLE.yaml (联邦宪法,机器定义的一级来源)
|
||
3. 04_memory/cross_machine/machines/ (git 同步的心跳数据)
|
||
"""
|
||
machines = {}
|
||
|
||
# 来源1: 本机注册表
|
||
local_reg = AGENT_LOCAL / "runtime" / "machines.json"
|
||
if local_reg.exists():
|
||
try:
|
||
machines.update(json.loads(local_reg.read_text()))
|
||
except: pass
|
||
|
||
# 来源2: ORACLE.yaml (最高优先级的机器定义)
|
||
oracle_path = _find_oracle()
|
||
if oracle_path:
|
||
try:
|
||
import yaml
|
||
with open(oracle_path) as f:
|
||
oracle = yaml.safe_load(f)
|
||
for name, info in oracle.get("machines", {}).items():
|
||
hn = info.get("hostname", name)
|
||
if hn not in machines:
|
||
machines[hn] = {
|
||
"hostname": hn,
|
||
"ip": info.get("tailscale_ip", ""),
|
||
"port": info.get("dashboard_port", 9988),
|
||
"user": info.get("ssh_user", ""),
|
||
"via": "ssh",
|
||
"oracle_name": name,
|
||
}
|
||
except Exception:
|
||
pass # ORACLE 可选的,无报错
|
||
|
||
# 来源3: cross_machine 目录 (从 Gitee 同步的远程心跳信息)
|
||
cross_dir = AGENT_SYNC / "04_memory" / "cross_machine" / "machines"
|
||
if cross_dir.exists():
|
||
for uid_dir in sorted(cross_dir.iterdir()):
|
||
if uid_dir.is_dir():
|
||
hb = uid_dir / "heartbeat.json"
|
||
if hb.exists():
|
||
try:
|
||
d = json.loads(hb.read_text())
|
||
hn = d.get("machine_name", d.get("hostname", ""))
|
||
if hn and hn not in machines:
|
||
machines[hn] = {
|
||
"hostname": hn,
|
||
"machine_uid": d.get("machine_uid", ""),
|
||
"ip": "127.0.0.1",
|
||
"port": 9988,
|
||
"user": d.get("ssh_user", d.get("user", "")),
|
||
"status": d.get("status", "unknown"),
|
||
}
|
||
except: pass
|
||
|
||
# 没找到则返回本机
|
||
if not machines:
|
||
import socket
|
||
hn = socket.gethostname()
|
||
machines[hn] = {
|
||
"hostname": hn,
|
||
"ip": "127.0.0.1",
|
||
"port": 9988,
|
||
"user": os.environ.get("USER", ""),
|
||
}
|
||
|
||
return machines
|
||
|
||
|
||
def _find_oracle():
|
||
"""查找 ORACLE.yaml 文件位置"""
|
||
# 尝试多个可能的位置
|
||
candidates = [
|
||
AGENT_SYNC / "ORACLE.yaml",
|
||
AGENT_SYNC / "oracle.yaml",
|
||
Path.home() / "workbuddy-agent-os" / "agent-sync" / "ORACLE.yaml",
|
||
]
|
||
# 如果 AGENT_SYNC 未设置,也尝试工作目录的父目录
|
||
if not AGENT_SYNC:
|
||
cwd = Path.cwd()
|
||
for p in [cwd, cwd.parent, cwd.parent.parent]:
|
||
candidates.insert(0, p / "ORACLE.yaml")
|
||
|
||
for p in candidates:
|
||
if p.exists():
|
||
return str(p)
|
||
return None
|
||
|
||
|
||
def _http_call(host_info, path, method="GET", data=None):
|
||
"""通过 HTTP API 调用远程机器 (Tailscale/局域网)"""
|
||
try:
|
||
import requests as req_lib
|
||
except ImportError:
|
||
return {"status": "error", "message": "requests 库未安装, 请 pip install requests"}
|
||
|
||
ip = host_info.get("ip", host_info.get("hostname", "localhost"))
|
||
port = host_info.get("port", 9988)
|
||
url = f"http://{ip}:{port}{path}"
|
||
|
||
try:
|
||
# 绕过系统代理 (SOCKS5 代理可能导致 502)
|
||
no_proxy = {"http": "", "https": ""}
|
||
headers = {"User-Agent": "mc-remote/1.0"}
|
||
|
||
if method == "GET":
|
||
r = req_lib.get(url, timeout=10, proxies=no_proxy, headers=headers)
|
||
else:
|
||
r = req_lib.post(url, json=data, timeout=10, proxies=no_proxy, headers=headers)
|
||
|
||
if r.status_code == 200:
|
||
return r.json()
|
||
return {"status": "error", "message": f"HTTP {r.status_code}: {r.text[:200]}"}
|
||
except req_lib.exceptions.ConnectionError:
|
||
return {"status": "error", "message": "连接失败 (Connection refused)"}
|
||
except req_lib.exceptions.Timeout:
|
||
return {"status": "error", "message": "超时 (10s)"}
|
||
except Exception as e:
|
||
return {"status": "error", "message": str(e)}
|
||
|
||
|
||
def _ssh_call(host_info, command):
|
||
"""通过 SSH 在远程机器执行命令"""
|
||
import subprocess
|
||
|
||
user = host_info.get("user", "")
|
||
ip = host_info.get("ip", host_info.get("hostname", ""))
|
||
ssh_target = f"{user}@{ip}" if user else ip
|
||
|
||
# 自动设置环境变量(每台机器可能不同,从 ORACLE 获取路径)
|
||
# 远程运行时会先 source 环境变量再执行命令
|
||
env_setup = "export AGENT_SYNC=\"$HOME/workbuddy-agent-os/agent-sync\"; "
|
||
env_setup += "export AGENT_LOCAL=\"$HOME/workbuddy-agent-os/agent-local\"; "
|
||
wrapped_cmd = f"{env_setup} {command}"
|
||
|
||
full_cmd = ["ssh", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=no",
|
||
ssh_target, wrapped_cmd]
|
||
|
||
try:
|
||
r = subprocess.run(full_cmd, capture_output=True, text=True, timeout=60)
|
||
return {
|
||
"status": "ok" if r.returncode == 0 else "error",
|
||
"returncode": r.returncode,
|
||
"stdout": r.stdout[:5000],
|
||
"stderr": r.stderr[:500],
|
||
}
|
||
except subprocess.TimeoutExpired:
|
||
return {"status": "error", "message": "SSH 超时 (60s)"}
|
||
except FileNotFoundError:
|
||
return {"status": "error", "message": "ssh 命令未找到"}
|
||
except Exception as e:
|
||
return {"status": "error", "message": str(e)}
|
||
|
||
|
||
def cmd_remote(args):
|
||
"""mc remote — 远程多机管理"""
|
||
|
||
if args.remote_action == "list":
|
||
machines = _lookup_machines()
|
||
if args.json:
|
||
print(json.dumps(machines, ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
print(f"\n📡 已注册机器 ({len(machines)}):")
|
||
for hn, info in machines.items():
|
||
ip = info.get("ip", "?")
|
||
port = info.get("port", 9988)
|
||
user = info.get("user", "")
|
||
via = info.get("via", "http")
|
||
print(f" {hn:20s} {user:10s} {ip:15s}:{port} (via {via})")
|
||
return
|
||
|
||
if args.remote_action == "ping":
|
||
machines = _lookup_machines()
|
||
if args.host:
|
||
machines = {k: v for k, v in machines.items()
|
||
if args.host in k or args.host in v.get("ip", "")}
|
||
|
||
results = {}
|
||
for hn, info in machines.items():
|
||
r = _http_call(info, "/api/health")
|
||
results[hn] = {
|
||
"reachable": r.get("status") == "ok",
|
||
"response": r.get("hostname", ""),
|
||
}
|
||
|
||
if args.json:
|
||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
print(f"\n📡 连通性测试 ({len(results)}):")
|
||
for hn, r in results.items():
|
||
icon = "✅" if r["reachable"] else "❌"
|
||
echo = r.get("response", "")[:20]
|
||
print(f" {icon} {hn:20s} {echo}")
|
||
return
|
||
|
||
if args.remote_action == "status":
|
||
machines = _lookup_machines()
|
||
if args.host:
|
||
machines = {k: v for k, v in machines.items()
|
||
if args.host in k or args.host in v.get("ip", "")}
|
||
|
||
results = {}
|
||
for hn, info in machines.items():
|
||
r = _http_call(info, "/api/machine/status")
|
||
results[hn] = r
|
||
|
||
if args.json:
|
||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
print(f"\n📊 多机状态 ({len(results)}):")
|
||
for hn, r in results.items():
|
||
status_icon = "✅" if r.get("status") == "ok" or "hostname" in r else "❌"
|
||
mat = r.get("matrix", {})
|
||
hp = r.get("homepage_info", {})
|
||
disk = r.get("disk", {})
|
||
guardd = r.get("guardd", {})
|
||
guardd_icon = "🟢" if guardd.get("running") else "🔴"
|
||
collected = hp.get("collected_at", "")[:16] if hp.get("collected_at") else "未采集"
|
||
print(f" {status_icon} {hn:20s} {'v'+r.get('version','?'):10s}"
|
||
f" 账号:{mat.get('total_accounts','?')} 已登录:{mat.get('logged_in_accounts','?')}")
|
||
print(f" {guardd_icon} guardd | 🕐 {collected} | 💾 {disk.get('free_gb','?')}G 剩余")
|
||
return
|
||
|
||
if args.remote_action == "exec":
|
||
command = " ".join(args.command)
|
||
host = args.host
|
||
via = args.via
|
||
|
||
machines = _lookup_machines()
|
||
host_info = None
|
||
for hn, info in machines.items():
|
||
if host in hn or host in info.get("ip", ""):
|
||
host_info = info
|
||
break
|
||
|
||
if not host_info:
|
||
host_info = {"ip": host, "port": 9988, "via": "http"}
|
||
|
||
# HTTP 方式 (Tailscale/局域网)
|
||
if via in ("auto", "http"):
|
||
result = _http_call(host_info, "/api/machine/exec", method="POST",
|
||
data={"command": command})
|
||
if result.get("status") == "ok" or "stdout" in result:
|
||
if args.json:
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
else:
|
||
out = result.get("stdout", "")
|
||
err = result.get("stderr", "")
|
||
if out:
|
||
print(out[:5000])
|
||
if err:
|
||
print(f"⚠️ stderr: {err}")
|
||
return
|
||
elif via == "http":
|
||
print(f"❌ HTTP 执行失败: {result.get('message','')}")
|
||
return
|
||
|
||
# SSH 方式
|
||
if via in ("auto", "ssh"):
|
||
result = _ssh_call(host_info, command)
|
||
if result.get("status") == "ok" or result.get("returncode") == 0:
|
||
if args.json:
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
else:
|
||
print(result.get("stdout", "")[:5000])
|
||
if result.get("stderr", ""):
|
||
print(f"⚠️ stderr: {result['stderr'][:500]}")
|
||
return
|
||
elif via == "ssh":
|
||
print(f"❌ SSH 执行失败: {result.get('message','')}")
|
||
return
|
||
|
||
print(f"❌ 无法连接到 {host}: HTTP 和 SSH 均失败")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# publish — 发布内容
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def cmd_publish(args):
|
||
"""mc publish — 发布视频/图文到平台"""
|
||
import subprocess
|
||
publish_script = SCRIPTS_DIR / "publish_video.py"
|
||
if not publish_script.exists():
|
||
print(f"❌ 发布脚本不存在: {publish_script}")
|
||
return 1
|
||
|
||
cmd = [sys.executable, str(publish_script), args.platform,
|
||
"--account", args.account, "--file", args.file]
|
||
if args.title:
|
||
cmd += ["--title", args.title]
|
||
if args.desc:
|
||
cmd += ["--desc", args.desc]
|
||
|
||
print(f"📤 发布到 {args.platform}: {args.file}")
|
||
result = subprocess.run(cmd, cwd=str(SCRIPTS_DIR))
|
||
return result.returncode
|
||
|
||
|
||
def cmd_config(args):
|
||
"""mc config — 查看系统配置"""
|
||
import yaml
|
||
print(f"\n{'='*55}")
|
||
print(" 📋 系统配置")
|
||
print(f"{'='*55}")
|
||
|
||
# 蓝图
|
||
bp_dir = TOOL_DIR / "blueprints"
|
||
bps = sorted(bp_dir.glob("*.json"))
|
||
print(f"\n 蓝图 ({len(bps)} 个):")
|
||
for bp in bps:
|
||
import json
|
||
try:
|
||
d = json.loads(bp.read_text())
|
||
steps = len(d.get("steps", []))
|
||
plat = d.get("platform", "?")
|
||
print(f" • {bp.stem:25s} ({plat:12s} {steps}步)")
|
||
except:
|
||
print(f" • {bp.stem} (读取失败)")
|
||
|
||
# 身份
|
||
from matrix_mgmt import AGENT_LOCAL
|
||
identities_root = AGENT_LOCAL / "tools" / "matrix" / "identities"
|
||
idents = sorted([d.name for d in identities_root.iterdir() if d.is_dir()]) if identities_root.exists() else []
|
||
print(f"\n 本地身份 ({len(idents)} 个):")
|
||
for name in idents:
|
||
ud = identities_root / name / "user_data"
|
||
has_cookie = len(list(ud.glob("*.sqlite"))) > 0 if ud.exists() else False
|
||
print(f" {'✅' if has_cookie else '⬜'} {name}")
|
||
|
||
print()
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 主解析器
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
def build_parser(subparsers=None, plugin_name="mc"):
|
||
"""构建参数解析器
|
||
|
||
Args:
|
||
subparsers: 如果提供,作为 agentos 插件的子命令注册
|
||
plugin_name: 插件名(默认 mc)
|
||
"""
|
||
if subparsers:
|
||
parser = subparsers.add_parser(
|
||
plugin_name,
|
||
help="Matrix Console — 矩阵养号统一命令入口",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
mc run --accounts douyin_01,douyin_04 --blueprints browse_v2 --rounds 10
|
||
mc run --accounts douyin_01 --blueprints browse_v2,comment --rounds 5 --mix
|
||
mc account list
|
||
mc account login douyin_04
|
||
mc status all
|
||
mc proxy list
|
||
mc corpus list
|
||
""",
|
||
)
|
||
else:
|
||
parser = argparse.ArgumentParser(
|
||
prog="mc",
|
||
description="Matrix Console — 矩阵养号统一命令入口",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
mc run --accounts douyin_01,douyin_04 --blueprints browse_v2 --rounds 10
|
||
mc run --accounts douyin_01 --blueprints browse_v2,comment --rounds 5 --mix
|
||
mc account list
|
||
mc account login douyin_04
|
||
mc status all
|
||
mc proxy list
|
||
mc corpus list
|
||
""",
|
||
)
|
||
parser.add_argument("-v", "--verbose", action="store_true", help="详细输出")
|
||
parser.add_argument("--json", action="store_true", help="JSON 输出格式")
|
||
parser.add_argument("--log", default="", help="日志文件路径")
|
||
sub = parser.add_subparsers(dest="command", required=True)
|
||
|
||
# ── run ──
|
||
p_run = sub.add_parser("run", help="批量执行养号任务")
|
||
p_run.add_argument("--accounts", required=True, help="账号ID列表,逗号分隔")
|
||
p_run.add_argument("--blueprints", required=True, help="蓝图ID列表,逗号分隔")
|
||
p_run.add_argument("--rounds", type=int, default=10, help="每轮循环次数")
|
||
p_run.add_argument("--interval", default="30-60", help="轮间隔范围(秒)")
|
||
p_run.add_argument("--stagger", default="15-30", help="身份组间错峰延迟(秒)")
|
||
p_run.add_argument("--max-browsers", type=int, default=3, help="最大同时浏览器数(默认3)")
|
||
p_run.add_argument("--direction", default="", help="评论方向(称赞/提问/共鸣/感慨/客观)")
|
||
p_run.add_argument("--corpus", default="", help="语料分类,逗号分隔")
|
||
p_run.add_argument("--url", default="", help="视频链接(互动蓝图用)")
|
||
p_run.add_argument("--comment-text", default="", help="评论内容(互动蓝图用,支持@corpus)")
|
||
p_run.add_argument("--reply-text", default="", help="回复内容(互动蓝图用)")
|
||
p_run.add_argument("--engine", default="auto", choices=["chrome", "camoufox", "auto"], help="浏览器引擎")
|
||
p_run.add_argument("--mix", action="store_true", help="混合随机模式(每轮随机选蓝图)")
|
||
p_run.add_argument("--daemon", action="store_true", help="后台运行")
|
||
p_run.add_argument("--keep", action="store_true", help="执行完毕后保留浏览器不关闭")
|
||
p_run.add_argument("--proxy", default="auto", help="代理策略")
|
||
p_run.set_defaults(func=cmd_run)
|
||
|
||
# ── account ──
|
||
p_acct = sub.add_parser("account", help="账号管理")
|
||
p_acct.add_argument("action", choices=["list", "login", "status", "export", "import", "create"])
|
||
p_acct.add_argument("name", nargs="?", default="", help="账号ID")
|
||
p_acct.add_argument("--platform", default="douyin", choices=["douyin", "xiaohongshu"], help="平台")
|
||
p_acct.add_argument("--path", default="", help="导入ZIP路径 (import)")
|
||
p_acct.set_defaults(func=cmd_account)
|
||
|
||
# ── blueprint ──
|
||
p_bp = sub.add_parser("blueprint", help="蓝图管理")
|
||
p_bp.add_argument("action", choices=["list", "show"])
|
||
p_bp.add_argument("name", nargs="?", default="", help="蓝图名称")
|
||
p_bp.set_defaults(func=cmd_blueprint)
|
||
|
||
# ── corpus ──
|
||
p_corpus = sub.add_parser("corpus", help="语料库管理")
|
||
p_corpus.add_argument("action", choices=["list", "add"])
|
||
p_corpus.add_argument("--category", default="", help="分类名称")
|
||
p_corpus.add_argument("--text", default="", help="评论文本")
|
||
p_corpus.set_defaults(func=cmd_corpus)
|
||
|
||
# ── proxy ──
|
||
p_proxy = sub.add_parser("proxy", help="代理管理")
|
||
p_proxy.add_argument("action", choices=["list", "test", "set"])
|
||
p_proxy.add_argument("--proxy", default="", help="代理地址")
|
||
p_proxy.add_argument("--account", default="", help="账号ID")
|
||
p_proxy.set_defaults(func=cmd_proxy)
|
||
|
||
# ── sms ──
|
||
p_sms = sub.add_parser("sms", help="短信管理")
|
||
p_sms.add_argument("action", choices=["config", "test"])
|
||
p_sms.add_argument("--phone", default="", help="手机号")
|
||
p_sms.set_defaults(func=cmd_sms)
|
||
|
||
# ── status ──
|
||
p_status = sub.add_parser("status", help="状态检查")
|
||
p_status.add_argument("action", choices=["all", "accounts", "browsers", "log"])
|
||
p_status.add_argument("--log-file", default="", help="日志文件路径")
|
||
p_status.set_defaults(func=cmd_status)
|
||
|
||
# ── record — 原子操作录制 ──
|
||
p_rec = sub.add_parser("record", help="原子操作录制/分析/导出")
|
||
rec_sub = p_rec.add_subparsers(dest="record_action")
|
||
|
||
p_rec_start = rec_sub.add_parser("start", help="开始录制")
|
||
p_rec_start.add_argument("--account", default="douyin_01", help="账号ID")
|
||
p_rec_start.add_argument("--platform", default="auto", choices=["auto", "douyin", "xiaohongshu"], help="平台")
|
||
p_rec_start.set_defaults(func=cmd_record)
|
||
|
||
p_rec_analyze = rec_sub.add_parser("analyze", help="分析录制包")
|
||
p_rec_analyze.add_argument("recording_path", help="录制包路径")
|
||
p_rec_analyze.set_defaults(func=cmd_record)
|
||
|
||
p_rec_export = rec_sub.add_parser("export", help="导出录制包为蓝图+代码")
|
||
p_rec_export.add_argument("recording_path", help="录制包路径")
|
||
p_rec_export.set_defaults(func=cmd_record)
|
||
|
||
p_rec_list = rec_sub.add_parser("list", help="列出所有录制包")
|
||
p_rec_list.set_defaults(func=cmd_record)
|
||
|
||
p_rec_delete = rec_sub.add_parser("delete", help="删除录制包")
|
||
p_rec_delete.add_argument("recording_path", help="录制包路径")
|
||
p_rec_delete.set_defaults(func=cmd_record)
|
||
|
||
p_rec.set_defaults(func=cmd_record)
|
||
|
||
# ── task — 智能任务 ──
|
||
p_task = sub.add_parser("task", help="智能任务:自动选账号/蓝图/填参数")
|
||
p_task.add_argument("task_type", nargs="?", choices=["comment", "search", "collect", "reply"],
|
||
help="任务类型")
|
||
p_task.add_argument("--url", default="", help="视频链接(定向评论用)")
|
||
p_task.add_argument("--keyword", default="", help="搜索/采集关键词")
|
||
p_task.add_argument("--direction", default="", help="评论方向:正面/提问/共鸣/感慨")
|
||
p_task.add_argument("--comment", default="", help="指定评论内容(默认自动生成)")
|
||
p_task.add_argument("--account", default="", help="指定账号(默认自动选)")
|
||
p_task.add_argument("--rounds", type=int, default=1, help="执行轮数")
|
||
p_task.add_argument("--list", action="store_true", help="列出可选任务类型")
|
||
p_task.add_argument("-y", "--yes", action="store_true", help="跳过确认直接执行")
|
||
p_task.set_defaults(func=cmd_task)
|
||
|
||
# ── schedule — 定时任务 ──
|
||
p_sched = sub.add_parser("schedule", help="定时任务管理")
|
||
p_sched.add_argument("action", choices=["list", "add", "remove", "start", "history"],
|
||
help="list=列出 add=添加 remove=删除 start=启动调度器 history=查看历史")
|
||
p_sched.add_argument("--id", default="", help="任务ID")
|
||
p_sched.add_argument("--account", default="", help="账号")
|
||
p_sched.add_argument("--blueprint", default="", help="蓝图")
|
||
p_sched.add_argument("--time", default="09:00", help="执行时间 HH:MM")
|
||
p_sched.add_argument("--days", default="1,2,3,4,5,6,7", help="运行日 1=周一..7=周日,逗号分隔")
|
||
p_sched.add_argument("--rounds", type=int, default=1, help="轮数")
|
||
p_sched.add_argument("--args", default="", help="额外参数 keyword=xxx")
|
||
p_sched.set_defaults(func=cmd_schedule)
|
||
|
||
# ── op — 原子操作注册/删除 ──
|
||
p_op = sub.add_parser("op", help="原子操作注册/删除")
|
||
op_sub = p_op.add_subparsers(dest="op_action")
|
||
|
||
p_op_reg = op_sub.add_parser("register", help="注册原子操作")
|
||
p_op_reg.add_argument("--name", required=True, help="操作名")
|
||
p_op_reg.add_argument("--from-blueprint", default="", help="从蓝图导入")
|
||
p_op_reg.add_argument("--source", default="recorded", choices=["recorded", "manual"], help="来源标记")
|
||
p_op_reg.add_argument("--platform", default="通用", help="平台")
|
||
p_op_reg.set_defaults(func=cmd_op)
|
||
|
||
p_op_del = op_sub.add_parser("delete", help="删除原子操作")
|
||
p_op_del.add_argument("--name", required=True, help="操作名")
|
||
p_op_del.set_defaults(func=cmd_op)
|
||
|
||
p_op_list = op_sub.add_parser("list", help="列出所有原子操作")
|
||
p_op_list.set_defaults(func=cmd_op)
|
||
|
||
# ── collect — 主页信息采集 ──
|
||
p_collect = sub.add_parser("collect", help="采集账号主页信息")
|
||
p_collect.add_argument("--all", action="store_true", help="采集所有身份")
|
||
p_collect.add_argument("--phone", default="", help="按手机号采集")
|
||
p_collect.add_argument("--account", default="", help="按账号ID采集")
|
||
p_collect.add_argument("--status", action="store_true", help="查看采集进度")
|
||
p_collect.set_defaults(func=cmd_collect)
|
||
|
||
# ── distill — 内容蒸馏 ──
|
||
p_distill = sub.add_parser("distill", help="蒸馏 01_submissions 为结构化报告")
|
||
p_distill.add_argument("--mode", choices=["extract", "llm"], default="extract",
|
||
help="extract=元数据提取 llm=AI摘要生成")
|
||
p_distill.add_argument("--source", default="01_submissions", help="源目录")
|
||
p_distill.add_argument("--output", default="", help="输出目录")
|
||
p_distill.add_argument("--batch-size", type=int, default=10, help="每批篇数(llm模式)")
|
||
p_distill.set_defaults(func=cmd_distill)
|
||
|
||
# ── interact — 评论互动 ──
|
||
p_interact = sub.add_parser("interact", help="评论互动:定向评论/三级接力/点赞/热评")
|
||
p_interact.add_argument("--url", required=True, help="目标视频链接")
|
||
p_interact.add_argument("--accounts", required=True, help="账号ID,逗号分隔")
|
||
p_interact.add_argument("--strategy", choices=["comment","chain","like","hot"], default="comment",
|
||
help="互动策略(默认定向评论)")
|
||
p_interact.add_argument("--direction", default="", help="评论方向:称赞/提问/共鸣/感慨")
|
||
p_interact.add_argument("--corpus", default="", help="语料分类")
|
||
p_interact.add_argument("--blueprint", default="", help="蓝图名(覆盖自动选择)")
|
||
p_interact.add_argument("--interval", default="300-600", help="步间间隔秒数(默认300-600)")
|
||
p_interact.add_argument("--dry-run", action="store_true", help="仅预览不执行")
|
||
p_interact.set_defaults(func=cmd_interact)
|
||
|
||
# ── login — 账号登录 ──
|
||
p_login = sub.add_parser("login", help="打开浏览器登录账号")
|
||
p_login.add_argument("--phone", default="", help="按手机号登录")
|
||
p_login.add_argument("--account", default="", help="按账号ID登录")
|
||
p_login.add_argument("--platform", default="auto", choices=["auto", "douyin", "xiaohongshu"], help="平台")
|
||
p_login.set_defaults(func=cmd_login)
|
||
|
||
# ── smart-login — 智能登录(自动检测平台+状态+全自动)──
|
||
p_smart = sub.add_parser("smart-login", help="智能登录: 自动检测平台+状态+全自动")
|
||
p_smart.add_argument("account_id", help="账号 ID (如 douyin_01 / xhs_01)")
|
||
p_smart.add_argument("--phone", "-p", default="", help="手机号(选填)")
|
||
p_smart.add_argument("--timeout", "-t", type=int, default=30,
|
||
help="浏览器超时自动关闭分钟数(默认 30)")
|
||
p_smart.add_argument("--skip-check", action="store_true",
|
||
help="跳过登录态前置检测(强制重新登录)")
|
||
p_smart.set_defaults(func=cmd_smart_login)
|
||
|
||
# ── remote — 远程多机命令 ──
|
||
p_remote = sub.add_parser("remote", help="远程多机管理 (通过 Tailscale/SSH/HTTP)")
|
||
remote_sub = p_remote.add_subparsers(dest="remote_action")
|
||
|
||
p_remote_exec = remote_sub.add_parser("exec", help="在远程机器执行命令")
|
||
p_remote_exec.add_argument("host", help="远程机器 hostname 或 Tailscale IP")
|
||
p_remote_exec.add_argument("command", nargs="+", help="要执行的命令, 如 'mc status --json'")
|
||
p_remote_exec.add_argument("--via", default="auto", choices=["auto", "http", "ssh"],
|
||
help="通信方式: auto/http/ssh")
|
||
p_remote_exec.set_defaults(func=cmd_remote)
|
||
|
||
p_remote_status = remote_sub.add_parser("status", help="查看所有/指定机器状态")
|
||
p_remote_status.add_argument("host", nargs="?", default="", help="指定机器 (默认全部)")
|
||
p_remote_status.add_argument("--via", default="auto", choices=["auto", "http", "ssh"],
|
||
help="通信方式")
|
||
p_remote_status.set_defaults(func=cmd_remote)
|
||
|
||
p_remote_ping = remote_sub.add_parser("ping", help="测试机器连通性")
|
||
p_remote_ping.add_argument("host", nargs="?", default="", help="指定机器 (默认全部)")
|
||
p_remote_ping.set_defaults(func=cmd_remote)
|
||
|
||
p_remote_list = remote_sub.add_parser("list", help="列出已注册机器")
|
||
p_remote_list.set_defaults(func=cmd_remote)
|
||
|
||
# ══════════════════════════════════════════════════════
|
||
# 动态发现平台插件: mc [platform] [action] --account <name>
|
||
# ══════════════════════════════════════════════════════
|
||
try:
|
||
TOOL_DIR = SCRIPTS_DIR.parent
|
||
sys.path.insert(0, str(TOOL_DIR))
|
||
from platforms import discover_platforms
|
||
platforms = discover_platforms()
|
||
for plat_name, plat_inst in platforms.items():
|
||
p_plat = sub.add_parser(plat_name, help=f"{plat_inst.display_name} 操作")
|
||
p_plat.add_argument("action", choices=["login", "collect", "status", "publish", "nurture"],
|
||
help="操作类型")
|
||
p_plat.add_argument("--account", default="", help="账号ID或手机号")
|
||
p_plat.add_argument("--file", default="", help="发布文件路径")
|
||
p_plat.add_argument("--title", default="", help="发布标题")
|
||
p_plat.add_argument("--desc", default="", help="发布描述")
|
||
p_plat.add_argument("--blueprint", default="daily", help="养号蓝图")
|
||
p_plat.set_defaults(func=lambda a, pn=plat_name, pi=plat_inst: cmd_platform(a, pn, pi))
|
||
except Exception as e:
|
||
pass # 插件加载失败不影响其他命令
|
||
|
||
# ── publish — 发布内容到平台 ──
|
||
p_publish = sub.add_parser("publish", help="发布视频/图文到平台")
|
||
p_publish.add_argument("platform", choices=["douyin", "xiaohongshu"], help="目标平台")
|
||
p_publish.add_argument("--account", required=True, help="账号ID")
|
||
p_publish.add_argument("--file", required=True, help="文件路径")
|
||
p_publish.add_argument("--title", default="", help="标题")
|
||
p_publish.add_argument("--desc", default="", help="描述")
|
||
p_publish.set_defaults(func=cmd_publish)
|
||
|
||
# ── config — 配置展示 ──
|
||
p_config = sub.add_parser("config", help="查看系统配置")
|
||
p_config.add_argument("action", choices=["show"])
|
||
p_config.set_defaults(func=cmd_config)
|
||
|
||
return parser
|
||
|
||
|
||
def main():
|
||
parser = build_parser()
|
||
args = parser.parse_args()
|
||
|
||
# ── 检测是否可以通过 agentos 执行 ──
|
||
# agentos 是最终目标 CLI,mc 是过渡桥接
|
||
# 当 agentos 可用时,转发命令到 agentos,mc 只做壳
|
||
if hasattr(args, 'subcommand') or hasattr(args, 'func'):
|
||
try:
|
||
_try_forward_to_agentos(args)
|
||
except Exception:
|
||
pass # 转发失败,回退到 mc 执行
|
||
|
||
# ── 关闭 FastAPI logger 的噪音 ──
|
||
logging.getLogger().setLevel(logging.INFO if args.verbose else logging.WARNING)
|
||
|
||
if not args.json:
|
||
print_banner()
|
||
if hasattr(args, "func"):
|
||
args.func(args)
|
||
else:
|
||
parser.print_help()
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# agentos 转发
|
||
# ════════════════════════════════════════════════════════════
|
||
|
||
# mc 子命令 → agentos domain 映射表
|
||
_AGENTOS_DOMAIN_MAP = {
|
||
'run': 'matrix', 'account': 'matrix',
|
||
'blueprint': 'matrix', 'corpus': 'matrix',
|
||
'login': 'matrix', 'smart-login': 'matrix',
|
||
'publish': 'matrix', 'task': 'matrix', 'op': 'matrix', 'record': 'matrix',
|
||
'distill': 'knowledge',
|
||
'interact': 'matrix',
|
||
'status': 'fleet', 'remote': 'fleet',
|
||
'schedule': 'serve', 'proxy': 'serve', 'sms': 'serve',
|
||
}
|
||
|
||
|
||
def _try_forward_to_agentos(args):
|
||
"""尝试将 mc 命令转发到 agentos
|
||
|
||
防递归:如果已经在 agentos 转发链中(环境变量标记),直接跳过。
|
||
避免 mc → agentos → mc → agentos → ... 无限循环。
|
||
"""
|
||
if os.environ.get("MC_FORWARDED_FROM_AGENTOS"):
|
||
return
|
||
# 设置环境变量标记,防止子进程(agentos)再转发回 mc
|
||
os.environ["MC_FORWARDED_FROM_AGENTOS"] = "1"
|
||
import subprocess, sys
|
||
from pathlib import Path
|
||
|
||
# 确定 mc 的子命令(从 args 或 func 名)
|
||
mc_cmd = None
|
||
if hasattr(args, 'subcommand') and args.subcommand:
|
||
mc_cmd = args.subcommand
|
||
elif hasattr(args, 'remote_action') and args.remote_action:
|
||
mc_cmd = 'remote'
|
||
elif hasattr(args, 'func'):
|
||
fn = args.func.__name__
|
||
if fn.startswith('cmd_'):
|
||
mc_cmd = fn[4:]
|
||
|
||
if not mc_cmd or mc_cmd not in _AGENTOS_DOMAIN_MAP:
|
||
return # 无法映射到 agentos 领域
|
||
|
||
domain = _AGENTOS_DOMAIN_MAP[mc_cmd]
|
||
|
||
# 构建 python -m agentos 命令
|
||
# sys.argv = ['mc', '<subcommand>', '<args>...']
|
||
# → ['python', '-m', 'agentos', '<domain>', '<subcommand>', '<args>...']
|
||
python = sys.executable
|
||
agentos_args = [python, '-m', 'agentos', domain] + sys.argv[1:]
|
||
|
||
try:
|
||
result = subprocess.run(agentos_args, capture_output=False)
|
||
if result.returncode == 0:
|
||
sys.exit(0)
|
||
# agentos 执行失败,回退到 mc
|
||
except Exception:
|
||
pass # 走 mc 原始逻辑
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|