移植自 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 带网关原话 / 正常返回)。
2114 lines
96 KiB
Python
2114 lines
96 KiB
Python
"""
|
||
command_bus.py — 统一命令传输层 v6
|
||
|
||
设计原则:
|
||
v5 (2026-06-23): 按机器分组,每台机器只发一条命令(含多个账号)
|
||
v6 (2026-06-25): 每台机器一个执行队列,串行执行
|
||
|
||
核心机制:
|
||
1. 路由层不拆解命令 — 传全部账号,由 MC 引擎按 identity 分组
|
||
2. 命令直接发到 guardd 调度器 — 不再排队串行化
|
||
3. guardd 调度器内部管理 3 个 slot 和优先级队列
|
||
4. MachineSession 仅做状态追踪和历史记录
|
||
5. 不同机器之间并行执行(互不影响)
|
||
6. 同一机器多任务并发由 guardd 调度器控制
|
||
7. 强制停止 → cancel() 杀进程+清浏览器
|
||
|
||
架构:
|
||
Dashboard → API → CommandBus → MachineSession(队列) → mc run → 引擎 → Camoufox
|
||
"""
|
||
|
||
import asyncio, copy, json, logging, os, shlex, signal, socket, subprocess, sys, time, threading, urllib.request, urllib.error
|
||
from datetime import datetime, timezone
|
||
from enum import Enum
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
logger = logging.getLogger("dashboard.command_bus")
|
||
|
||
_THIS_DIR = Path(__file__).resolve().parent.parent
|
||
AGENT_SYNC = Path(os.environ.get("AGENT_SYNC", str(Path.home() / "workbuddy-agent-os" / "agent-sync")))
|
||
AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(Path.home() / "workbuddy-agent-os" / "agent-local")))
|
||
ORACLE_PATH = AGENT_SYNC / "ORACLE.yaml"
|
||
|
||
from utils.identity import resolve_hostname
|
||
|
||
HOSTNAME = resolve_hostname()
|
||
|
||
# ── guardd HTTP API 客户端(v7)──────────────────────────
|
||
GUARDD_PORT = 9090
|
||
|
||
def _guardd_url(machine: str = "") -> str:
|
||
"""获取目标机器的 guardd URL(本机用 localhost,远程用 Tailscale IP)"""
|
||
if not machine or machine == HOSTNAME:
|
||
return f"http://localhost:{GUARDD_PORT}"
|
||
info = _get_machine_info(machine)
|
||
ip = info.get("ip", "")
|
||
if ip:
|
||
return f"http://{ip}:{GUARDD_PORT}"
|
||
return ""
|
||
|
||
def _guardd_api(method, path, data=None, machine="", timeout=5):
|
||
"""call guardd HTTP API via socket (avoids urllib+Tailscale timeout issue)
|
||
|
||
Args:
|
||
timeout: 连接超时秒数(默认 5,clear-all 等批量操作建议 10)
|
||
"""
|
||
url = _guardd_url(machine)
|
||
if not url:
|
||
logger.warning("guardd_api: machine {} URL empty".format(machine))
|
||
return {}
|
||
host = url.replace("http://", "").replace("https://", "")
|
||
port = 9090
|
||
if ":" in host:
|
||
host, ps = host.split(":", 1)
|
||
port = int(ps)
|
||
try:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
s.settimeout(timeout)
|
||
s.connect((host, port))
|
||
if method == "GET":
|
||
s.sendall("GET {} HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n\r\n".format(path, host).encode())
|
||
elif method == "POST":
|
||
body = json.dumps(data or {}).encode()
|
||
req = "POST {} HTTP/1.0\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n".format(path, host, len(body))
|
||
s.sendall(req.encode() + body)
|
||
resp = b""
|
||
while True:
|
||
chunk = s.recv(4096)
|
||
if not chunk: break
|
||
resp += chunk
|
||
s.close()
|
||
if b"\r\n\r\n" in resp:
|
||
return json.loads(resp.split(b"\r\n\r\n", 1)[1].decode())
|
||
return {}
|
||
except socket.timeout:
|
||
logger.warning("guardd_api {} {}://{}:{}{} -> timeout".format(method, "http", host, port, path))
|
||
except (socket.error, OSError) as e:
|
||
logger.warning("guardd_api {} {}://{}:{}{} -> {}".format(method, "http", host, port, path, e))
|
||
except json.JSONDecodeError as e:
|
||
logger.warning("guardd_api {} {}://{}:{}{} -> JSON fail: {}".format(method, "http", host, port, path, e))
|
||
return {}
|
||
|
||
class CommandStatus(str, Enum):
|
||
QUEUED = "queued"
|
||
PREFLIGHTING = "preflighting"
|
||
PREFLIGHT_FAILED = "preflight_failed"
|
||
DISPATCHING = "dispatching"
|
||
RUNNING = "running"
|
||
COMPLETED = "completed"
|
||
FAILED = "failed"
|
||
TIMED_OUT = "timed_out"
|
||
CRASHED = "crashed"
|
||
CANCELLED = "cancelled"
|
||
|
||
@property
|
||
def is_terminal(self) -> bool:
|
||
return self in (CommandStatus.COMPLETED, CommandStatus.FAILED,
|
||
CommandStatus.TIMED_OUT, CommandStatus.CRASHED,
|
||
CommandStatus.CANCELLED, CommandStatus.PREFLIGHT_FAILED)
|
||
|
||
@property
|
||
def is_active(self) -> bool:
|
||
return self in (CommandStatus.PREFLIGHTING, CommandStatus.DISPATCHING, CommandStatus.RUNNING)
|
||
|
||
|
||
class Command:
|
||
"""一条命令的完整生命周期(v5:支持多账号)"""
|
||
|
||
def __init__(self, cmd_type: str, accounts: list, machine: str,
|
||
command_line: str, run_id: str, params: dict = None):
|
||
self.cmd_type = cmd_type
|
||
self.accounts = accounts # [str] — 账号ID列表
|
||
self.machine = machine
|
||
self.command_line = command_line
|
||
self.run_id = run_id
|
||
self.params = params or {}
|
||
|
||
self.status = CommandStatus.QUEUED
|
||
self.pid = None
|
||
self.message = ""
|
||
self.result = None
|
||
|
||
self.created_at = time.time()
|
||
self.started_at = None
|
||
self.completed_at = None
|
||
self.last_poll_at = None
|
||
self.log_path = None
|
||
|
||
@property
|
||
def account_display(self) -> str:
|
||
"""显示用:'douyin_test,xhs_01' 或 'douyin_test'"""
|
||
return ",".join(self.accounts)
|
||
|
||
@property
|
||
def elapsed_sec(self) -> float:
|
||
if self.started_at is None:
|
||
return 0
|
||
end = self.completed_at or time.time()
|
||
return end - self.started_at
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"run_id": self.run_id,
|
||
"type": self.cmd_type,
|
||
"accounts": self.accounts,
|
||
"account": self.accounts[0] if self.accounts else "",
|
||
"machine": self.machine,
|
||
"status": self.status.value,
|
||
"command": self.command_line,
|
||
"pid": self.pid,
|
||
"message": self.message,
|
||
"elapsed_sec": round(self.elapsed_sec, 1),
|
||
"created_at": datetime.fromtimestamp(self.created_at, tz=timezone.utc).isoformat(),
|
||
"started_at": datetime.fromtimestamp(self.started_at, tz=timezone.utc).isoformat() if self.started_at else None,
|
||
"completed_at": datetime.fromtimestamp(self.completed_at, tz=timezone.utc).isoformat() if self.completed_at else None,
|
||
"result": self.result,
|
||
"log_path": str(self.log_path) if self.log_path else None,
|
||
}
|
||
|
||
|
||
_ORACLE_MACHINES = None
|
||
|
||
def _load_profiles() -> dict:
|
||
"""加载 profiles.json 获取账号行业标记"""
|
||
import json
|
||
try:
|
||
from pathlib import Path
|
||
profiles_path = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "data" / "profiles.json"
|
||
if profiles_path.exists():
|
||
with open(profiles_path) as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
def _get_machine_info(machine_name: str) -> dict:
|
||
global _ORACLE_MACHINES
|
||
if _ORACLE_MACHINES is None:
|
||
if ORACLE_PATH.exists():
|
||
import yaml
|
||
_ORACLE_MACHINES = yaml.safe_load(ORACLE_PATH.read_text()).get("machines", {})
|
||
else:
|
||
_ORACLE_MACHINES = {}
|
||
for name, info in _ORACLE_MACHINES.items():
|
||
if name == machine_name or info.get("hostname") == machine_name:
|
||
return {"name": name, "hostname": info.get("hostname", name),
|
||
"ip": info.get("tailscale_ip", ""), "user": info.get("ssh_user", "")}
|
||
return {}
|
||
|
||
|
||
def _pkill_exact(account_id: str, ssh_target: str = ""):
|
||
"""精确匹配账号 ID 的僵尸进程清理(非精确不杀)
|
||
|
||
设计意图:只杀已经无人管理的残留僵尸进程,不碰 guardd 调度器正在管理的 active 任务。
|
||
使用方式:
|
||
- 先由调用方确认 guardd 无 active 任务(通过 _guardd_api 查询 /scheduler/tasks)
|
||
- 再调用本函数做僵尸兜底清理
|
||
|
||
匹配规则:
|
||
1. pgrep -f 取所有含 "mc run"/"mc task"/"mc collect"/"mc smart-login" 的进程 PID
|
||
2. ps 取每个 PID 的完整命令行
|
||
3. 解析 --accounts= 参数的值,按逗号分割
|
||
4. 仅当 account_id 在分割后的列表中精确匹配时才 kill
|
||
"""
|
||
if not account_id:
|
||
return
|
||
|
||
# 远程机器 → SSH 执行(guardd HTTP 优先,SSH 是兜底)
|
||
if ssh_target:
|
||
_ssh_remote_exact_pkill(ssh_target, account_id)
|
||
return
|
||
|
||
# 本地
|
||
prefixes = ["mc run", "mc task", "mc collect", "mc smart-login"]
|
||
for prefix in prefixes:
|
||
try:
|
||
r = subprocess.run(["pgrep", "-f", prefix], capture_output=True, text=True, timeout=3)
|
||
pids = r.stdout.strip().split()
|
||
except:
|
||
continue
|
||
for pid_str in pids:
|
||
if not pid_str:
|
||
continue
|
||
try:
|
||
cmdline = subprocess.run(["ps", "-p", pid_str, "-o", "command="],
|
||
capture_output=True, text=True, timeout=2).stdout.strip()
|
||
except:
|
||
continue
|
||
# 在命令行中查找 --accounts= 参数,精确匹配
|
||
for part in cmdline.split():
|
||
if part.startswith("--accounts="):
|
||
vals = part.split("=", 1)[1].split(",")
|
||
if account_id in vals:
|
||
logger.debug(f" 🧹 清理僵尸: PID {pid_str} ({cmdline[:60]})")
|
||
try:
|
||
os.kill(int(pid_str), signal.SIGTERM)
|
||
except:
|
||
pass
|
||
break
|
||
|
||
|
||
def _ssh_remote_exact_pkill(ssh_target: str, account_id: str):
|
||
"""远程机器精确匹配僵尸进程清理(通过 SSH 执行 shell 脚本)"""
|
||
script = (
|
||
f'for _pid in $(pgrep -f "mc run" 2>/dev/null || true); do '
|
||
f'_cmd=$(ps -p $_pid -o command= 2>/dev/null || true); '
|
||
f'if echo "$_cmd" | grep -Eq -- \'--accounts=(.*,)?{account_id}(,.*)?\' 2>/dev/null; then '
|
||
f'kill $_pid 2>/dev/null || true; fi; done; '
|
||
f'for _pid in $(pgrep -f "mc task" 2>/dev/null || true); do '
|
||
f'_cmd=$(ps -p $_pid -o command= 2>/dev/null || true); '
|
||
f'if echo "$_cmd" | grep -Eq -- \'--accounts=(.*,)?{account_id}(,.*)?\' 2>/dev/null; then '
|
||
f'kill $_pid 2>/dev/null || true; fi; done; '
|
||
f'for _pid in $(pgrep -f "mc collect" 2>/dev/null || true); do '
|
||
f'_cmd=$(ps -p $_pid -o command= 2>/dev/null || true); '
|
||
f'if echo "$_cmd" | grep -Eq -- \'--accounts=(.*,)?{account_id}(,.*)?\' 2>/dev/null; then '
|
||
f'kill $_pid 2>/dev/null || true; fi; done; '
|
||
f'for _pid in $(pgrep -f "mc smart-login" 2>/dev/null || true); do '
|
||
f'_cmd=$(ps -p $_pid -o command= 2>/dev/null || true); '
|
||
f'if echo "$_cmd" | grep -Eq -- \'--accounts=(.*,)?{account_id}(,.*)?\' 2>/dev/null; then '
|
||
f'kill $_pid 2>/dev/null || true; fi; done; true'
|
||
)
|
||
try:
|
||
subprocess.run(
|
||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||
ssh_target, script],
|
||
capture_output=True, timeout=10
|
||
)
|
||
except:
|
||
pass
|
||
|
||
|
||
class MachineSession:
|
||
"""单台机器的命令执行会话(v6: 每台机器一个队列)"""
|
||
|
||
_sessions = {}
|
||
|
||
def __init__(self, machine: str):
|
||
self.machine = machine
|
||
self.is_local = (machine == HOSTNAME)
|
||
self.commands: list[Command] = [] # 全部命令(含历史)
|
||
self.current_cmd: Optional[Command] = None # 正在执行的命令
|
||
self.queued_cmds: list[Command] = [] # 排队等待的命令
|
||
self.max_history = 50
|
||
self._lock = threading.Lock()
|
||
self.machine_info = _get_machine_info(machine) if not self.is_local else {}
|
||
self.ssh_target = None
|
||
if not self.is_local and self.machine_info.get("ip"):
|
||
u = self.machine_info.get("user", "")
|
||
self.ssh_target = f"{u}@{self.machine_info['ip']}" if u else self.machine_info["ip"]
|
||
|
||
@property
|
||
def is_busy(self) -> bool:
|
||
"""机器是否正在执行命令"""
|
||
return self.current_cmd is not None and self.current_cmd.status.is_active
|
||
|
||
@classmethod
|
||
def get(cls, machine: str) -> "MachineSession":
|
||
if machine not in cls._sessions:
|
||
cls._sessions[machine] = cls(machine)
|
||
return cls._sessions[machine]
|
||
|
||
def graceful_exit(self, account_id: str = None):
|
||
"""优雅退出:执行前清理同机残留进程。
|
||
|
||
只杀无 active 命令的僵尸进程;若有 active 命令,由 guardd 调度器排他管理,不抢杀。
|
||
|
||
判断链路:
|
||
1. MachineSession 内是否有 active 命令
|
||
2. guardd 调度器是否有该账号的 active 任务
|
||
3. 以上都无 → 精确匹配 pkill(只杀同名僵尸,不误杀其他账号)
|
||
"""
|
||
for cmd in self.commands[:]:
|
||
if cmd.status.is_active and (account_id is None or account_id in cmd.accounts):
|
||
self.cancel(cmd)
|
||
|
||
# 检查 MachineSession 内是否有 active 命令
|
||
active_for_account = any(
|
||
cmd.status.is_active and (account_id is None or account_id in cmd.accounts)
|
||
for cmd in self.commands
|
||
)
|
||
if active_for_account:
|
||
return
|
||
|
||
# 检查 guardd 调度器是否有 active 任务(对外对内统一路径)
|
||
if account_id:
|
||
try:
|
||
guardd_data = _guardd_api("GET", "/scheduler/tasks", machine=self.machine)
|
||
if guardd_data:
|
||
for task in guardd_data.get("active", []):
|
||
if account_id in task.get("accounts", []):
|
||
logger.debug(f" ✅ [{account_id}] guardd 调度器有 active 任务,跳过清理")
|
||
return
|
||
except Exception:
|
||
pass # guardd 不可用时,走 pkill 兜底
|
||
|
||
# 无 active 任务 → 精确匹配清理僵尸进程
|
||
target = account_id or ""
|
||
if not target:
|
||
return
|
||
|
||
if self.is_local:
|
||
_pkill_exact(target)
|
||
elif self.ssh_target:
|
||
_pkill_exact(target, ssh_target=self.ssh_target)
|
||
|
||
def preflight(self) -> dict:
|
||
with self._lock:
|
||
active = [c for c in self.commands if c.status.is_active]
|
||
if not self.is_local and self.ssh_target:
|
||
try:
|
||
r = subprocess.run(
|
||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||
self.ssh_target, "echo ok"],
|
||
capture_output=True, text=True, timeout=8
|
||
)
|
||
if r.returncode != 0:
|
||
logger.warning(f" ⚠️ [{self.machine}] preflight SSH 不可达 (rc={r.returncode}), 仍然尝试发送")
|
||
return {"ok": True, "message": "SSH 不可达但尝试发送", "running": len(active)}
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ [{self.machine}] preflight SSH 异常: {e}, 仍然尝试发送")
|
||
return {"ok": True, "message": f"SSH 异常但尝试发送: {e}", "running": len(active)}
|
||
return {"ok": True, "message": "就绪", "running": len(active)}
|
||
|
||
def _clear_stale(self):
|
||
"""清理卡死的 current_cmd(_guardd_api 临时不可用导致 is_busy 卡死)"""
|
||
cmd = self.current_cmd
|
||
if not cmd:
|
||
return
|
||
elapsed = time.time() - (cmd.started_at or time.time())
|
||
timeout_map = {
|
||
"login": 600, # 登录最多等 10 分钟
|
||
"comment": 1200, # 评论最多等 20 分钟
|
||
"collect": 600, # 账号信息采集最多等 10 分钟
|
||
"nurture": 21600, # 养号最多等 6 小时
|
||
}
|
||
max_time = timeout_map.get(cmd.cmd_type, 3600)
|
||
if elapsed > max_time:
|
||
cmd.status = CommandStatus.TIMED_OUT
|
||
cmd.message = f"超时自动清理 ({int(elapsed)}s > {max_time}s)"
|
||
self.current_cmd = None
|
||
logger.warning(f" ⏰ 清理卡死命令 ({cmd.cmd_type}, {int(elapsed)}s > {max_time}s)")
|
||
|
||
def send(self, cmd: Command) -> dict:
|
||
with self._lock:
|
||
self._clear_stale()
|
||
|
||
# 直接发送到 guardd 调度器(不再排队串行化)
|
||
# guardd 调度器内部管理 3 个 slot 和优先级队列
|
||
if self.is_local:
|
||
result = self._send_local(cmd)
|
||
else:
|
||
result = self._send_remote(cmd)
|
||
if "error" not in result:
|
||
self.current_cmd = cmd
|
||
self.commands.insert(0, cmd)
|
||
self._trim_history()
|
||
return result
|
||
|
||
def _send_local(self, cmd: Command) -> dict:
|
||
# 优先通过 guardd 调度引擎提交(v4.3.0)
|
||
scheduler_task = {
|
||
"task_id": cmd.run_id,
|
||
"cmd_type": cmd.cmd_type,
|
||
"accounts": cmd.accounts,
|
||
"blueprint": cmd.params.get("blueprint", ""),
|
||
"rounds": cmd.params.get("rounds", 1),
|
||
"priority": cmd.params.get("priority", 0 if cmd.cmd_type in ("interact", "comment", "dm", "live", "publish") else 1),
|
||
"interval": cmd.params.get("interval", 0),
|
||
"params": cmd.params,
|
||
"command_line": cmd.command_line,
|
||
}
|
||
guardd_result = _guardd_api("POST", "/scheduler/submit", scheduler_task)
|
||
if guardd_result.get("status") == "accepted":
|
||
cmd.status = CommandStatus.DISPATCHING
|
||
cmd.started_at = time.time()
|
||
return {"guardd": True, "scheduler": True}
|
||
|
||
logger.warning(f" ❌ {cmd.run_id} -> scheduler submit failed: {guardd_result}")
|
||
cmd.status = CommandStatus.FAILED
|
||
cmd.error = "guardd scheduler 不可用"
|
||
return {"guardd": False, "error": "guardd scheduler 不可用"}
|
||
|
||
guardd_result = _guardd_api("POST", "/task", {
|
||
"cmd": full_shell_cmd,
|
||
"run_id": cmd.run_id,
|
||
"machine": HOSTNAME,
|
||
})
|
||
if guardd_result.get("status") == "accepted":
|
||
cmd.pid = guardd_result.get("pid")
|
||
cmd.status = CommandStatus.DISPATCHING
|
||
cmd.started_at = time.time()
|
||
return {"pid": cmd.pid, "guardd": True}
|
||
|
||
# guardd 完全不可用时降级为 subprocess(向后兼容)
|
||
logger.warning(f"guardd 不可用,降级为 subprocess: {cmd.run_id}")
|
||
try:
|
||
mc_count = int(subprocess.run(
|
||
["pgrep", "-f", "python3.*-m mc"], capture_output=True, text=True, timeout=3
|
||
).stdout.strip() or "0") or 0
|
||
if mc_count > 600:
|
||
subprocess.run(["pkill", "-f", "python3.*-m mc"], capture_output=True, timeout=3)
|
||
time.sleep(1)
|
||
return {"error": f"系统 mc 进程数过高 ({mc_count}),已自动清理,请重试"}
|
||
except:
|
||
pass
|
||
|
||
scripts_dir = AGENT_SYNC / "05_tools" / "07_matrix" / "scripts"
|
||
log_dir = AGENT_LOCAL / "runtime" / "commands"
|
||
log_dir.mkdir(parents=True, exist_ok=True)
|
||
log_path = log_dir / f"{cmd.run_id}.log"
|
||
cmd.log_path = log_path
|
||
log_fh = open(log_path, "w")
|
||
|
||
if cmd.cmd_type == "nurture":
|
||
wrapper = str(AGENT_SYNC / "05_tools" / "10_dashboard" / "services" / "nurture_runner.sh")
|
||
accts = ",".join(cmd.accounts)
|
||
bp = cmd.params.get("blueprint") or "douyin_daily"
|
||
rounds = cmd.params.get("rounds") or 10
|
||
p = subprocess.Popen(
|
||
["bash", wrapper, accts, bp, str(rounds), cmd.run_id],
|
||
stdout=log_fh, stderr=subprocess.STDOUT
|
||
)
|
||
else:
|
||
python_path = f"{Path.home()}/.workbuddy/binaries/python/envs/agent-os/bin/python3"
|
||
full_cmd = f"cd {scripts_dir} && PYTHONPATH='{scripts_dir}' {python_path} -m {cmd.command_line}"
|
||
p = subprocess.Popen(
|
||
full_cmd, shell=True, stdout=log_fh, stderr=subprocess.STDOUT
|
||
)
|
||
|
||
cmd.pid = p.pid
|
||
cmd.status = CommandStatus.DISPATCHING
|
||
cmd.started_at = time.time()
|
||
return {"pid": cmd.pid, "log_path": str(log_path)}
|
||
|
||
def _send_remote(self, cmd: Command) -> dict:
|
||
logger.info("_send_remote: {} @ {}, accounts={}".format(cmd.run_id, self.machine, cmd.accounts))
|
||
|
||
# ── 路径1: guardd /scheduler/submit (新调度引擎) ──
|
||
scheduler_task = {
|
||
"task_id": cmd.run_id,
|
||
"cmd_type": cmd.cmd_type,
|
||
"accounts": cmd.accounts,
|
||
"blueprint": cmd.params.get("blueprint", ""),
|
||
"rounds": cmd.params.get("rounds", 1),
|
||
"priority": cmd.params.get("priority", 0 if cmd.cmd_type in ("interact", "comment", "dm", "live", "publish") else 1),
|
||
"interval": cmd.params.get("interval", 0),
|
||
"params": cmd.params,
|
||
"command_line": cmd.command_line,
|
||
}
|
||
guardd_result = _guardd_api("POST", "/scheduler/submit", scheduler_task, machine=self.machine)
|
||
if guardd_result.get("status") == "accepted":
|
||
cmd.status = CommandStatus.DISPATCHING
|
||
cmd.started_at = time.time()
|
||
cmd.message = "sent to {} via scheduler".format(self.machine)
|
||
logger.info(" ✅ {} -> scheduler accepted".format(cmd.run_id))
|
||
return {"guardd": True, "scheduler": True, "machine": self.machine}
|
||
logger.info(" ⚠️ {} -> scheduler submit failed (result={}), trying old /task".format(cmd.run_id, guardd_result))
|
||
|
||
# ── 路径2: guardd /task (旧API) ──
|
||
# 构造 shell 命令
|
||
py_discover = 'PY=$(ls $HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3 2>/dev/null || which python3); '
|
||
scripts_dir = "$AGENT_SYNC/05_tools/07_matrix/scripts"
|
||
env_setup = "export AGENT_SYNC=\"$HOME/workbuddy-agent-os/agent-sync\"; export AGENT_LOCAL=\"$HOME/workbuddy-agent-os/agent-local\"; export MC_PYTHON=\"$PY\"; export PYTHONPATH={}:$PYTHONPATH; ".format(scripts_dir)
|
||
if cmd.cmd_type == "nurture":
|
||
accts = ",".join(cmd.accounts)
|
||
_bp = cmd.params.get("blueprint") or "douyin_daily"
|
||
_rd = cmd.params.get("rounds") or 10
|
||
wrapper = "$AGENT_SYNC/05_tools/10_dashboard/services/nurture_runner.sh"
|
||
full_cmd = "{} {} bash {} {} {} {}".format(py_discover, env_setup, wrapper, accts, _bp, _rd, cmd.run_id)
|
||
else:
|
||
full_cmd = "{} {} cd {} && $MC_PYTHON -m {}".format(py_discover, env_setup, scripts_dir, cmd.command_line)
|
||
guardd_result = _guardd_api("POST", "/task", {
|
||
"cmd": full_cmd, "run_id": cmd.run_id, "machine": self.machine,
|
||
}, machine=self.machine)
|
||
if guardd_result.get("status") == "accepted":
|
||
cmd.pid = guardd_result.get("pid")
|
||
cmd.status = CommandStatus.DISPATCHING
|
||
cmd.started_at = time.time()
|
||
cmd.message = "sent to {} via guardd /task".format(self.machine)
|
||
logger.info(" ✅ {} -> /task accepted".format(cmd.run_id))
|
||
return {"pid": cmd.pid, "guardd": True, "machine": self.machine}
|
||
logger.info(" ⚠️ {} -> /task failed (result={}), trying SSH".format(cmd.run_id, guardd_result))
|
||
|
||
# ── 路径3: SSH nohup ──
|
||
if not self.ssh_target:
|
||
cmd.status = CommandStatus.FAILED
|
||
cmd.message = "machine {} guardd and SSH unavailable".format(self.machine)
|
||
logger.warning(" ❌ {} -> SSH target not available".format(cmd.run_id))
|
||
return {"error": cmd.message}
|
||
|
||
logger.warning(" 🔌 {} -> fallback to SSH: {}".format(cmd.run_id, self.ssh_target))
|
||
ssh_cmd = "nohup {} > /tmp/ops_{}.log 2>&1 &".format(full_cmd, cmd.run_id)
|
||
|
||
try:
|
||
r = subprocess.run(
|
||
["ssh", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=no",
|
||
self.ssh_target, ssh_cmd],
|
||
capture_output=True, text=True, timeout=15
|
||
)
|
||
if r.returncode != 0:
|
||
logger.warning(" ❌ {} -> SSH returned code {}: {}".format(cmd.run_id, r.returncode, r.stderr[:200]))
|
||
cmd.status = CommandStatus.FAILED
|
||
cmd.message = "SSH failed (code {}): {}".format(r.returncode, r.stderr[:100])
|
||
return {"error": cmd.message}
|
||
cmd.status = CommandStatus.DISPATCHING
|
||
cmd.started_at = time.time()
|
||
cmd.message = "command sent via SSH: {}".format(self.ssh_target)
|
||
logger.info(" ✅ {} -> SSH sent OK".format(cmd.run_id))
|
||
return {"status": "sent", "target": self.ssh_target}
|
||
except Exception as e:
|
||
cmd.status = CommandStatus.FAILED
|
||
cmd.message = "SSH send failed: {}".format(e)
|
||
logger.warning(" ❌ {} -> SSH exception: {}".format(cmd.run_id, e))
|
||
return {"error": str(e)}
|
||
|
||
def _remote_poll_result(self, cmd: Command) -> bool:
|
||
"""远程检查:SSH 到远端机器读取 result 文件"""
|
||
if not self.ssh_target:
|
||
return False
|
||
try:
|
||
if cmd.cmd_type == "nurture":
|
||
check = f"cat $AGENT_LOCAL/runtime/nurture/results/{cmd.run_id}.json 2>/dev/null"
|
||
else:
|
||
check = f"cat $AGENT_LOCAL/runtime/results/{cmd.run_id}.json 2>/dev/null; cat /tmp/ops_{cmd.run_id}.log 2>/dev/null"
|
||
r = subprocess.run(
|
||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||
self.ssh_target,
|
||
f"export AGENT_LOCAL=\"$HOME/workbuddy-agent-os/agent-local\"; {check}"],
|
||
capture_output=True, text=True, timeout=8
|
||
)
|
||
if r.returncode == 0 and r.stdout.strip():
|
||
# 尝试解析 JSON(nurture 结果文件)
|
||
try:
|
||
data = json.loads(r.stdout)
|
||
cmd.result = data
|
||
s = data.get("status", "running")
|
||
if s == "completed":
|
||
cmd.status = CommandStatus.COMPLETED
|
||
cmd.completed_at = time.time()
|
||
cmd.message = f"远程执行完成"
|
||
return True
|
||
elif s == "failed":
|
||
cmd.status = CommandStatus.FAILED
|
||
cmd.completed_at = time.time()
|
||
cmd.message = f"远程执行失败: {data.get('error','')}"
|
||
return True
|
||
else:
|
||
steps = data.get("steps", {})
|
||
cmd.message = f"运行中 (已完成 {steps.get('success', 0)}/{steps.get('total', '?')} 步)"
|
||
cmd.status = CommandStatus.RUNNING
|
||
return True
|
||
except ValueError:
|
||
# 不是 JSON — 可能是日志内容,检查是否包含完成标记
|
||
if "completed" in r.stdout.lower() or "done" in r.stdout.lower():
|
||
cmd.status = CommandStatus.COMPLETED
|
||
cmd.completed_at = time.time()
|
||
cmd.message = "远程执行完成"
|
||
return True
|
||
if "error" in r.stdout.lower() or "traceback" in r.stdout.lower():
|
||
cmd.status = CommandStatus.FAILED
|
||
cmd.completed_at = time.time()
|
||
cmd.message = "远程执行失败"
|
||
return True
|
||
# 有输出但未完成 — 运行中
|
||
if len(r.stdout.strip()) > 20:
|
||
cmd.status = CommandStatus.RUNNING
|
||
cmd.message = f"远程进程输出中 ({len(r.stdout)}bytes)"
|
||
return True
|
||
return False
|
||
except:
|
||
return False
|
||
|
||
def poll(self, cmd: Command) -> CommandStatus:
|
||
with self._lock:
|
||
if cmd.status.is_terminal:
|
||
return cmd.status
|
||
cmd.last_poll_at = time.time()
|
||
|
||
# 按 cmd_type 获取轮询策略
|
||
strategy = CMD_POLL_STRATEGY.get(cmd.cmd_type, DEFAULT_POLL_STRATEGY)
|
||
grace_period = strategy.get("grace_period", 30)
|
||
max_timeout = cmd.params.get("timeout", strategy.get("timeout", 600))
|
||
check_process = strategy.get("check_process", True)
|
||
check_log_completed = strategy.get("check_log_completed", [])
|
||
|
||
# 通过 guardd 查询任务状态(v7)
|
||
g_tasks = _guardd_api("GET", "/tasks", machine=self.machine)
|
||
for gt in g_tasks if isinstance(g_tasks, list) else []:
|
||
if gt.get("run_id") == cmd.run_id:
|
||
gs = gt.get("status", "")
|
||
if gs == "completed":
|
||
cmd.status = CommandStatus.COMPLETED
|
||
cmd.completed_at = time.time()
|
||
return cmd.status
|
||
elif gs in ("failed", "crashed"):
|
||
cmd.status = CommandStatus.FAILED
|
||
cmd.completed_at = time.time()
|
||
return cmd.status
|
||
elif gs == "cancelled":
|
||
cmd.status = CommandStatus.CANCELLED
|
||
cmd.completed_at = time.time()
|
||
return cmd.status
|
||
elif gs == "running":
|
||
cmd.status = CommandStatus.RUNNING
|
||
return cmd.status
|
||
|
||
# 远程命令:通过 SSH 读取结果
|
||
if not self.is_local and self.ssh_target:
|
||
if self._remote_poll_result(cmd):
|
||
return cmd.status
|
||
|
||
# 本机命令:优先读统一结果文件(mc/engine.py 写入)
|
||
if self.is_local:
|
||
_result_file = AGENT_LOCAL / "runtime" / "results" / f"{cmd.run_id}.json"
|
||
if _result_file.exists():
|
||
try:
|
||
_data = json.loads(_result_file.read_text())
|
||
cmd.result = _data
|
||
s = _data.get("status", "running")
|
||
if s == "completed":
|
||
cmd.status = CommandStatus.COMPLETED
|
||
cmd.message = _data.get("message", "执行完成")
|
||
cmd.completed_at = time.time()
|
||
elif s == "failed":
|
||
cmd.status = CommandStatus.FAILED
|
||
cmd.message = _data.get("error", _data.get("message", "执行失败"))
|
||
cmd.completed_at = time.time()
|
||
elif s == "crashed":
|
||
cmd.status = CommandStatus.CRASHED
|
||
cmd.message = _data.get("error", "进程崩溃")
|
||
cmd.completed_at = time.time()
|
||
elif s == "cancelled":
|
||
cmd.status = CommandStatus.CANCELLED
|
||
cmd.completed_at = time.time()
|
||
else:
|
||
cmd.status = CommandStatus.RUNNING
|
||
steps = _data.get("steps", {})
|
||
cmd.message = f"运行中 (已完成 {steps.get('success', 0)}/{steps.get('total', '?')} 步)"
|
||
return cmd.status
|
||
except:
|
||
pass
|
||
|
||
# 兼容旧路径: runtime/nurture/results/
|
||
if cmd.cmd_type == "nurture":
|
||
result_dir = AGENT_LOCAL / "runtime" / "nurture" / "results"
|
||
result_file = result_dir / f"{cmd.run_id}.json"
|
||
if result_file.exists():
|
||
try:
|
||
data = json.loads(result_file.read_text())
|
||
s = data.get("status", "running")
|
||
cmd.result = data
|
||
if s == "completed":
|
||
cmd.status = CommandStatus.COMPLETED
|
||
cmd.completed_at = time.time()
|
||
elif s == "failed":
|
||
cmd.status = CommandStatus.FAILED
|
||
cmd.completed_at = time.time()
|
||
else:
|
||
cmd.status = CommandStatus.RUNNING
|
||
steps = data.get("steps", {})
|
||
cmd.message = f"运行中 (已完成 {steps.get('success', 0)}/{steps.get('total', '?')} 步)"
|
||
return cmd.status
|
||
except:
|
||
pass
|
||
|
||
# 根据策略决定是否检测进程存活
|
||
alive = False
|
||
if check_process:
|
||
alive = self._is_alive(cmd)
|
||
if alive:
|
||
cmd.status = CommandStatus.RUNNING
|
||
cmd.message = "进程运行中"
|
||
return cmd.status
|
||
else:
|
||
# check_process=False:不依赖进程存活判断(login/logout 快速命令)
|
||
# 检查日志中是否有完成标记
|
||
if check_log_completed and cmd.log_path and cmd.log_path.exists():
|
||
log_text = cmd.log_path.read_text(encoding="utf-8", errors="replace")
|
||
for marker in check_log_completed:
|
||
if marker in log_text:
|
||
cmd.status = CommandStatus.COMPLETED
|
||
cmd.message = f"检测到完成标记: {marker}"
|
||
cmd.completed_at = time.time()
|
||
return cmd.status
|
||
|
||
elapsed = cmd.elapsed_sec
|
||
if elapsed < grace_period:
|
||
cmd.status = CommandStatus.RUNNING
|
||
cmd.message = f"{'远程' if not self.is_local else ''}进程启动中 ({int(elapsed)}s/{grace_period}s)"
|
||
return cmd.status
|
||
|
||
if elapsed > max_timeout:
|
||
cmd.status = CommandStatus.TIMED_OUT
|
||
cmd.message = f"执行超时 ({int(elapsed)}s > {max_timeout}s)"
|
||
cmd.completed_at = time.time()
|
||
return cmd.status
|
||
|
||
if cmd.log_path and cmd.log_path.exists():
|
||
tail = cmd.log_path.read_text().strip()
|
||
if tail:
|
||
cmd.status = CommandStatus.COMPLETED
|
||
cmd.message = "进程已结束,无异常"
|
||
cmd.completed_at = time.time()
|
||
return cmd.status
|
||
|
||
# 远程:检查是否有任何输出(即使进程已退出)
|
||
if not self.is_local and self.ssh_target:
|
||
try:
|
||
r = subprocess.run(
|
||
["ssh", "-o", "ConnectTimeout=5", self.ssh_target,
|
||
f"cat /tmp/ops_{cmd.run_id}.log 2>/dev/null | wc -c"],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
if r.returncode == 0 and r.stdout.strip().isdigit() and int(r.stdout.strip()) > 0:
|
||
cmd.status = CommandStatus.COMPLETED
|
||
cmd.message = "远程进程已完成"
|
||
cmd.completed_at = time.time()
|
||
return cmd.status
|
||
except:
|
||
pass
|
||
|
||
cmd.status = CommandStatus.CRASHED
|
||
cmd.message = "进程已消失,无输出"
|
||
cmd.completed_at = time.time()
|
||
return cmd.status
|
||
|
||
def _is_alive(self, cmd: Command) -> bool:
|
||
if self.is_local:
|
||
if cmd.pid:
|
||
try:
|
||
os.kill(cmd.pid, 0)
|
||
return True
|
||
except OSError:
|
||
return False
|
||
return False
|
||
if self.ssh_target:
|
||
# 先按 run_id 查(log 文件重定向包含 run_id)
|
||
patterns = [cmd.run_id]
|
||
# 再按进程名查(实际进程可能不包含 run_id)
|
||
type_patterns = {
|
||
"collect": ["mc run.*douyin_read_profile", "mc run.*xiaohongshu_read_profile"],
|
||
"nurture": ["mc run", "camoufox.*-no-remote"],
|
||
"login": ["smart-login", "mc smart-login"],
|
||
"comment": ["task comment", "mc task"],
|
||
"like": ["mc task like"],
|
||
}
|
||
patterns.extend(type_patterns.get(cmd.cmd_type, []))
|
||
try:
|
||
for p in patterns:
|
||
r = subprocess.run(
|
||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||
self.ssh_target, f"pgrep -f '{p}'"],
|
||
capture_output=True, text=True, timeout=8
|
||
)
|
||
if r.returncode == 0:
|
||
return True
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
def cancel(self, cmd: Command) -> dict:
|
||
with self._lock:
|
||
if cmd.status.is_terminal:
|
||
return {"error": "命令已结束"}
|
||
|
||
# 优先通过 guardd 停止(v7)
|
||
g_stop = _guardd_api("POST", f"/task/{cmd.run_id}/stop", machine=self.machine)
|
||
if g_stop.get("status") in ("stopped", "already_stopped"):
|
||
# guardd 已处理进程清理
|
||
pass
|
||
else:
|
||
# 降级:手动杀进程 + 清理浏览器
|
||
if self.is_local and cmd.pid:
|
||
try:
|
||
os.kill(cmd.pid, 15)
|
||
time.sleep(0.5)
|
||
os.kill(cmd.pid, 9)
|
||
except:
|
||
pass
|
||
for aid in cmd.accounts:
|
||
try:
|
||
subprocess.run(["pkill", "-f", aid], capture_output=True, timeout=3)
|
||
except:
|
||
pass
|
||
elif not self.is_local and self.ssh_target:
|
||
try:
|
||
subprocess.run(
|
||
["ssh", self.ssh_target, f"pkill -f '{cmd.run_id}' 2>/dev/null"],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
except:
|
||
pass
|
||
|
||
cmd.status = CommandStatus.CANCELLED
|
||
cmd.message = "用户取消"
|
||
cmd.completed_at = time.time()
|
||
|
||
# 如果是当前正在执行的命令 → 启动下一条
|
||
if self.current_cmd is cmd:
|
||
self._start_next()
|
||
|
||
return {"ok": True}
|
||
|
||
def _cleanup_zombies(self):
|
||
"""清理僵尸进程:进程已死但状态未更新的命令"""
|
||
for cmd in self.commands[:]:
|
||
if cmd.status.is_active:
|
||
if not self._is_alive(cmd):
|
||
if cmd.log_path and cmd.log_path.exists() and cmd.log_path.stat().st_size > 0:
|
||
cmd.status = CommandStatus.COMPLETED
|
||
cmd.message = "进程已结束"
|
||
else:
|
||
cmd.status = CommandStatus.CRASHED
|
||
cmd.message = "进程已消失"
|
||
cmd.completed_at = time.time()
|
||
|
||
# 命令刚完成 → 启动队列下一条
|
||
if cmd.status.is_terminal and self.current_cmd is cmd:
|
||
self._start_next()
|
||
|
||
def _start_next(self):
|
||
"""当前命令完成后,启动队列中的下一条"""
|
||
self.current_cmd = None
|
||
if self.queued_cmds:
|
||
next_cmd = self.queued_cmds.pop(0)
|
||
logger.info(f" ⏩ 启动队列下一条: {next_cmd.run_id} ({next_cmd.cmd_type})")
|
||
if self.is_local:
|
||
r = self._send_local(next_cmd)
|
||
else:
|
||
r = self._send_remote(next_cmd)
|
||
if "error" not in r:
|
||
self.current_cmd = next_cmd
|
||
|
||
def _trim_history(self):
|
||
while len(self.commands) > self.max_history:
|
||
self.commands.pop()
|
||
|
||
def get_active_commands(self) -> list[Command]:
|
||
return [c for c in self.commands if c.status.is_active]
|
||
|
||
def get_queue_info(self) -> dict:
|
||
"""返回队列状态"""
|
||
return {
|
||
"busy": self.is_busy,
|
||
"current_run_id": self.current_cmd.run_id if self.current_cmd else None,
|
||
"current_type": self.current_cmd.cmd_type if self.current_cmd else None,
|
||
"current_status": self.current_cmd.status.value if self.current_cmd else None,
|
||
"queued_count": len(self.queued_cmds),
|
||
"queued": [{"run_id": c.run_id, "type": c.cmd_type, "accounts": c.accounts}
|
||
for c in self.queued_cmds],
|
||
}
|
||
|
||
def get_recent_commands(self, limit: int = 20) -> list[dict]:
|
||
return [c.to_dict() for c in self.commands[:limit]]
|
||
|
||
|
||
# ── 操作注册表(统一 cmd_type → 命令模板映射)───────────────
|
||
# 本注册表是整个系统通往执行层的唯一映射表。
|
||
# 新增操作类型只需在这里加一行,不需要改 dispatch 逻辑。
|
||
#
|
||
# 注册表字段说明:
|
||
# template — 命令模板,用 {ids} {blueprint} {rounds} 等变量渲染
|
||
# defaults — 参数默认值(前端不传 params 时使用)
|
||
# auto_blueprint — 是否根据账号 platform 字段自动选择采集蓝图
|
||
# blueprint_map — platform → blueprint 映射表
|
||
# runner — shell 包装器路径(nurture 专用)
|
||
# single_account — 是否一次只处理一个账号
|
||
# required_params — 必填参数列表(缺失则报错)
|
||
#
|
||
# 前端调用示例:
|
||
# POST /api/ops/run {type:'collect', accounts:['douyin_01'], params:{rounds:1}}
|
||
# → mc run --accounts=douyin_01 --blueprints=douyin_read_profile --rounds=1
|
||
#
|
||
# ⚠️ 以下 collect 是「账号信息采集」(走 Camoufox 养号引擎),
|
||
# 用于采集已登录账号的昵称/粉丝数等主页信息。
|
||
# 新「内容数据采集」走独立引擎:POST /api/collect/run(Chrome/OpenCLI)
|
||
# 两条路径完全隔离,互不相关。
|
||
CMD_REGISTRY = {
|
||
"nurture": {
|
||
"runner": "nurture_runner.sh", # shell 包装器
|
||
"defaults": {"blueprint": "douyin_daily_clean", "rounds": 10},
|
||
"auto_blueprint": False,
|
||
},
|
||
# ── 账号信息采集(走 Camoufox 养号引擎)─────────────────────
|
||
# 用途:采集已登录养号账号的主页信息(昵称/粉丝数/获赞数)
|
||
# 引擎:mc run → Camoufox(Firefox 内核,养号专用指纹)
|
||
# 路由:POST /api/ops/run {type:'collect'}
|
||
#
|
||
# ⛔ 新内容采集走独立系统(Chrome/OpenCLI):
|
||
# 路由 POST /api/collect/run — 两条路径完全隔离。
|
||
# ────────────────────────────────────────────────────────────
|
||
"collect": {
|
||
"template": "mc run --accounts={ids} --blueprints={blueprint} --rounds={rounds}",
|
||
"defaults": {"rounds": 1},
|
||
"auto_blueprint": True,
|
||
"blueprint_map": {
|
||
"douyin": "douyin_read_profile",
|
||
"xiaohongshu": "xiaohongshu_read_profile",
|
||
},
|
||
},
|
||
"login": {
|
||
"template": "mc smart-login {ids}",
|
||
"single_account": True,
|
||
},
|
||
"logout": {
|
||
"template": "mc run --accounts={ids} --blueprints=douyin_daily --rounds=1 --engine=auto",
|
||
},
|
||
"comment": {
|
||
"template": "mc task comment --account={ids} --url={url} --direction={direction}",
|
||
"required_params": ["url"],
|
||
},
|
||
"like": {
|
||
"template": "mc run --accounts={ids} --blueprints=douyin_daily --rounds=1",
|
||
},
|
||
"interact": {
|
||
"template": "mc run --accounts={ids} --blueprints={blueprint} --rounds=1 --url={url} --direction={direction} --corpus={corpus}",
|
||
"defaults": {"blueprint": "interact_comment", "rounds": 1, "direction": "", "corpus": ""},
|
||
"required_params": ["url"],
|
||
},
|
||
"record": {
|
||
"template": "mc record start --accounts={ids} --platform={platform}",
|
||
"single_account": True,
|
||
"defaults": {"platform": "douyin"},
|
||
},
|
||
"smart_comment": {
|
||
"master_only": True,
|
||
"required_params": ["urls"],
|
||
"single_account": True,
|
||
},
|
||
}
|
||
|
||
|
||
# ── 命令轮询策略(按 cmd_type 设定不同的超时/检测方式)────────
|
||
# 解决:登录进程退出后 poll 不知道它已完成的问题
|
||
CMD_POLL_STRATEGY = {
|
||
"login": {
|
||
"grace_period": 15, # 登录通常15秒内完成启动
|
||
"timeout": 120, # 登录超时2分钟
|
||
"check_process": False, # smart-login 启动浏览器后进程退出
|
||
"check_log_completed": ["登录成功", "SMS验证码已发送", "浏览器已打开"],
|
||
},
|
||
"collect": {
|
||
"grace_period": 30,
|
||
"timeout": 600,
|
||
"check_process": True,
|
||
"check_log_completed": ["📊 执行完成", "✅ 采集完成"],
|
||
},
|
||
"nurture": {
|
||
"grace_period": 60,
|
||
"timeout": 1800,
|
||
"check_process": True,
|
||
"check_log_completed": ["📊 执行完成", "✅ 全部"],
|
||
},
|
||
"comment": {
|
||
"grace_period": 20,
|
||
"timeout": 300,
|
||
"check_process": True,
|
||
"check_log_completed": ["评论已发送", "✅ 完成"],
|
||
},
|
||
"like": {
|
||
"grace_period": 20,
|
||
"timeout": 300,
|
||
"check_process": True,
|
||
"check_log_completed": ["✅ 执行完成"],
|
||
},
|
||
"logout": {
|
||
"grace_period": 15,
|
||
"timeout": 120,
|
||
"check_process": False,
|
||
"check_log_completed": ["已退出"],
|
||
},
|
||
"record": {
|
||
"grace_period": 15,
|
||
"timeout": 7200,
|
||
"check_process": True,
|
||
"check_log_completed": ["✅ 录制完成", "📦 录制包已保存"],
|
||
},
|
||
}
|
||
DEFAULT_POLL_STRATEGY = {"grace_period": 30, "timeout": 600, "check_process": True, "check_log_completed": []}
|
||
|
||
|
||
# ── 互动计划生成器 v2(全量互动模型)────────────────────────
|
||
# 账号全量 × 视频按比例 × 动作组合:
|
||
# ① 每个视频独立随机决定动作组合(点赞/收藏/评论,视频比例控制)
|
||
# ② 账号 100% 全量执行命中的动作(不分组)
|
||
# ③ 评论两道闸门:每视频评论上限(随机挑账号)+ 每账号日评论上限(到限自动降级为只赞藏)
|
||
# ④ 评论内容从 comment_map[url] 领取(用户设定,按序分配,用完即止);未设定退回随机语料
|
||
# ⑤ 未命中任何动作的视频直接跳过
|
||
# ⑥ 节奏控制:compact(快)/ loose(慢)两种间隔档
|
||
# 产出 tasks(单账号单视频的组合蓝图任务),由 CommandBus 按机器分发。
|
||
|
||
def _build_interact_plan(machine_groups: dict, params: dict, now_ts: int) -> list:
|
||
"""生成全量互动计划任务列表(v2)
|
||
|
||
params.strategy:
|
||
like_ratio: 0.9 — 点赞视频比例(0~1)
|
||
collect_ratio: 0.3 — 收藏视频比例
|
||
comment_ratio: 0.6 — 评论视频比例
|
||
comment_per_video: 5 — 每视频评论上限
|
||
comment_daily_limit: 20 — 每账号日评论上限
|
||
pace: "loose"/"compact"
|
||
params.comment_map: {url: [评论, ...]} — 用户设定评论内容
|
||
"""
|
||
import random
|
||
strategy = params.get("strategy", {})
|
||
urls = params.get("urls", []) # [{title, url}, ...]
|
||
if isinstance(urls, str):
|
||
urls = [{"title": "", "url": urls}]
|
||
if not urls:
|
||
return []
|
||
|
||
# ── 策略参数(带范围保护,防止脏输入)──
|
||
like_ratio = min(max(float(strategy.get("like_ratio", 0.9)), 0.0), 1.0)
|
||
collect_ratio = min(max(float(strategy.get("collect_ratio", 0.3)), 0.0), 1.0)
|
||
comment_ratio = min(max(float(strategy.get("comment_ratio", 0.6)), 0.0), 1.0)
|
||
comment_per_video = max(0, int(strategy.get("comment_per_video", 5))) # 每视频评论上限
|
||
comment_daily_limit = max(1, int(strategy.get("comment_daily_limit", 20))) # 每账号评论日上限
|
||
pace = strategy.get("pace", "loose") # compact / loose
|
||
|
||
# 节奏 → 间隔秒数(紧凑快、宽松慢)
|
||
if pace == "compact":
|
||
interval = {"like": "20-60", "comment": "45-120", "collect": "30-90", "cross": "60-180"}
|
||
else:
|
||
interval = {"like": "60-180", "comment": "120-300", "collect": "90-240", "cross": "300-600"}
|
||
|
||
# ── 账号池(跨机器合并,全部账号都执行)──
|
||
all_accts = [] # [{id, machine, is_local, nickname, platform}]
|
||
for machine, accts in machine_groups.items():
|
||
for a in accts:
|
||
all_accts.append({
|
||
"id": a["id"],
|
||
"machine": machine,
|
||
"is_local": (machine == HOSTNAME),
|
||
"nickname": a.get("nickname", ""),
|
||
"platform": a.get("platform", "douyin"),
|
||
})
|
||
if not all_accts:
|
||
return []
|
||
|
||
# ── 评论内容池(随机语料兜底,未设定评论内容时使用)──
|
||
_comment_pool = []
|
||
try:
|
||
import sys as _sys
|
||
_scripts_dir = str(AGENT_SYNC / "05_tools" / "07_matrix" / "scripts")
|
||
if _scripts_dir not in _sys.path:
|
||
_sys.path.insert(0, _scripts_dir)
|
||
from mc.corpus import CorpusManager
|
||
_cm = CorpusManager()
|
||
_comment_pool = _cm.get_comments(platform="douyin", count=50) or []
|
||
except Exception:
|
||
_comment_pool = []
|
||
if not _comment_pool:
|
||
_comment_pool = ["不错哦", "学到了", "很有道理", "支持一下", "太棒了",
|
||
"有意思", "点个赞", "加油", "说得对", "路过支持"]
|
||
|
||
# ── 用户设定评论内容(视频 → 评论列表,按序领取,用完即止)──
|
||
comment_map = params.get("comment_map", {}) or {}
|
||
_comment_map_used = {} # url → 已领条数
|
||
|
||
# ── 评论日限计数(全账号,实际领到评论才消耗配额)──
|
||
comment_used = {a["id"]: 0 for a in all_accts}
|
||
|
||
tasks = []
|
||
skipped_videos = 0
|
||
for vi, item in enumerate(urls):
|
||
url = item.get("url", "") if isinstance(item, dict) else str(item)
|
||
title = item.get("title", "") if isinstance(item, dict) else ""
|
||
if not url:
|
||
continue
|
||
|
||
# ── ① 每个视频独立随机决定动作组合 ──
|
||
do_like = random.random() < like_ratio
|
||
do_collect = random.random() < collect_ratio
|
||
do_comment = random.random() < comment_ratio
|
||
if not (do_like or do_collect or do_comment):
|
||
skipped_videos += 1
|
||
continue # 未命中任何动作,直接跳过
|
||
|
||
# ── ② 评论账号:随机挑 comment_per_video 个(未到日限)──
|
||
comment_acct_ids = set()
|
||
if do_comment:
|
||
pool_accts = [a for a in all_accts if comment_used[a["id"]] < comment_daily_limit]
|
||
random.shuffle(pool_accts)
|
||
comment_acct_ids = {a["id"] for a in pool_accts[:comment_per_video]}
|
||
|
||
# 组合间隔:多动作用 cross(宽松),单动作用对应动作间隔
|
||
n_actions = sum([do_like, do_collect, do_comment])
|
||
if n_actions > 1:
|
||
bp_interval = interval["cross"]
|
||
elif do_like:
|
||
bp_interval = interval["like"]
|
||
elif do_collect:
|
||
bp_interval = interval["collect"]
|
||
else:
|
||
bp_interval = interval["comment"]
|
||
|
||
# ── ③ 每个账号生成组合任务(蓝图名动态拼接)──
|
||
for a in all_accts:
|
||
parts = []
|
||
if do_like:
|
||
parts.append("interact_like")
|
||
if do_collect:
|
||
parts.append("interact_collect")
|
||
if do_comment and a["id"] in comment_acct_ids:
|
||
parts.append("interact_comment")
|
||
if not parts:
|
||
continue # 该账号此视频无动作(如只命中评论但未被挑中)
|
||
|
||
# ── ④ 评论内容领取:设定内容优先(按序、用完即止),未设定退回随机语料 ──
|
||
comment_text = ""
|
||
if "interact_comment" in parts:
|
||
_user_comments = comment_map.get(url) if isinstance(comment_map.get(url), list) else []
|
||
if _user_comments:
|
||
_idx = _comment_map_used.get(url, 0)
|
||
if _idx < len(_user_comments):
|
||
comment_text = str(_user_comments[_idx]).strip()
|
||
_comment_map_used[url] = _idx + 1
|
||
comment_used[a["id"]] += 1
|
||
# 设定内容用完 → 该账号降级为只赞藏(评论动作不发布)
|
||
else:
|
||
# 完全未设定 → 随机语料兜底
|
||
_raw = random.choice(_comment_pool)
|
||
comment_text = _raw.get("text", "") if isinstance(_raw, dict) else str(_raw)
|
||
# 双保险:{keyword} 占位符若仍残留(旧缓存/其他路径),用当前视频标题提取关键词替换,兜底「这个」
|
||
if comment_text and "{keyword}" in comment_text:
|
||
_k = _cm._extract_first_keyword(title) if title else ""
|
||
comment_text = comment_text.replace("{keyword}", _k or "这个")
|
||
if comment_text:
|
||
comment_used[a["id"]] += 1
|
||
if not comment_text:
|
||
parts.remove("interact_comment") # 内容用完,评论变点赞收藏
|
||
if not parts:
|
||
continue
|
||
|
||
bp_name = "+".join(parts)
|
||
cmd_line = f"mc run --accounts={a['id']} --blueprints={bp_name} --rounds=1 --url={shlex.quote(url)} --interval={bp_interval}"
|
||
if comment_text:
|
||
cmd_line += f" --comment-text={shlex.quote(comment_text)}"
|
||
tasks.append({
|
||
"machine": a["machine"], "cmd_type": "interact",
|
||
"ids_str": a["id"], "is_local": a["is_local"],
|
||
"nickname": a["nickname"], "platform": a["platform"],
|
||
"cmd_line": cmd_line,
|
||
"run_id": f"plan_{now_ts}_{a['machine']}_{a['id']}_{vi}_{int(random.random()*1000)}",
|
||
"params": {"url": url, "title": title, "action": bp_name, "comment_text": comment_text,
|
||
"interval": bp_interval, "blueprint": bp_name, "rounds": 1},
|
||
})
|
||
|
||
like_n = sum(1 for t in tasks if "interact_like" in t["params"].get("blueprint", ""))
|
||
collect_n = sum(1 for t in tasks if "interact_collect" in t["params"].get("blueprint", ""))
|
||
comment_n = sum(1 for t in tasks if "interact_comment" in t["params"].get("blueprint", ""))
|
||
logger.info(f" 📋 互动计划(v2全量): {len(urls)}视频({skipped_videos}跳过) | 点赞≈{like_n} 收藏≈{collect_n} 评论≈{comment_n} | 任务≈{len(tasks)}")
|
||
return tasks
|
||
|
||
|
||
def _build_discussion_plan(machine_groups: dict, params: dict, now_ts: int) -> list:
|
||
"""生成多人讨论分发计划(讨论串 turns × 账号精确配对)
|
||
|
||
params:
|
||
urls: [{title, url}] — 视频(多视频时轮流分配)
|
||
turns: [{role, text}] — 已生成的讨论串(AI 一次生成)
|
||
min_per_account: int — 每账号发言下限(默认 1,保底参与)
|
||
max_per_account: int — 每账号发言上限(默认 2,防封号)
|
||
|
||
分配逻辑:随机抽发言账号 → 下限保底 → 剩余条数加权随机(条数少者优先,分布自然)
|
||
→ 账号槽位随机打散 → 每条讨论配一个账号(弱指向,不依赖发布顺序)
|
||
"""
|
||
import random
|
||
import collections
|
||
urls = params.get("urls", [])
|
||
turns = params.get("turns", [])
|
||
if not urls or not turns:
|
||
raise ValueError("discussion 需要 urls 和 turns 参数")
|
||
min_per = max(1, int(params.get("min_per_account", 1)))
|
||
max_per = max(min_per, int(params.get("max_per_account", 2)))
|
||
|
||
all_accts = []
|
||
for machine, accts in machine_groups.items():
|
||
for a in accts:
|
||
all_accts.append({
|
||
"id": a["id"], "machine": machine,
|
||
"is_local": (machine == HOSTNAME),
|
||
"nickname": a.get("nickname", ""),
|
||
"platform": a.get("platform", "douyin"),
|
||
})
|
||
if not all_accts:
|
||
raise ValueError("账号池为空")
|
||
|
||
total_turns = len(turns)
|
||
need_min = (total_turns + max_per - 1) // max_per # 至少需要多少发言账号
|
||
if len(all_accts) < need_min:
|
||
raise ValueError(
|
||
f"账号不足:{total_turns} 条讨论(每账号上限 {max_per})至少需要 {need_min} 个发言账号,"
|
||
f"当前仅 {len(all_accts)} 个。请补选账号或减少讨论条数"
|
||
)
|
||
|
||
# 发言账号数:按平均条数估算(下限保底),夹在 need_min ~ min(池, 条数) 之间
|
||
avg = (min_per + max_per) / 2.0
|
||
speaker_n = min(len(all_accts), total_turns,
|
||
max(need_min, int(round(total_turns / max(avg, 1.0)))))
|
||
speakers = random.sample(all_accts, speaker_n)
|
||
|
||
# 条数分配:下限保底 + 均匀随机加成(出现 1/2/3 条混合的自然分布,不刻意均摊)
|
||
alloc = {a["id"]: min_per for a in speakers}
|
||
remain = total_turns - min_per * speaker_n
|
||
guard = 0
|
||
while remain > 0 and guard < 100000:
|
||
guard += 1
|
||
cands = [a["id"] for a in speakers if alloc[a["id"]] < max_per]
|
||
if not cands:
|
||
break
|
||
aid = random.choice(cands)
|
||
alloc[aid] += 1
|
||
remain -= 1
|
||
|
||
# 账号槽位(按分配条数展开后随机打散)
|
||
slots = []
|
||
for aid, n in alloc.items():
|
||
slots.extend([aid] * n)
|
||
random.shuffle(slots)
|
||
|
||
aid_map = {a["id"]: a for a in all_accts}
|
||
tasks = []
|
||
for i, turn in enumerate(turns):
|
||
if i >= len(slots):
|
||
break
|
||
aid = slots[i]
|
||
a = aid_map[aid]
|
||
url = (urls[i % len(urls)] or {}).get("url", "")
|
||
text = (turn.get("text") or "").strip()
|
||
if not text or not url:
|
||
continue
|
||
tasks.append({
|
||
"machine": a["machine"], "cmd_type": "comment",
|
||
"ids_str": aid, "is_local": a["is_local"],
|
||
"nickname": a["nickname"], "platform": a["platform"],
|
||
"cmd_line": (
|
||
f"mc task comment --account={shlex.quote(aid)} "
|
||
f"--url={shlex.quote(url)} --comment={shlex.quote(text)} -y"
|
||
),
|
||
"run_id": f"discussion_{now_ts}_{a['machine']}_{aid}_{i}",
|
||
"priority": 0,
|
||
"params": {"url": url, "comment_text": text,
|
||
"role": turn.get("role", ""), "seq": i + 1},
|
||
})
|
||
|
||
dist = collections.Counter(alloc.values())
|
||
logger.info(
|
||
f" 💬 讨论计划: {total_turns} 条 × {speaker_n} 个发言账号 "
|
||
f"(条数分布 {dict(sorted(dist.items()))}) → {len(tasks)} 个任务"
|
||
)
|
||
return tasks
|
||
|
||
|
||
def _build_assigned_plan(machine_groups: dict, params: dict, now_ts: int) -> list:
|
||
"""生成「指定评论」分发计划:用户提供的评论 → 每条配一个账号(1 条/账号)
|
||
|
||
params:
|
||
urls: [{title, url}] — 视频(多视频时轮流分配)
|
||
comments: [str] — 用户提供的评论内容(前端已解析成条、去重去空)
|
||
spare_action: none|browse|like — 多余账号动作(账号数 > 评论数时;默认 none 不参与)
|
||
|
||
与多人讨论的区别:
|
||
- 1 条评论恰好配 1 个账号(不存在一个账号领多条)
|
||
- 账号不足 → 明确报错(提示补账号或减评论)
|
||
- 账号多余 → 按 spare_action 生成围观(预留)/ 点赞任务
|
||
"""
|
||
import random
|
||
urls = params.get("urls", [])
|
||
comments = [str(c).strip() for c in (params.get("comments") or []) if str(c).strip()]
|
||
if not urls:
|
||
raise ValueError("指定评论需要 urls 参数(视频链接)")
|
||
if not comments:
|
||
raise ValueError("缺少评论内容(comments 为空)")
|
||
spare_action = (params.get("spare_action") or "none").strip().lower()
|
||
|
||
all_accts = []
|
||
for machine, accts in machine_groups.items():
|
||
for a in accts:
|
||
all_accts.append({
|
||
"id": a["id"], "machine": machine,
|
||
"is_local": (machine == HOSTNAME),
|
||
"nickname": a.get("nickname", ""),
|
||
"platform": a.get("platform", "douyin"),
|
||
})
|
||
if not all_accts:
|
||
raise ValueError("账号池为空")
|
||
|
||
if len(all_accts) < len(comments):
|
||
short = len(comments) - len(all_accts)
|
||
raise ValueError(
|
||
f"账号不足:{len(comments)} 条指定评论需要 {len(comments)} 个账号,"
|
||
f"当前仅 {len(all_accts)} 个 —— 请补选 {short} 个账号,或减少 {short} 条评论"
|
||
)
|
||
|
||
# 随机抽「评论数」个账号领评论;评论顺序同时打散(不按序对应,避免规律)
|
||
speakers = random.sample(all_accts, len(comments))
|
||
shuffled = comments[:]
|
||
random.shuffle(shuffled)
|
||
|
||
tasks = []
|
||
for i, (a, text) in enumerate(zip(speakers, shuffled)):
|
||
url = (urls[i % len(urls)] or {}).get("url", "")
|
||
if not url:
|
||
continue
|
||
tasks.append({
|
||
"machine": a["machine"], "cmd_type": "comment",
|
||
"ids_str": a["id"], "is_local": a["is_local"],
|
||
"nickname": a["nickname"], "platform": a["platform"],
|
||
"cmd_line": (
|
||
f"mc task comment --account={shlex.quote(a['id'])} "
|
||
f"--url={shlex.quote(url)} --comment={shlex.quote(text)} -y"
|
||
),
|
||
"run_id": f"assigned_{now_ts}_{a['machine']}_{a['id']}_{i}",
|
||
"priority": 0,
|
||
"params": {"url": url, "comment_text": text, "seq": i + 1},
|
||
})
|
||
|
||
# 多余账号:按 spare_action 处理(like 生成点赞任务;browse 蓝图未实现,暂不生成)
|
||
speaker_ids = {a["id"] for a in speakers}
|
||
spare = [a for a in all_accts if a["id"] not in speaker_ids]
|
||
spare_done = 0
|
||
if spare and spare_action == "like":
|
||
for j, a in enumerate(spare):
|
||
url = (urls[j % len(urls)] or {}).get("url", "")
|
||
if not url:
|
||
continue
|
||
tasks.append({
|
||
"machine": a["machine"], "cmd_type": "interact",
|
||
"ids_str": a["id"], "is_local": a["is_local"],
|
||
"nickname": a["nickname"], "platform": a["platform"],
|
||
"cmd_line": (
|
||
f"mc run --accounts={shlex.quote(a['id'])} "
|
||
f"--blueprints=interact_like --rounds=1 --url={shlex.quote(url)}"
|
||
),
|
||
"run_id": f"assigned_spare_like_{now_ts}_{a['machine']}_{a['id']}_{j}",
|
||
"priority": 1, # 点赞优先级低于评论
|
||
"params": {"url": url, "blueprint": "interact_like", "action": "like"},
|
||
})
|
||
spare_done += 1
|
||
|
||
logger.info(
|
||
f" 📌 指定评论计划: {len(comments)} 条 × {len(speakers)} 个领评账号 | "
|
||
f"多余 {len(spare)} 个({spare_action}→{spare_done} 任务) → 共 {len(tasks)} 个任务"
|
||
)
|
||
return tasks
|
||
|
||
|
||
# ── 命令总线 ────────────────────────────────────────────────
|
||
class CommandBus:
|
||
"""全局命令总线 — 所有操作的统一入口"""
|
||
|
||
@classmethod
|
||
def dispatch(cls, cmd_type: str, accounts: list, params: dict = None, wait: bool = False) -> dict:
|
||
"""主入口:按机器分组后分发
|
||
之前: 每个账号一条命令
|
||
现在: 每台机器一条命令(含多个账号)
|
||
"""
|
||
params = params or {}
|
||
force_machine = params.get("machine", "")
|
||
dry_run = params.get("dry_run", False)
|
||
now_ts = int(time.time())
|
||
|
||
# ── 前置校验 ──
|
||
if not cmd_type:
|
||
return {"status": "error", "message": "type 必填 (login/logout/nurture/comment/like/collect)", "errors": [{"message": "type 必填"}]}
|
||
if not accounts:
|
||
return {"status": "error", "message": "accounts 必填 (至少一个账号ID)", "errors": [{"message": "accounts 为空"}]}
|
||
if not isinstance(accounts, list):
|
||
accounts = [accounts]
|
||
|
||
# 获取所有账号信息
|
||
try:
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
all_accts = {a["id"]: a for a in mgr.list_accounts()}
|
||
except Exception as e:
|
||
return {"status": "error", "message": f"加载账号失败: {e}", "errors": [{"message": str(e)}]}
|
||
|
||
# ── 加载 ORACLE 宪法(账号→机器映射表)──
|
||
oracle_map = {} # account_id → assigned_machine
|
||
try:
|
||
oracle_path = AGENT_SYNC / "ORACLE.yaml"
|
||
if oracle_path.exists():
|
||
import yaml
|
||
oracle = yaml.safe_load(oracle_path.read_text())
|
||
for entry in oracle.get("accounts", []):
|
||
machine = entry.get("machine", "") or entry.get("assigned_machine", "")
|
||
for plat, acct_id in entry.get("platforms", {}).items():
|
||
oracle_map[acct_id] = machine
|
||
except:
|
||
pass # ORACLE 文件不存在时不阻断执行
|
||
|
||
# ── 逐个账号校验 ──
|
||
errors = []
|
||
warnings = []
|
||
machine_groups = {}
|
||
for aid in accounts:
|
||
acct = all_accts.get(aid) if isinstance(aid, str) else aid
|
||
if not acct:
|
||
errors.append({"account": str(aid), "message": "账号不存在"})
|
||
continue
|
||
machine = force_machine or acct.get("owner_machine", "")
|
||
if not machine:
|
||
errors.append({"account": aid, "message": f"账号 {aid} 未分配机器 (owner_machine 为空)"})
|
||
continue
|
||
# ORACLE 合规检查
|
||
oracle_machine = oracle_map.get(aid)
|
||
if oracle_machine and oracle_machine != machine:
|
||
warnings.append({"account": aid, "message": f"账号 {aid} 按 ORACLE 应在机器 {oracle_machine},实际发往 {machine}"})
|
||
elif not oracle_machine:
|
||
warnings.append({"account": aid, "message": f"账号 {aid} 未在 ORACLE 登记,建议 git pull 同步后执行 fleet_reconcile"})
|
||
if machine not in machine_groups:
|
||
machine_groups[machine] = []
|
||
machine_groups[machine].append(acct)
|
||
|
||
results = []
|
||
|
||
# ── 互动计划生成器(strategy 模式)────────────────────────
|
||
# 前端传入 strategy 时(评论配额/账号分组/限流/节奏),不走常规循环,
|
||
# 由 _build_interact_plan 一次性生成全局互动计划(跨机器账号池)。
|
||
_plan_tasks = None
|
||
if cmd_type == "interact" and params.get("strategy"):
|
||
try:
|
||
_plan_tasks = _build_interact_plan(machine_groups, params, now_ts)
|
||
except Exception as e:
|
||
logger.error(f"互动计划生成失败: {e}")
|
||
errors.append({"account": "all", "message": f"互动计划生成失败: {e}"})
|
||
|
||
# ── 多人讨论计划生成器(turns × 账号精确配对)──────────
|
||
if cmd_type == "discussion":
|
||
try:
|
||
_plan_tasks = _build_discussion_plan(machine_groups, params, now_ts)
|
||
except Exception as e:
|
||
logger.error(f"讨论计划生成失败: {e}")
|
||
errors.append({"account": "all", "message": f"讨论计划生成失败: {e}"})
|
||
|
||
# ── 指定评论计划生成器(用户提供的评论 × 账号 1:1 配对)──
|
||
if cmd_type == "assigned_comment":
|
||
try:
|
||
_plan_tasks = _build_assigned_plan(machine_groups, params, now_ts)
|
||
except Exception as e:
|
||
logger.error(f"指定评论计划生成失败: {e}")
|
||
errors.append({"account": "all", "message": f"指定评论计划生成失败: {e}"})
|
||
|
||
# 第二步:按机器分组构建命令任务
|
||
# 返回 list[dict] = {machine, cmd_type, ids_str, is_local, cmd_line, params, run_id}
|
||
tasks = []
|
||
for machine, accts in machine_groups.items():
|
||
is_local = (machine == HOSTNAME)
|
||
all_ids = ",".join(a["id"] for a in accts)
|
||
|
||
# strategy 模式:计划已整体生成,跳过常规循环
|
||
if _plan_tasks is not None:
|
||
continue
|
||
|
||
if cmd_type == "nurture":
|
||
plat_groups = {}
|
||
for a in accts:
|
||
p = a.get("platform", "douyin")
|
||
plat_groups.setdefault(p, []).append(a)
|
||
for platform, plat_accts in plat_groups.items():
|
||
bp = params.get("blueprint") or {"douyin": "douyin_daily_clean", "xiaohongshu": "xhs_daily"}.get(platform, "douyin_daily_clean")
|
||
r = params.get("rounds", 10)
|
||
# 拆解:每个账号生成独立任务(让 guardd 3 slot 并行执行)
|
||
for a in plat_accts:
|
||
aid = a["id"]
|
||
tasks.append({
|
||
"machine": machine, "cmd_type": cmd_type,
|
||
"ids_str": aid, "is_local": is_local,
|
||
"nickname": a.get("nickname", ""),
|
||
"platform": a.get("platform", "douyin"),
|
||
"cmd_line": f"mc run --accounts={aid} --blueprints={bp} --rounds={r} --mix --interval=45-90",
|
||
"run_id": f"{cmd_type}_{now_ts}_{machine}_{aid}",
|
||
})
|
||
elif cmd_type == "interact" and params.get("actions"):
|
||
# 🎯 批量互动 v2 — 按百分比执行多动作
|
||
# 前端传入: actions: {like:80, collect:100, comment:30, follow:20, browse:{rate:40, count:3, mode:'interact'}}
|
||
url = params.get("url", "")
|
||
title = params.get("title", "")
|
||
actions = params.get("actions", {})
|
||
interval = params.get("interval", "300-600")
|
||
|
||
# 动作→蓝图映射(部分蓝图待录制)
|
||
BLUEPRINT_MAP = {
|
||
"like": {"name": "interact_like", "status": "existing"},
|
||
"collect": {"name": "interact_collect", "status": "placeholder"},
|
||
"comment": {"name": "interact_comment", "status": "existing"},
|
||
"follow": {"name": "interact_follow", "status": "placeholder"},
|
||
"browse": {"name": "browse_author_page", "status": "placeholder"},
|
||
}
|
||
|
||
for a in accts:
|
||
aid = a["id"]
|
||
sampled = []
|
||
# 采样:根据百分比决定是否执行
|
||
import random
|
||
|
||
# 顶层动作采样
|
||
for action_key in ["like", "collect", "comment", "follow"]:
|
||
rate = actions.get(action_key, 0)
|
||
if isinstance(rate, (int, float)) and rate > 0 and random.randint(1, 100) <= rate:
|
||
sampled.append(action_key)
|
||
|
||
# 浏览其他作品采样(独立逻辑)
|
||
browse_cfg = actions.get("browse", {})
|
||
browse_rate = 0
|
||
browse_count = 3
|
||
browse_mode = "interact"
|
||
if isinstance(browse_cfg, dict):
|
||
browse_rate = browse_cfg.get("rate", 0)
|
||
browse_count = browse_cfg.get("count", 3)
|
||
browse_mode = browse_cfg.get("mode", "interact")
|
||
elif isinstance(browse_cfg, (int, float)):
|
||
browse_rate = int(browse_cfg)
|
||
|
||
browse_triggered = browse_rate > 0 and random.randint(1, 100) <= browse_rate
|
||
if browse_triggered:
|
||
sampled.append(f"browse_{browse_mode}")
|
||
|
||
if not sampled:
|
||
continue
|
||
|
||
# 构建命令 — 使用蓝图组合执行
|
||
action_descs = []
|
||
for s in sampled:
|
||
bp_key = s.replace("_view", "").replace("_interact", "")
|
||
bp_info = BLUEPRINT_MAP.get(bp_key, {})
|
||
bp_name = bp_info.get("name", bp_key)
|
||
bp_status = bp_info.get("status", "placeholder")
|
||
action_descs.append(f"{s}({bp_name}:{bp_status})")
|
||
|
||
# 主命令:用第一个动作的蓝图
|
||
primary_bp = BLUEPRINT_MAP.get(sampled[0], {}).get("name", "interact_comment")
|
||
cmd_line = f"mc run --accounts={aid} --blueprints={primary_bp} --rounds=1 --url={url} --interval={interval}"
|
||
|
||
tasks.append({
|
||
"machine": machine, "cmd_type": "interact",
|
||
"ids_str": aid, "is_local": is_local,
|
||
"nickname": a.get("nickname", ""),
|
||
"platform": a.get("platform", "douyin"),
|
||
"cmd_line": cmd_line,
|
||
"run_id": f"interact_{now_ts}_{machine}_{aid}_{int(random.random()*1000)}",
|
||
"params": {
|
||
"url": url,
|
||
"title": title,
|
||
"sampled_actions": sampled,
|
||
"browse_count": browse_count if browse_triggered else 0,
|
||
"browse_mode": browse_mode if browse_triggered else "",
|
||
"blueprint": primary_bp,
|
||
"rounds": 1,
|
||
"interval": interval,
|
||
},
|
||
})
|
||
logger.info(f" 🎯 [{aid}] {title[:20]}: {', '.join(action_descs)}")
|
||
|
||
elif cmd_type == "smart_comment":
|
||
# 智能评论:先分析视频,再按账号拆解
|
||
urls = params.get("urls", [])
|
||
if not urls:
|
||
errors.append({"account": all_ids, "message": "smart_comment 需要 urls 参数"})
|
||
continue
|
||
direction = params.get("direction", "praise")
|
||
corpus_category = params.get("corpus_category", "")
|
||
|
||
# 跳过分析模式:使用传入的标题直接匹配语料,无需开浏览器
|
||
skip_analysis = params.get("skip_analysis", False)
|
||
titles_map = params.get("titles", {}) # {url: "标题文本"}
|
||
if skip_analysis and titles_map:
|
||
results = {}
|
||
for url in urls:
|
||
results[url] = {
|
||
"url": url,
|
||
"title": titles_map.get(url, ""),
|
||
"industry": "general",
|
||
"comment": "",
|
||
}
|
||
else:
|
||
try:
|
||
from services.video_analyzer import VideoAnalyzer
|
||
import asyncio
|
||
analyzer = VideoAnalyzer(max_concurrent=2)
|
||
# 获取该机器上账号的行业(取第一个账号的行业做匹配)
|
||
profiles = _load_profiles()
|
||
first_account = accts[0]["id"] if accts else ""
|
||
account_industry = profiles.get(first_account, {}).get("industry", None)
|
||
# 如果用户指定了语料分类,限制分析器只从该分类选评论
|
||
if corpus_category:
|
||
account_industry = corpus_category
|
||
results = asyncio.run(analyzer.analyze_batch(
|
||
urls, account_industry=account_industry, direction=direction
|
||
))
|
||
except Exception as e:
|
||
logger.error(f" 视频分析失败: {e}")
|
||
errors.append({"account": all_ids, "message": f"视频分析失败: {e}"})
|
||
continue
|
||
|
||
# 预览模式:只分析不分发,返回分析结果
|
||
is_preview = params.get("preview", False)
|
||
if is_preview:
|
||
preview = {}
|
||
for url, data in results.items():
|
||
preview[url] = {
|
||
"title": data.get("title", ""),
|
||
"description": data.get("description", ""),
|
||
"industry": data.get("industry", "general"),
|
||
"tags": data.get("tags", []),
|
||
"comment": data.get("comment", ""),
|
||
}
|
||
logger.info(f" 👁️ 预览模式: {len(preview)} 个视频分析完成")
|
||
# 直接返回预览数据,不入 tasks
|
||
return {"status": "ok", "preview": preview}
|
||
|
||
# 使用自定义评论(用户在前端修改后传回)
|
||
custom_comments = params.get("comments", {})
|
||
|
||
# 导入 CorpusManager(延迟导入,避免循环依赖)
|
||
import sys as _sys
|
||
_scripts_dir = str(AGENT_SYNC / "05_tools" / "07_matrix" / "scripts")
|
||
if _scripts_dir not in _sys.path:
|
||
_sys.path.insert(0, _scripts_dir)
|
||
from mc.corpus import CorpusManager
|
||
_cm = CorpusManager()
|
||
|
||
# 每个账号 × 每个 URL = 最小任务单元
|
||
for a in accts:
|
||
aid = a["id"]
|
||
for url_data in results.values():
|
||
url = url_data.get("url", "")
|
||
video_title = url_data.get("title", "")
|
||
# 优先级1: 用户手动编辑的评论
|
||
comment = custom_comments.get(url)
|
||
if not comment:
|
||
# 优先级2: 每个账号从语料库独立随机取
|
||
comment = _cm.get_comment_for_video(
|
||
video_title=video_title,
|
||
direction=direction,
|
||
account_id=aid,
|
||
)
|
||
if not comment:
|
||
comment = url_data.get("comment", "") # 兜底用 analyzer 的
|
||
if not comment:
|
||
continue
|
||
tasks.append({
|
||
"machine": machine, "cmd_type": "comment",
|
||
"ids_str": aid, "is_local": is_local,
|
||
"cmd_line": f'mc task comment --account={shlex.quote(aid)} --url={shlex.quote(url)} --comment={shlex.quote(comment)} -y',
|
||
"run_id": f"smart_comment_{int(time.time())}_{machine}_{aid}",
|
||
"priority": 0, # P0
|
||
"nickname": a.get("nickname", ""),
|
||
"platform": a.get("platform", "douyin"),
|
||
})
|
||
logger.info(f" 📝 [{aid}] → 评论: {comment[:40]}...")
|
||
else:
|
||
# 从操作注册表读取模板
|
||
cmd_config = CMD_REGISTRY.get(cmd_type)
|
||
if not cmd_config:
|
||
errors.append({"account": all_ids, "message": f"不支持的操作: {cmd_type}"})
|
||
continue
|
||
|
||
template = cmd_config.get("template", "")
|
||
defaults = cmd_config.get("defaults", {})
|
||
auto_bp = cmd_config.get("auto_blueprint", False)
|
||
bp_map = cmd_config.get("blueprint_map", {})
|
||
|
||
# 合并参数:params 覆盖 defaults
|
||
merged = dict(defaults)
|
||
merged.update(params)
|
||
|
||
# Auto-blueprint: 根据账号平台自动选择采集蓝图(多平台用逗号分隔)
|
||
if auto_bp and not merged.get("blueprint"):
|
||
platforms = set(a.get("platform", "douyin") for a in accts)
|
||
bp_list = [bp_map.get(p, "douyin_read_profile") for p in sorted(platforms)]
|
||
merged["blueprint"] = ",".join(bp_list)
|
||
|
||
# 任务拆解:interact/comment/collect 等交互任务拆成 per-account 子任务
|
||
# 这样每台机器的 guardd 调度队列中能看到每个账号的独立状态
|
||
if cmd_type in ("interact", "comment", "collect") and len(accts) > 1:
|
||
for a in accts:
|
||
aid = a["id"]
|
||
try:
|
||
cmd_line_a = template.format(ids=aid, ids_str=aid, **merged)
|
||
except KeyError:
|
||
safe_kw = {k: merged.get(k, "") for k in
|
||
[p[1] for p in __import__("string").Formatter().parse(template) if p[1]]}
|
||
safe_kw.update({"ids": aid, "ids_str": aid})
|
||
cmd_line_a = template.format(**safe_kw)
|
||
tasks.append({
|
||
"machine": machine, "cmd_type": cmd_type,
|
||
"ids_str": aid, "is_local": is_local,
|
||
"cmd_line": cmd_line_a,
|
||
"run_id": f"{cmd_type}_{now_ts}_{machine}_{aid}",
|
||
"nickname": a.get("nickname", ""),
|
||
"platform": a.get("platform", "douyin"),
|
||
"priority": params.get("priority", 1),
|
||
})
|
||
else:
|
||
# 普通任务(登录/单账号等):一条命令包含所有账号
|
||
try:
|
||
cmd_line = template.format(ids=all_ids, ids_str=all_ids, **merged)
|
||
except KeyError:
|
||
safe_kw = {k: merged.get(k, "") for k in
|
||
[p[1] for p in __import__("string").Formatter().parse(template) if p[1]]}
|
||
safe_kw.update({"ids": all_ids, "ids_str": all_ids})
|
||
cmd_line = template.format(**safe_kw)
|
||
if not cmd_line:
|
||
errors.append({"account": all_ids, "message": f"不支持的操作: {cmd_type}"})
|
||
continue
|
||
tasks.append({
|
||
"machine": machine, "cmd_type": cmd_type,
|
||
"ids_str": all_ids, "is_local": is_local,
|
||
"cmd_line": cmd_line,
|
||
"run_id": f"{cmd_type}_{now_ts}_{machine}",
|
||
})
|
||
|
||
# strategy 模式:合并计划生成器产出的任务
|
||
if _plan_tasks:
|
||
tasks.extend(_plan_tasks)
|
||
|
||
# 第三步:并行分发到各台机器
|
||
dispatched_cmds = []
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
|
||
def _do_send(t):
|
||
# 合并 task-specific 字段到 params
|
||
merged_params = dict(params)
|
||
for extra_key in ("nickname", "platform", "priority"):
|
||
if extra_key in t:
|
||
merged_params[extra_key] = t[extra_key]
|
||
return cls._execute_one(
|
||
t["cmd_type"], t["ids_str"], t["machine"],
|
||
t["is_local"], t["cmd_line"], merged_params,
|
||
t["run_id"], results, errors, dry_run
|
||
)
|
||
|
||
with ThreadPoolExecutor(max_workers=5) as ex:
|
||
futures = [ex.submit(_do_send, t) for t in tasks]
|
||
for f in as_completed(futures):
|
||
try:
|
||
cmd = f.result()
|
||
if cmd:
|
||
dispatched_cmds.append(cmd)
|
||
except Exception as e:
|
||
logger.error(f"并行分发异常: {e}")
|
||
|
||
# 第四步:任务拆解(interact 类型拆成 per-account 子任务)
|
||
decomposed_count = 0
|
||
for cmd in dispatched_cmds[:]:
|
||
if cmd.cmd_type == "interact" and len(cmd.accounts) > 1:
|
||
logger.info(f" 拆解 interact 任务: {len(cmd.accounts)}个账号 → 独立子任务")
|
||
for acct in cmd.accounts:
|
||
sub_task = copy.deepcopy(cmd)
|
||
sub_task.run_id = f"{cmd.run_id}_{acct}"
|
||
sub_task.accounts = [acct]
|
||
sub_task.cmd_line = cmd.cmd_line.replace(
|
||
",".join(cmd.accounts), acct
|
||
)
|
||
sub_task.params = dict(cmd.params)
|
||
sub_task.params["decomposed_from"] = cmd.run_id
|
||
# 投递到 guardd
|
||
_guardd_api("POST", "/scheduler/submit", {
|
||
"task_id": sub_task.run_id,
|
||
"cmd_type": "interact",
|
||
"accounts": [acct],
|
||
"blueprint": cmd.params.get("blueprint", "interact_comment"),
|
||
"priority": 0,
|
||
"interval": cmd.params.get("interval", 0),
|
||
"params": cmd.params,
|
||
}, machine=cmd.machine)
|
||
decomposed_count += 1
|
||
# 移除原始群组命令
|
||
dispatched_cmds.remove(cmd)
|
||
|
||
# 第五步:等待执行结果(仅 wait=True 时)
|
||
per_machine = {}
|
||
if wait and dispatched_cmds:
|
||
deadline = time.time() + params.get("timeout", 600)
|
||
while time.time() < deadline:
|
||
all_terminal = True
|
||
for cmd in dispatched_cmds:
|
||
session = MachineSession.get(cmd.machine)
|
||
session.poll(cmd)
|
||
if not cmd.status.is_terminal:
|
||
all_terminal = False
|
||
if all_terminal:
|
||
break
|
||
time.sleep(15)
|
||
|
||
# 聚合每台机器结果
|
||
for cmd in dispatched_cmds:
|
||
m = cmd.machine
|
||
if m not in per_machine:
|
||
per_machine[m] = {"accounts": [], "status": "running",
|
||
"success": 0, "failed": 0, "duration": 0}
|
||
per_machine[m]["accounts"].extend(cmd.accounts)
|
||
per_machine[m]["status"] = cmd.status.value
|
||
per_machine[m]["duration"] = max(per_machine[m]["duration"], cmd.elapsed_sec)
|
||
result_data = cmd.result or {}
|
||
steps = result_data.get("steps", {}) or {}
|
||
per_machine[m]["success"] += steps.get("success", 0) if steps else 0
|
||
per_machine[m]["failed"] += steps.get("failed", 0) if steps else 0
|
||
|
||
# 若全部完成则标记总状态
|
||
all_terminal = all(cmd.status.is_terminal for cmd in dispatched_cmds)
|
||
for m in per_machine:
|
||
per_machine[m]["status"] = per_machine[m]["status"] if all_terminal else "running"
|
||
|
||
total_success = sum(pm["success"] for pm in per_machine.values())
|
||
total_failed = sum(pm["failed"] for pm in per_machine.values())
|
||
total_accounts = sum(len(pm["accounts"]) for pm in per_machine.values())
|
||
|
||
# 写入 batch log(smart_comment 专用,非预览模式)
|
||
if cmd_type == "smart_comment" and not dry_run and not params.get("preview"):
|
||
try:
|
||
_save_batch_log(
|
||
batch_id=params.get("decomposed_from", f"smart_comment_{now_ts}"),
|
||
urls=params.get("urls", []),
|
||
video_ids=params.get("video_ids", []),
|
||
direction=params.get("direction", ""),
|
||
interval=params.get("interval", ""),
|
||
total=len(dispatched_cmds),
|
||
account_count=len(accts),
|
||
video_count=len(params.get("urls", [])),
|
||
machine_counts=per_machine if per_machine else {},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 计划统计(interact strategy 模式)──
|
||
_plan_summary = None
|
||
if _plan_tasks is not None:
|
||
_plan_summary = {
|
||
"comment": sum(1 for t in _plan_tasks if "interact_comment" in t.get("params", {}).get("blueprint", "")),
|
||
"like": sum(1 for t in _plan_tasks if "interact_like" in t.get("params", {}).get("blueprint", "")),
|
||
"collect": sum(1 for t in _plan_tasks if "interact_collect" in t.get("params", {}).get("blueprint", "")),
|
||
"total": len(_plan_tasks),
|
||
}
|
||
|
||
return {
|
||
"status": "completed" if (wait and all_terminal) else ("accepted" if not dry_run else "plan"),
|
||
"total_accounts": total_accounts or None,
|
||
"total_success": total_success or None,
|
||
"total_failed": total_failed or None,
|
||
"commands": results,
|
||
"per_machine": per_machine if per_machine else None,
|
||
"errors": errors if errors else None,
|
||
"warnings": warnings if warnings else None,
|
||
"total_tasks": _plan_summary["total"] if _plan_summary else None,
|
||
"summary": _plan_summary,
|
||
}
|
||
|
||
@classmethod
|
||
def _execute_one(cls, cmd_type, ids_str, machine, is_local, cmd_line, params, run_id, results, errors, dry_run):
|
||
"""执行单条命令,返回 Command 对象(供调用方追踪结果)"""
|
||
acct_ids = ids_str.split(",")
|
||
if dry_run:
|
||
results.append({
|
||
"accounts": acct_ids, "account": ids_str,
|
||
"machine": machine, "is_local": is_local, "command": cmd_line,
|
||
})
|
||
return None
|
||
|
||
cmd = Command(
|
||
cmd_type=cmd_type, accounts=acct_ids, machine=machine,
|
||
command_line=cmd_line, run_id=run_id, params=params
|
||
)
|
||
|
||
session = MachineSession.get(machine)
|
||
cmd.status = CommandStatus.PREFLIGHTING
|
||
pf = session.preflight()
|
||
if not pf["ok"]:
|
||
cmd.status = CommandStatus.PREFLIGHT_FAILED
|
||
cmd.message = pf["message"]
|
||
errors.append({"account": ids_str, "message": pf["message"]})
|
||
results.append(cmd.to_dict())
|
||
return cmd
|
||
|
||
for a in acct_ids:
|
||
session.graceful_exit(a)
|
||
|
||
session.send(cmd)
|
||
results.append(cmd.to_dict())
|
||
return cmd
|
||
|
||
@classmethod
|
||
def get_status(cls, machine: str = None, account: str = None) -> list[dict]:
|
||
"""查询各机状态 — 优先从 guardd HTTP API 读取"""
|
||
results = []
|
||
machines = [machine] if machine else list(MachineSession._sessions.keys())
|
||
if not machines:
|
||
machines = [HOSTNAME]
|
||
for m_name in machines:
|
||
try:
|
||
data = _guardd_api("GET", "/scheduler/tasks", machine=m_name)
|
||
if data:
|
||
results.append({"machine": m_name, "data": data})
|
||
continue
|
||
except Exception:
|
||
pass
|
||
# fallback: 从 MachineSession 读取(兼容旧版)
|
||
session = MachineSession.get(m_name)
|
||
for cmd in session.commands:
|
||
if account and account not in cmd.accounts:
|
||
continue
|
||
session.poll(cmd)
|
||
results.append(cmd.to_dict())
|
||
return results
|
||
|
||
@classmethod
|
||
def get_machine_status(cls, machine: str) -> dict:
|
||
"""查询单机状态 — 优先 guardd"""
|
||
try:
|
||
data = _guardd_api("GET", "/scheduler/tasks", machine=machine)
|
||
if data:
|
||
from services.browser_orchestrator import check_running_browsers
|
||
browsers = check_running_browsers(machine)
|
||
slots = data.get("slots", {})
|
||
return {
|
||
"machine": machine,
|
||
"is_local": machine == HOSTNAME,
|
||
"active_task": data.get("active"),
|
||
"queue": data.get("queue", []),
|
||
"slots": slots,
|
||
"browsers_running": len(browsers) if browsers else slots.get("used", 0),
|
||
"browser_list": browsers if browsers else [],
|
||
}
|
||
except Exception:
|
||
pass
|
||
# fallback
|
||
session = MachineSession.get(machine)
|
||
return super().get_machine_status(machine)
|
||
|
||
@classmethod
|
||
def cancel(cls, run_id: str) -> dict:
|
||
for session in MachineSession._sessions.values():
|
||
for c in session.commands:
|
||
if c.run_id == run_id:
|
||
return session.cancel(c)
|
||
return {"error": f"未找到命令: {run_id}"}
|
||
|
||
@classmethod
|
||
def get_machine_status(cls, machine: str) -> dict:
|
||
session = MachineSession.get(machine)
|
||
active = session.get_active_commands()
|
||
from services.browser_orchestrator import check_running_browsers
|
||
browsers = check_running_browsers(machine)
|
||
return {
|
||
"machine": machine,
|
||
"is_local": session.is_local,
|
||
"active_commands": len(active),
|
||
"browsers_running": len(browsers),
|
||
"browser_list": browsers,
|
||
"reachable": session.ssh_target is not None or session.is_local,
|
||
"queue": session.get_queue_info(),
|
||
}
|
||
|
||
@classmethod
|
||
def get_all_machines_status(cls) -> dict:
|
||
from services.browser_orchestrator import check_running_browsers
|
||
machines = {}
|
||
# 从 guardd 查询所有已知机器
|
||
known_machines = set(MachineSession._sessions.keys()) | {HOSTNAME}
|
||
try:
|
||
import yaml
|
||
oracle = yaml.safe_load(ORACLE_PATH.read_text())
|
||
for m_name in oracle.get("machines", {}):
|
||
known_machines.add(m_name)
|
||
except:
|
||
pass
|
||
for m_name in sorted(known_machines):
|
||
machines[m_name] = cls.get_machine_status(m_name)
|
||
return {"machines": machines}
|
||
|
||
|
||
# ── Poll 守卫线程(已迁移到 guardd 调度引擎)────────────────
|
||
# v4.3.0: CommandBus 不再负责轮询,由各机 guardd 的 Scheduler.run_cycle() 处理
|
||
# 保留空桩以兼容旧代码引用
|
||
def _start_poll_guard():
|
||
"""已废弃 — 轮询由 guardd 调度引擎处理"""
|
||
pass
|
||
|
||
|
||
def cleanup_stale_commands() -> int:
|
||
"""清理所有僵尸命令:进程已死但状态为 running 的标记为 CRASHED"""
|
||
count = 0
|
||
for name, session in MachineSession._sessions.items():
|
||
for cmd in session.commands:
|
||
if cmd.status.is_active:
|
||
# 先 poll 一下看能否自动判定
|
||
session.poll(cmd)
|
||
if cmd.status.is_active:
|
||
strategy = CMD_POLL_STRATEGY.get(cmd.cmd_type, DEFAULT_POLL_STRATEGY)
|
||
gp = strategy.get("grace_period", 30)
|
||
if cmd.elapsed_sec > (gp + 5):
|
||
cmd.status = CommandStatus.CRASHED
|
||
cmd.message = "僵尸清理: 进程已消失"
|
||
cmd.completed_at = time.time()
|
||
count += 1
|
||
logger.info(f" 🧹 清理僵尸: {cmd.run_id[:30]} ({cmd.cmd_type})")
|
||
# 如果当前命令被清理了,启动下一条
|
||
if session.current_cmd and session.current_cmd.status.is_terminal:
|
||
session._start_next()
|
||
return count
|
||
|
||
|
||
# ── Batch Log(执行记录持久化)─────────────────────────────
|
||
|
||
_BATCH_LOG_DIR = None
|
||
|
||
def _get_batch_log_dir():
|
||
global _BATCH_LOG_DIR
|
||
if _BATCH_LOG_DIR is None:
|
||
_BATCH_LOG_DIR = AGENT_LOCAL / "runtime" / "commands" / "batches"
|
||
_BATCH_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
return _BATCH_LOG_DIR
|
||
|
||
|
||
def _save_batch_log(batch_id: str, urls: list, video_ids: list,
|
||
direction: str, interval: str, total: int,
|
||
account_count: int, video_count: int,
|
||
machine_counts: dict):
|
||
"""保存一批任务的执行记录到 JSONL"""
|
||
import json, threading, time
|
||
log_entry = {
|
||
"batch_id": batch_id,
|
||
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"type": "smart_comment",
|
||
"total": total,
|
||
"video_count": video_count,
|
||
"account_count": account_count,
|
||
"urls": urls[:20], # 只存前20个,避免文件太大
|
||
"video_ids": video_ids[:20],
|
||
"direction": direction,
|
||
"interval": interval,
|
||
"machine_counts": machine_counts,
|
||
}
|
||
today = time.strftime("%Y-%m-%d")
|
||
log_file = _get_batch_log_dir() / f"{today}.jsonl"
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
|
||
|
||
|
||
def _load_batch_logs(limit: int = 50) -> list:
|
||
"""读取执行记录(最新 N 条)"""
|
||
import json
|
||
log_dir = _get_batch_log_dir()
|
||
if not log_dir.exists():
|
||
return []
|
||
entries = []
|
||
for f in sorted(log_dir.glob("*.jsonl"), reverse=True):
|
||
lines = f.read_text(encoding="utf-8").strip().split("\n")
|
||
for line in reversed(lines):
|
||
if line.strip():
|
||
try:
|
||
entries.append(json.loads(line))
|
||
except json.JSONDecodeError:
|
||
pass
|
||
if len(entries) >= limit:
|
||
break
|
||
if len(entries) >= limit:
|
||
break
|
||
return entries
|