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

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

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

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

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

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

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

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

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,20 @@
"""
mc — Matrix Console 统一命令入口 v1.0
用法:
mc run --accounts A,B --blueprints X,Y --rounds 10 --mix
mc account list
mc account login <name>
mc blueprint list
mc corpus list
mc proxy list
mc sms config
mc status all
设计原则:
- 所有操作 = 参数 + CLI 命令 + 输出日志
- 看板是 CLI 的可视化外壳
- 自动化/Guardd 也只调 CLI
- 可预测、可重复、可脚本化
"""
__version__ = "1.0.0"
@@ -0,0 +1,7 @@
"""
mc __main__ — python -m mc 入口
"""
from mc.cli import main
if __name__ == "__main__":
main()
@@ -0,0 +1,462 @@
"""
analyzer.py — 录制步骤差异分析引擎 (v1.0)
三阶段管线 Phase 2:
读取录制包 → 对比相邻步骤的 URL/DOM/事件 →
推断操作类型 → 生成结构化分析结果
用法:
from mc.analyzer import analyze_recording
result = analyze_recording(package)
"""
import json
import re
from pathlib import Path
from typing import Optional
# ── 抖音快捷键映射 ──
DY_HOTKEYS = {
'z': 'like', 'x': 'comment', 'c': 'comment',
'g': 'follow',
'k': 'loop', 'm': 'mute', 'j': 'clear',
'p': 'loop', 'h': 'fullscreen',
'ArrowDown': 'next_video', 'ArrowUp': 'prev_video',
' ': 'play_pause',
}
# ── 点击坐标区域定义 ──
# (x_min, y_min, x_max, y_max, label)
KNOWN_REGIONS = {
'dy_like_area': (330, 400, 500, 600, '抖音点赞区域'),
'dy_comment_area': (350, 680, 500, 720, '抖音评论区触发'),
'dy_search_bar': (50, 50, 350, 90, '抖音搜索框'),
'xhs_bottom_nav': (0, 700, 400, 780, 'XHS底部导航'),
'xhs_me_tab': (300, 700, 400, 780, 'XHS"我"标签'),
}
def analyze_recording(package: dict) -> dict:
"""分析录制包,生成可导出的原子操作定义"""
steps = package.get("steps", [])
if len(steps) < 2:
return {"error": "至少需要2个步骤才能分析", "steps_analyzed": 0}
result = {
"meta": package.get("meta", {}),
"steps_analyzed": len(steps),
"actions": [],
"blueprint_suggestions": [],
}
# 分析相邻步骤对
for i in range(len(steps) - 1):
before = steps[i]
after = steps[i+1]
analysis = _analyze_step_pair(before, after)
result["actions"].append(analysis)
# 生成蓝图建议
result["blueprint_suggestions"] = _suggest_blueprint(result["actions"])
return result
def _analyze_step_pair(before: dict, after: dict) -> dict:
"""分析一对相邻步骤的变化"""
bp = before.get("page", {})
ap = after.get("page", {})
events = before.get("events", [])
# before_state 对比(三段式记录的状态指纹)
bbs = before.get("before_state", {}) or {}
abs = after.get("before_state", {}) or {}
analysis = {
"step_before": before["step"],
"step_after": after["step"],
"event_count": len(events),
"page_url": ap.get("url", ""),
"page_mode": ap.get("page_mode", "?"),
"interactable_count": ap.get("interactable_count", 0),
}
# 0. before_state 选择器变化分析(优先级最高)
sel_before = bbs.get("selectors", {})
sel_after = abs.get("selectors", {})
if sel_before and sel_after:
sel_changes = {}
all_keys = set(sel_before) | set(sel_after)
for k in all_keys:
v1 = sel_before.get(k, False)
v2 = sel_after.get(k, False)
if v1 != v2:
sel_changes[k] = f"{'✅' if v1 else '❌'}→{'✅' if v2 else '❌'}"
if sel_changes:
analysis["selector_changes"] = sel_changes
# 基于选择器变化推断操作
op = _infer_op_from_selectors(sel_before, sel_after, bbs, abs)
if op:
analysis["action_type"] = op["type"]
analysis["action_desc"] = op["desc"]
analysis["confidence"] = op["confidence"]
# 1. URL 变化分析
url_before = bp.get("url", "")
url_after = ap.get("url", "")
if url_before != url_after:
analysis["url_changed"] = True
analysis["url_from"] = url_before[:80]
analysis["url_to"] = url_after[:80]
# URL 变化类型推断
if "/video/" in url_after and "/video/" not in url_before:
analysis["action_type"] = "navigate_video"
analysis["action_desc"] = "进入视频播放页"
elif "/user/profile/" in url_after:
analysis["action_type"] = "navigate_profile"
analysis["action_desc"] = "进入个人主页"
elif "/search/" in url_after or "?keyword=" in url_after:
analysis["action_type"] = "search"
analysis["action_desc"] = "搜索"
elif "/user/self" in url_after:
analysis["action_type"] = "navigate_profile"
analysis["action_desc"] = "进入抖音主页"
else:
analysis["action_type"] = "navigate"
analysis["action_desc"] = "页面导航"
else:
analysis["url_changed"] = False
# 2. 页面模式变化
mode_before = bp.get("page_mode", "?")
mode_after = ap.get("page_mode", "?")
if mode_before != mode_after:
analysis["mode_changed"] = f"{mode_before} → {mode_after}"
if mode_before == "grid" and mode_after == "player":
analysis["action_type"] = analysis.get("action_type", "enter_video")
analysis["action_desc"] = analysis.get("action_desc", "进入视频播放")
elif mode_before == "player" and mode_after == "grid":
analysis["action_type"] = analysis.get("action_type", "exit_video")
analysis["action_desc"] = analysis.get("action_desc", "退出视频")
elif mode_after == "profile":
analysis["action_type"] = analysis.get("action_type", "view_profile")
analysis["action_desc"] = analysis.get("action_desc", "查看主页")
# 3. 事件序列分析(从用户操作反推操作类型)
if events:
action = _infer_action_from_events(events)
if action:
analysis["inferred_action"] = action
# 事件推断的优先级高于 URL 推断
if action.get("type") and not analysis.get("action_type"):
analysis["action_type"] = action["type"]
analysis["action_desc"] = action.get("desc", "")
# 4. 文本差异分析
text_before = bp.get("text_snippet", "")
text_after = ap.get("text_snippet", "")
if text_before and text_after and text_before != text_after:
analysis["text_changed"] = True
# 检测文本中的关键变化
added = _find_added_text(text_before, text_after)
if added:
analysis["text_added"] = added[:100]
# 5. 标题变化
title_before = bp.get("title", "")
title_after = ap.get("title", "")
if title_before != title_after:
analysis["title_changed"] = f"{title_before[:30]} → {title_after[:30]}"
# 6. 交互元素变化
ia_before = set(e.get("e2e", "") or e.get("cls", "") for e in bp.get("interactables", []))
ia_after = set(e.get("e2e", "") or e.get("cls", "") for e in ap.get("interactables", []))
new_elements = ia_after - ia_before
if new_elements:
analysis["new_elements"] = list(new_elements)[:5]
# 7. 生成特征码(可复现的自动化选择器建议)
analysis["features"] = _suggest_features(bp, ap, events, analysis)
# 8. 检测空步骤(连续两次 · 无操作)
if not analysis.get("action_type"):
no_events = len(events) <= 1 # 只有 · 键事件
no_url_change = not analysis.get("url_changed", True)
no_sel_change = not analysis.get("selector_changes")
no_mode_change = not analysis.get("mode_changed")
no_clicks = len([e for e in events if e.get('t') == 'click']) == 0
no_scrolls = len([e for e in events if e.get('t') == 'scroll']) == 0
if no_events and no_url_change and no_sel_change and no_mode_change:
analysis["action_type"] = "skip"
analysis["action_desc"] = "无有效操作(空步骤)"
analysis["skip_reason"] = "no_change"
# 9. 生成操作摘要
if not analysis.get("action_type"):
analysis["action_type"] = "unknown"
analysis["action_desc"] = f"页面内容变化 ({len(events)} events)"
return analysis
def _infer_op_from_selectors(sel_before: dict, sel_after: dict,
bbs: dict, abs: dict) -> Optional[dict]:
"""基于 before_state 的选择器变化推断具体操作"""
# 抖音 like 按钮: [data-e2e="video-player-digg"]
# 抖音 collect: [data-e2e="video-player-collect"]
# 小红书 note: section.note-item
digg_key = '[data-e2e="video-player-digg"]'
collect_key = '[data-e2e="video-player-collect"]'
search_key = '[data-e2e="searchbar-input"]'
video_key = 'video'
note_key = 'section.note-item'
p_before = bbs.get("page_mode", "")
p_after = abs.get("page_mode", "")
# 检测抖音点赞操作
digg_before = sel_before.get(digg_key, False)
digg_after = sel_after.get(digg_key, False)
if digg_before and not digg_after and p_before == "player":
return {"type": "like", "desc": "点赞", "confidence": "high"}
# 检测抖音取消点赞
if not digg_before and digg_after and p_before == "player":
liked_text_before = bbs.get("markers", {}).get("liked", "")
liked_text_after = abs.get("markers", {}).get("liked", "")
return {"type": "like", "desc": "点赞(按钮重显)", "confidence": "medium"}
# 检测抖音收藏操作
collect_before = sel_before.get(collect_key, False)
collect_after = sel_after.get(collect_key, False)
if collect_before and not collect_after and p_before == "player":
return {"type": "collect", "desc": "收藏", "confidence": "high"}
# 检测搜索操作
search_before = sel_before.get(search_key, False)
search_after = sel_after.get(search_key, False)
if not search_before and search_after:
return {"type": "search", "desc": "打开搜索", "confidence": "high"}
if search_before and not search_after:
return {"type": "go_back", "desc": "关闭搜索", "confidence": "medium"}
# 检测进入小红书笔记
note_before = sel_before.get(note_key, False)
note_after = sel_after.get(note_key, False)
if note_before and not note_after and p_before == "grid":
return {"type": "xhs_click_note", "desc": "点击笔记", "confidence": "high"}
if not note_before and note_after:
return {"type": "go_back", "desc": "返回笔记列表", "confidence": "medium"}
# 检测视频播放状态变化
video_before = sel_before.get(video_key, False)
video_after = sel_after.get(video_key, False)
if not video_before and video_after:
return {"type": "enter_video", "desc": "进入视频页", "confidence": "medium"}
if video_before and not video_after:
return {"type": "exit_video", "desc": "退出视频", "confidence": "medium"}
return None
def _infer_action_from_events(events: list) -> Optional[dict]:
"""从 CDP 事件序列推断操作类型"""
# 取关键事件:键盘 + 点击
key_events = [e for e in events if e.get('t') == 'key']
click_events = [e for e in events if e.get('t') == 'click']
scroll_events = [e for e in events if e.get('t') == 'scroll']
# 抖音快捷键识别
for ke in key_events:
k = ke.get('k', '').lower()
if k in DY_HOTKEYS:
action = DY_HOTKEYS[k]
desc_map = {
'like': '点赞', 'comment': '打开评论', 'follow': '关注',
'loop': '连播', 'mute': '静音', 'clear': '清屏',
'fullscreen': '全屏', 'next_video': '下一个视频',
'prev_video': '上一个视频', 'play_pause': '播放/暂停',
}
return {"type": action, "desc": desc_map.get(action, action),
"trigger": f"key:{k}", "confidence": "high"}
# 字母键识别
for ke in key_events:
k = ke.get('k', '')
if len(k) == 1 and k.isalpha():
return {"type": f"keypress_{k}", "desc": f"按键 {k}",
"trigger": f"key:{k}", "confidence": "medium"}
# 点击事件分析
if click_events:
last_click = click_events[-1]
x, y = last_click.get('x', 0), last_click.get('y', 0)
target = last_click.get('target', '')
# 检测区域
for region_name, (x1, y1, x2, y2, label) in KNOWN_REGIONS.items():
if x1 <= x <= x2 and y1 <= y <= y2:
return {"type": f"click_{region_name}", "desc": label,
"trigger": f"click({x},{y})", "confidence": "medium",
"coords": {"x": x, "y": y}}
return {"type": "click", "desc": f"点击 ({x},{y}) <{target}>",
"trigger": f"click({x},{y})", "confidence": "medium",
"coords": {"x": x, "y": y}}
# 滚动事件
if scroll_events and len(scroll_events) >= 2:
total_dy = sum(e.get('dy', 0) for e in scroll_events)
if total_dy > 200:
return {"type": "scroll", "desc": f"下滚 (delta={total_dy})",
"trigger": "scroll", "confidence": "medium"}
return None
def _find_added_text(old_text: str, new_text: str) -> str:
"""简单的文本差异检测——找出出现在新文本中但不在旧文本中的部分"""
# 行级别 diff
old_lines = set(old_text.split('\n'))
new_lines = new_text.split('\n')
added = [l.strip() for l in new_lines if l.strip() and l.strip() not in old_lines
and len(l.strip()) > 3]
return '\n'.join(added[:10]) if added else ''
def _suggest_features(before_page: dict, after_page: dict,
events: list, analysis: dict) -> list:
"""从事件和页面状态生成自动化特征码建议"""
features = []
key_events = [e for e in events if e.get('t') == 'key']
click_events = [e for e in events if e.get('t') == 'click']
# 来自推断操作的特征
inferred = analysis.get("inferred_action", {})
if inferred:
trigger = inferred.get("trigger", "")
if trigger.startswith("key:"):
k = trigger.split(":")[1]
features.append({
"type": "keyboard",
"selector": None,
"description": f"按键 {k}",
"code": f'await page.keyboard.press("{k}")',
"confidence": inferred.get("confidence", "medium"),
})
elif "coords" in inferred:
coords = inferred["coords"]
# 尝试从 after_page 的 interactables 中找到最近的可点击元素
closest = None
for el in after_page.get("interactables", []):
rect_str = el.get("rect", "")
if rect_str and ',' in rect_str:
parts = rect_str.split()
if len(parts) >= 1:
xy = parts[0].split(',')
if len(xy) == 2:
try:
ex, ey = int(xy[0]), int(xy[1])
dist = (coords['x'] - ex) ** 2 + (coords['y'] - ey) ** 2
if dist < 5000 and (closest is None or dist < closest[0]):
closest = (dist, el)
except:
pass
if closest:
el = closest[1]
selector = el.get("e2e") or el.get("cls", "").split()[0] if el.get("cls") else None
tag = el.get("tag", "")
text = el.get("text", "")
features.append({
"type": "click",
"selector": f'[data-e2e="{selector}"]' if selector else (f'{tag}:has-text("{text}")' if text else None),
"description": f'点击 <{tag}> "{text[:20]}"' if text else f'点击 <{tag}>',
"code": f'await page.locator("{selector or tag}").click()',
"confidence": "high" if selector else "medium",
"near_coords": f'({coords["x"]},{coords["y"]})',
})
else:
features.append({
"type": "click_coords",
"selector": None,
"description": f"点击坐标 ({coords['x']},{coords['y']})",
"code": f'await page.mouse.click({coords["x"]}, {coords["y"]})',
"confidence": "medium",
})
# URL 变化特征
if analysis.get("url_changed"):
url_to = analysis.get("url_to", "")
if "/video/" in url_to:
features.append({
"type": "navigation",
"selector": 'a[href*="/video/"]',
"description": "进入视频播放页",
"code": 'await page.goto(video_url)',
"confidence": "high",
})
elif "/user/profile/" in url_to:
features.append({
"type": "navigation",
"selector": 'a[href*="/user/profile/"]',
"description": "进入个人主页",
"code": 'await page.goto(profile_url)',
"confidence": "high",
})
# 页面模式变化的特征
if analysis.get("mode_changed"):
features.append({
"type": "page_mode",
"selector": None,
"description": f"页面模式: {analysis['mode_changed']}",
"code": None,
"confidence": "high",
})
return features
def _suggest_blueprint(actions: list) -> list:
"""从分析结果生成蓝图建议"""
suggestions = []
for i, action in enumerate(actions):
step_num = i + 1
at = action.get("action_type", "unknown")
# 操作名 → 建议的原子操作op名
op_map = {
"like": "like", "comment": "open_comment", "follow": "follow",
"navigate_video": "open_video", "navigate_profile": "goto_profile",
"scroll": "scroll_feed", "next_video": "next_video",
"click_like_area": "like", "click_comment_area": "open_comment",
"search": "search", "enter_video": "enter_video",
}
op = op_map.get(at, f"custom_{at}")
suggestions.append({
"step": step_num,
"suggested_op": op,
"action_type": at,
"desc": action.get("action_desc", ""),
"confidence": action.get("inferred_action", {}).get("confidence", "medium"),
})
return suggestions
# ── 快捷入口 ──
def analyze_recording_file(path: str) -> dict:
"""从文件路径加载并分析录制包"""
from mc.recorder import RecordingSession
pkg = RecordingSession.load_recording(path)
return analyze_recording(pkg)
if __name__ == "__main__":
import sys
path = sys.argv[1] if len(sys.argv) > 1 else ""
if path:
result = analyze_recording_file(path)
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print("用法: python -m mc.analyzer <录制包路径>")
@@ -0,0 +1,190 @@
"""
browser.py — 浏览器生命周期管理器
核心功能:
1. Context manager: with BrowserManager() as bm:
2. 自动检测并清理残留进程
3. 自动清理 wal/shm 锁文件
4. 记录 PID 到 pid_file
5. 退出时妥善关闭 + 确认
用法:
from mc.browser import BrowserManager
with BrowserManager() as bm:
bm.prepare("douyin_01")
# ... 执行操作 ...
# 退出时自动:
# - 关闭浏览器
# - 等待进程退出
# - 清理 wal/shm
# - 删除 pid_file
"""
import logging
import os
import signal
import sqlite3
import subprocess
import time
from pathlib import Path
log = logging.getLogger(__name__)
from matrix_mgmt import AGENT_LOCAL
LOCAL_ROOT = AGENT_LOCAL / "tools" / "matrix"
PID_DIR = Path("/tmp") / "mc_pids"
class BrowserManager:
"""浏览器生命周期管理器"""
def __init__(self):
self._managed_pids: list[int] = []
PID_DIR.mkdir(parents=True, exist_ok=True)
def __enter__(self):
log.info("🚀 BrowserManager 启动")
self._cleanup_stale()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
log.info("🛑 BrowserManager 关闭中...")
self._close_all()
self._cleanup_pid_files()
log.info("✅ BrowserManager 已关闭")
# ──────────────── 公共方法 ────────────────
def prepare(self, identity: str) -> dict:
"""准备某个身份的浏览器环境
Args:
identity: 身份目录名 (douyin_01, douyin_04 等)
Returns:
{"status": "ok", "pid": pid} 或 {"status": "stale", "pid": old_pid}
"""
pid_file = PID_DIR / f"{identity}.pid"
# 检查是否有残留进程
if pid_file.exists():
old_pid = int(pid_file.read_text().strip())
if self._is_process_alive(old_pid):
log.warning(f" ⚠️ {identity} 已有残留进程 PID={old_pid},清理中...")
self._kill_process(old_pid)
pid_file.unlink(missing_ok=True)
# 清理 cookie 锁文件
self._cleanup_cookie_locks(identity)
# 清理 Camoufox profile lock
self._cleanup_profile_locks(identity)
return {"status": "ok"}
def register_pid(self, identity: str, pid: int):
"""记录进程 PID"""
pid_file = PID_DIR / f"{identity}.pid"
pid_file.write_text(str(pid))
self._managed_pids.append(pid)
log.info(f" 📝 {identity} PID={pid} 已注册")
def is_healthy(self, identity: str) -> bool:
"""检查某个身份对应的浏览器是否存活"""
pid_file = PID_DIR / f"{identity}.pid"
if not pid_file.exists():
return False
try:
pid = int(pid_file.read_text().strip())
return self._is_process_alive(pid)
except:
return False
# ──────────────── 内部方法 ────────────────
def _is_process_alive(self, pid: int) -> bool:
"""检查进程是否存在"""
try:
os.kill(pid, 0)
return True
except (OSError, ProcessLookupError):
return False
def _kill_process(self, pid: int, force: bool = False):
"""安全终止进程"""
try:
if force:
os.kill(pid, signal.SIGKILL)
log.info(f" 💀 强制终止 PID={pid}")
else:
os.kill(pid, signal.SIGTERM)
# 等待最多 5 秒
for _ in range(10):
if not self._is_process_alive(pid):
break
time.sleep(0.5)
if self._is_process_alive(pid):
os.kill(pid, signal.SIGKILL)
log.info(f" 💀 SIGTERM 超时,SIGKILL PID={pid}")
else:
log.info(f" ✅ PID={pid} 已正常退出")
except ProcessLookupError:
pass # 进程已不存在
except Exception as e:
log.warning(f" ⚠️ 终止 PID={pid} 失败: {e}")
def _cleanup_stale(self):
"""清理所有残留的 mc 管理进程"""
for pid_file in PID_DIR.glob("*.pid"):
try:
pid = int(pid_file.read_text().strip())
if not self._is_process_alive(pid):
pid_file.unlink(missing_ok=True)
log.info(f" 🧹 清除残留 PID 文件: {pid_file.name}")
except:
pid_file.unlink(missing_ok=True)
def _cleanup_cookie_locks(self, identity: str):
"""清理 cookie 数据库的 wal/shm 锁文件"""
cookie_dir = LOCAL_ROOT / "identities" / identity / "user_data"
for ext in ["-wal", "-shm"]:
f = cookie_dir / f"cookies.sqlite{ext}"
if f.exists():
try:
# 检查是否被进程占用
f.unlink()
log.info(f" 🧹 清理锁文件: {f.name}")
except OSError:
log.warning(f" ⚠️ 锁文件被占用,跳过: {f.name}")
def _cleanup_profile_locks(self, identity: str):
"""清理 Camoufox profile 锁文件"""
profile_dir = LOCAL_ROOT / "identities" / identity / "user_data"
for lock_file in [".parentlock", "lock", "parent.lock"]:
f = profile_dir / lock_file
if f.exists():
try:
f.unlink()
log.info(f" 🧹 清理 profile 锁: {lock_file}")
except:
pass
def _close_all(self):
"""关闭所有管理的浏览器进程"""
for pid in self._managed_pids:
if self._is_process_alive(pid):
self._kill_process(pid)
self._managed_pids.clear()
def _cleanup_pid_files(self):
"""清理所有 PID 文件"""
for pid_file in PID_DIR.glob("*.pid"):
pid_file.unlink(missing_ok=True)
def cleanup_all():
"""一键清理所有残留(供 Guardd 定时调用)"""
with BrowserManager() as bm:
bm._cleanup_stale()
return {"status": "ok", "message": "所有残留已清理"}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,718 @@
"""
engine.py — mc run 执行引擎(v5.3 — 身份共享 + 多平台同浏览器)
核心逻辑:
1. 按 identity_dir 分组账号 → 同身份共用一个浏览器
2. 每个浏览器内先做完抖音,再做小红书
3. 不同身份组之间错峰启动(防假死)
4. 每个账号在其平台上顺序执行蓝图
"""
import asyncio
import json
import logging
import os
import random
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional
log = logging.getLogger(__name__)
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
TOOL_DIR = SCRIPTS_DIR.parent
from matrix_mgmt import AGENT_SYNC, AGENT_LOCAL
IDENTITIES_ROOT = AGENT_LOCAL / "tools" / "matrix" / "identities"
sys.path.insert(0, str(SCRIPTS_DIR))
# ════════════════════════════════════════════════════════════
# 工具函数
# ════════════════════════════════════════════════════════════
def resolve_account(account_id: str) -> dict:
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
for a in mgr.list_accounts():
if a["id"] == account_id:
return a
raise ValueError(f"账号不存在: {account_id}")
def resolve_blueprint(name: str) -> Optional[dict]:
bp_dir = TOOL_DIR / "blueprints"
# 长名字优先(避免 douyin_daily 被子串匹配到 douyin_daily_clean)
for f in sorted(bp_dir.glob("*.json"), key=lambda p: -len(p.stem)):
bp = json.loads(f.read_text())
if name in (bp.get("id", ""), bp.get("name", ""), f.stem):
return bp
if name in f.stem or f.stem in name:
return json.loads(f.read_text())
return None
def merge_blueprints(names: str) -> Optional[dict]:
"""组合蓝图:把多个蓝图(+ 分隔)合并为一个执行序列
规则:
1. 第一个 goto_url 保留,后续蓝图的 goto_url 跳过(页面已在目标视频)
2. 连续 wait/wait_watch 合并(取较大 seconds)
"""
bps = []
for n in names.split("+"):
bp = resolve_blueprint(n.strip())
if bp:
bps.append(bp)
if not bps:
return None
merged_steps = []
seen_url_goto = False
prev_was_wait = False
for bp in bps:
for s in bp.get("steps", []):
op = s.get("op", "")
if op == "goto_url":
if seen_url_goto:
continue # 已在目标视频,跳过重复导航
seen_url_goto = True
prev_was_wait = False
merged_steps.append(s)
elif op in ("wait", "wait_watch"):
if prev_was_wait:
# 合并连续等待:保留较大 seconds
try:
prev_s = float((merged_steps[-1].get("args", {}) or {}).get("seconds") or 0)
cur_s = float((s.get("args", {}) or {}).get("seconds") or 0)
merged_steps[-1]["args"]["seconds"] = max(prev_s, cur_s)
except Exception:
pass
continue
prev_was_wait = True
merged_steps.append(s)
else:
prev_was_wait = False
merged_steps.append(s)
return {
"id": "+".join(b["id"] for b in bps),
"name": "+".join(b["name"] for b in bps),
"description": "组合蓝图(多动作连做)",
"platform": bps[0].get("platform", "douyin"),
"steps": merged_steps,
}
def check_cookie(identity_hint: str) -> str:
"""检查身份目录 cookie 状态 → 'ok'|'no_cookie'|'expired'|'no_identity'"""
if not identity_hint:
return "no_identity"
ident_dir = IDENTITIES_ROOT / identity_hint
if not ident_dir.exists():
return "no_identity"
ck = ident_dir / "user_data" / "cookies.sqlite"
if not ck.exists() or ck.stat().st_size < 100:
return "no_cookie"
try:
import sqlite3
conn_ck = sqlite3.connect(str(ck), timeout=2)
cnt = conn_ck.execute("SELECT count(*) FROM moz_cookies WHERE name LIKE '%session%'").fetchone()[0]
conn_ck.close()
return "ok" if cnt > 0 else "expired"
except:
return "error"
def group_accounts_by_identity(account_ids: List[str]) -> dict:
"""按 identity_dir 分组账号,同目录的放到一个组里"""
groups = {}
for aid in account_ids:
try:
info = resolve_account(aid)
except ValueError:
continue
ident = info.get("identity_dir", aid).replace("identities/", "")
groups.setdefault(ident, []).append(info)
return groups
# ════════════════════════════════════════════════════════════
# 执行报告
# ════════════════════════════════════════════════════════════
class StepResult:
def __init__(self, op: str, step_id: int, success: bool = True,
duration: float = 0, error: str = ""):
self.op = op; self.step_id = step_id; self.success = success
self.duration = duration; self.error = error
def __str__(self):
icon = "✅" if self.success else "❌"
return f"{icon} [{self.step_id:2d}] {self.op:20s} ({self.duration:.1f}s) {self.error}"
class AccountRunReport:
def __init__(self, account: str, blueprint: str, round_idx: int):
self.account = account; self.blueprint = blueprint; self.round = round_idx
self.steps: List[StepResult] = []; self.start = time.time()
self.success = 0; self.failed = 0; self.skipped = False
@property
def duration(self) -> float: return time.time() - self.start
def add_step(self, result: StepResult):
self.steps.append(result)
if result.success: self.success += 1
else: self.failed += 1
def to_dict(self) -> dict:
return {"account": self.account, "blueprint": self.blueprint,
"round": self.round, "success": self.success, "failed": self.failed,
"skipped": self.skipped, "duration": round(self.duration, 1),
"steps": [{"op": s.op, "step_id": s.step_id, "success": s.success,
"duration": round(s.duration, 1), "error": s.error} for s in self.steps]}
class BatchReport:
def __init__(self):
self.accounts: List[str] = []; self.blueprints: List[str] = []
self.rounds_total = 0; self.mix_mode = False
self.start = time.time(); self.account_reports: List[AccountRunReport] = []
self.error: Optional[str] = None
@property
def duration(self) -> float: return time.time() - self.start
@property
def success(self) -> int: return sum(r.success for r in self.account_reports)
@property
def failed(self) -> int: return sum(r.failed for r in self.account_reports)
@property
def skipped(self) -> int: return sum(1 for r in self.account_reports if r.skipped)
def to_dict(self) -> dict:
return {"accounts": self.accounts, "blueprints": self.blueprints,
"rounds": self.rounds_total, "mix_mode": self.mix_mode,
"duration": round(self.duration, 1),
"total_steps": self.success + self.failed,
"success": self.success, "failed": self.failed,
"skipped": self.skipped,
"account_reports": [r.to_dict() for r in self.account_reports],
"error": self.error}
# ════════════════════════════════════════════════════════════
# 核心引擎
# ════════════════════════════════════════════════════════════
# 平台→默认蓝图映射(当不传蓝图时自动匹配)
PLATFORM_BLUEPRINTS = {
"douyin": "douyin_read_profile",
"xiaohongshu": "xiaohongshu_read_profile",
}
PLATFORM_DAILY_BLUEPRINTS = {
"douyin": "douyin_daily",
"xiaohongshu": "xhs_daily",
}
class BatchEngine:
def __init__(self, accounts: List[str], blueprints: List[str] = None,
rounds: int = 10, mix: bool = False,
corpus: List[str] = None,
stagger: str = "15-30",
keep_open: bool = False,
max_browsers: int = 3,
run_id: str = "",
task_params: dict = None):
self.accounts = accounts
self.blueprints = blueprints or []
self.rounds_total = rounds
self.mix = mix
self.corpus = corpus or []
self.stagger = stagger
self.keep_open = keep_open
self.max_browsers = max_browsers
self.run_id = run_id
self.task_params = task_params or {}
def _mark_banned(self, account_id: str, platform: str):
"""将账号标记为封号状态,写入 profiles.json"""
try:
from pathlib import Path
home = Path.home()
profiles_path = home / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "data" / "profiles.json"
if profiles_path.exists():
import json
all_p = json.loads(profiles_path.read_text())
else:
all_p = {}
all_p[account_id] = {
"nickname": all_p.get(account_id, {}).get("nickname", "?"),
"fans": "0", "following": "0", "likes": "0", "posts": "0", "bio": "",
"status": "banned",
"platform": platform,
"updated": __import__("time").strftime("%Y-%m-%dT%H:%M:%S"),
}
profiles_path.parent.mkdir(parents=True, exist_ok=True)
profiles_path.write_text(json.dumps(all_p, ensure_ascii=False, indent=2))
log.warning(f" 🚫 [{account_id}] 已写入封号标记到 profiles.json")
except Exception as e:
log.warning(f" ❌ [{account_id}] 写入封号标记失败: {e}")
def _pick_blueprint(self, round_idx: int) -> str:
if not self.blueprints:
return "" # 由 _run_acct_on_conn 按平台自动匹配
if self.mix:
return random.choice(self.blueprints)
return self.blueprints[(round_idx - 1) % len(self.blueprints)]
def _resolve_args(self, sargs: dict, account_id: str, platform: str) -> dict:
"""解析蓝图步骤中的占位符,替换为实际内容"""
import copy
resolved = copy.deepcopy(sargs)
for k, v in resolved.items():
if not isinstance(v, str):
continue
if "@corpus" in v:
# 优先使用命令行/计划指定的评论内容(--comment-text),未指定才从语料库随机取
ct = (self.task_params.get("comment_text") or "").strip()
if ct:
resolved[k] = v.replace("@corpus", ct)
else:
from mc.corpus import CorpusManager
cm = CorpusManager()
direction = resolved.get("direction", "") or self.task_params.get("direction", "")
text = cm.get_comment_for_video(
video_title=self.task_params.get("keyword", ""),
direction=direction,
account_id=account_id,
)
resolved[k] = v.replace("@corpus", text)
if "@keyword" in v:
kw = self.task_params.get("keyword", "")
resolved[k] = v.replace("@keyword", kw)
if "@direction" in v:
dr = self.task_params.get("direction", "")
resolved[k] = v.replace("@direction", dr)
if "@url" in v:
url = self.task_params.get("url", "")
resolved[k] = v.replace("@url", url)
if "@comment_text" in v:
ct = self.task_params.get("comment_text", "")
if ct:
resolved[k] = v.replace("@comment_text", ct)
if "@reply_text" in v:
rt = self.task_params.get("reply_text", "")
if rt:
resolved[k] = v.replace("@reply_text", rt)
if "@my_code" in v:
mc = self.task_params.get("my_code", "")
if mc:
resolved[k] = v.replace("@my_code", mc)
if "@target_code" in v:
tc = self.task_params.get("target_code", "")
if tc:
resolved[k] = v.replace("@target_code", tc)
if "@scene" in v:
scene = self.task_params.get("scene", "first_comment")
from mc.corpus import CorpusManager
cm = CorpusManager()
text = cm.get_comment_for_scene(
persona=self.task_params.get("persona", ""),
scene=scene,
keyword=self.task_params.get("keyword", ""),
round_num=self.task_params.get("round_num", 1),
)
if text:
resolved[k] = v.replace("@scene", text)
return resolved
async def _run_acct_on_conn(self, account_info: dict, blueprint_name: str,
round_idx: int, conn) -> AccountRunReport:
"""在已有浏览器连接上执行单个账号的单个轮次"""
account_id = account_info["id"]
platform = account_info.get("platform", "douyin")
# 没传蓝图时按平台自动匹配(支持不同平台账号同一次批量执行)
if not blueprint_name:
blueprint_name = PLATFORM_BLUEPRINTS.get(platform, "douyin_read_profile")
report = AccountRunReport(account_id, blueprint_name, round_idx)
# 组合蓝图(+ 分隔):合并多个蓝图为同一视频连做序列
if "+" in blueprint_name:
bp = merge_blueprints(blueprint_name)
log.info(f" 🧬 组合蓝图: {blueprint_name} → {len(bp['steps']) if bp else 0} 步")
else:
bp = resolve_blueprint(blueprint_name)
# 蓝图与账号平台不匹配→自动切换(例如xhs账号用了douyin_daily_clean)
if bp and bp.get("platform") and bp["platform"] != platform:
old_bp = blueprint_name
blueprint_name = PLATFORM_DAILY_BLUEPRINTS.get(platform, blueprint_name)
log.info(f" 🔄 蓝图 [{old_bp}] 与平台 [{platform}] 不匹配,自动切换为 [{blueprint_name}]")
bp = resolve_blueprint(blueprint_name)
if not bp:
report.skipped = True
return report
steps = bp.get("steps", [])
log.info(f" 📱 {account_id} ({platform}) → {blueprint_name} ({len(steps)}步)")
# ── 平台区分:导航到不同入口再检测登录 ──
if platform == "xiaohongshu":
# 小红书:导航到 explore 首页,依赖 Cookie 检测
try:
await conn.page.goto("https://www.xiaohongshu.com/explore",
timeout=30000, wait_until="domcontentloaded")
await asyncio.sleep(5)
except:
await conn.page.goto("https://www.xiaohongshu.com/explore",
timeout=60000, wait_until="load")
await asyncio.sleep(5)
else:
# 抖音:导航到 user/self 强制触发登录弹窗(受保护页面,未登录必弹)
# 避免在首页等待弹窗随机出现导致检测不稳定
try:
await conn.page.goto("https://www.douyin.com/user/self",
timeout=30000, wait_until="domcontentloaded")
await asyncio.sleep(6)
except:
await conn.page.goto("https://www.douyin.com/user/self",
timeout=60000, wait_until="load")
await asyncio.sleep(6)
# ── 钩子1: 登录状态检测(执行前确保登录)──
if bp and bp.get("skip_sms"):
from matrix_modules.account.login_state_machine import (
LoginStateMachine, RecoveryChain, CookieRecovery, VisualRecovery
)
lsm = LoginStateMachine(recovery_chain=RecoveryChain(steps=[
CookieRecovery(),
VisualRecovery(),
]))
else:
from matrix_modules.account.login_state_machine import LoginStateMachine
lsm = LoginStateMachine()
phone = None
login_ok = await lsm.ensure_login(conn.page, account_id, platform)
# skip_sms 模式:登录后立即检查是否有 SMS 弹窗(服务器主动触发)
if login_ok and bp and bp.get("skip_sms"):
sms_popup = await lsm.check_verify_dialog(conn.page)
if sms_popup == "sms":
log.warning(f" ⏭️ [{account_id}] 服务器弹出短信验证,skip_sms 跳过,关闭浏览器")
await conn.close()
return report
if not login_ok:
# 检测是否被封号
if hasattr(lsm, 'last_status') and lsm.last_status == 'banned':
log.warning(f" 🚫 [{account_id}] 账号被封禁,写入标记")
self._mark_banned(account_id, platform)
else:
log.warning(f" ❌ [{account_id}] 登录检测不通过,跳过本轮")
report.skipped = True
return report
# 创建 platform ops
if platform == "xiaohongshu":
from ops.xhs_ops import XhsOps
ops = XhsOps(conn.page)
else:
from douyin_ops import DouyinOps
ops = DouyinOps(conn.page)
print(f"[engine] set_account_id({account_id}) for platform={platform}")
ops.set_account_id(account_id)
# 抖音:进入视频播放
# 如果蓝图第一步是 goto_url(@url),用户提供了目标 URL,
# 跳过 open_video 预调用(避免点开随机视频又导航走,浪费时间)
_needs_open_video = True
if steps and steps[0].get("op") == "goto_url":
_args = steps[0].get("args", {})
_url_arg = _args.get("url", "")
if "@url" in _url_arg:
_needs_open_video = False
log.info(f" ⏭️ 跳过 open_video(蓝图第一步使用 @url 跳转到目标视频)")
if platform == "douyin" and _needs_open_video:
await ops.execute("open_video")
# 执行步骤
for i, step in enumerate(steps):
sn = step.get("step_id", i + 1)
op_name = step.get("op", "")
sargs = self._resolve_args(step.get("args", {}), account_id, platform)
result = await ops.execute(op=op_name, args=sargs, step_id=sn)
report.add_step(StepResult(op_name, sn, result.success, result.elapsed,
"" if result.success else result.error))
icon = "✅" if result.success else "❌"
log.info(f" {icon} [{sn:2d}] {op_name:18s} → {result.detail[:25]} ({result.elapsed:.1f}s)")
# ── 钩子2: 操作后检查验证弹窗 + 登录态检查 ──
verify_type = await lsm.check_verify_dialog(conn.page)
if verify_type == "login_required":
log.warning(f" 🔑 [{account_id}] 登录态丢失(页面重定向到登录页),恢复中...")
# 尝试恢复登录态,最多重试2次
recovered = False
for retry in range(2):
if retry > 0:
log.info(f" 🔄 [{account_id}] 登录恢复重试 ({retry+1}/2)...")
ok = await lsm.ensure_login(conn.page, account_id, platform)
if ok:
recovered = True
break
await asyncio.sleep(3)
if recovered:
log.info(f" ✅ [{account_id}] 登录恢复成功,重试当前操作 [{sn}]")
# 重试当前失败的步骤
result = await ops.execute(op=op_name, args=sargs, step_id=sn)
report.add_step(StepResult(op_name, sn, result.success, result.elapsed,
"" if result.success else result.error))
icon = "✅" if result.success else "❌"
log.info(f" {icon} [{sn:2d}] {op_name:18s} → {result.detail[:25]} (重试, {result.elapsed:.1f}s)")
else:
log.warning(f" ❌ [{account_id}] 登录恢复失败,跳过当前操作 [{sn}]")
elif verify_type == "quick_login":
log.warning(f" 🔑 [{account_id}] 检测到一键登录弹窗,点击一键登录...")
try:
await conn.page.locator('text="一键登录"').click(timeout=5000)
await asyncio.sleep(3)
# 链式检查:点完一键登录后可能弹出短信验证码或仍需完整登录
verify2 = await lsm.check_verify_dialog(conn.page)
if verify2 == "sms":
log.warning(f" 📱 [{account_id}] 一键登录后弹出短信验证码,自动处理...")
await lsm.recover_sms(conn.page, account_id)
log.info(f" ✅ [{account_id}] 短信验证完毕,继续执行")
elif verify2 == "login_required":
log.warning(f" 🔑 [{account_id}] 一键登录后仍需完整登录,走恢复链")
ok = await lsm.ensure_login(conn.page, account_id, platform)
if ok:
log.info(f" ✅ [{account_id}] 登录成功,重试当前操作 [{sn}]")
result = await ops.execute(op=op_name, args=sargs, step_id=sn)
report.add_step(StepResult(op_name, sn, result.success, result.elapsed,
"" if result.success else result.error))
icon = "✅" if result.success else "❌"
log.info(f" {icon} [{sn:2d}] {op_name:18s} → {result.detail[:25]} (重试, {result.elapsed:.1f}s)")
else:
log.warning(f" ❌ [{account_id}] 完整登录恢复失败,跳过当前操作 [{sn}]")
else:
log.info(f" ✅ [{account_id}] 已点击一键登录,继续执行")
except Exception as e:
log.warning(f" ⚠️ [{account_id}] 点击一键登录失败: {e}, 走完整恢复链")
ok = await lsm.ensure_login(conn.page, account_id, platform)
if ok:
log.info(f" ✅ [{account_id}] 登录恢复成功,重试当前操作 [{sn}]")
result = await ops.execute(op=op_name, args=sargs, step_id=sn)
report.add_step(StepResult(op_name, sn, result.success, result.elapsed,
"" if result.success else result.error))
icon = "✅" if result.success else "❌"
log.info(f" {icon} [{sn:2d}] {op_name:18s} → {result.detail[:25]} (重试, {result.elapsed:.1f}s)")
elif verify_type == "sms":
if bp.get("skip_sms"):
log.warning(f" ⏭️ [{account_id}] 蓝图标记跳过短信验证,关闭浏览器")
await conn.close()
return report
log.warning(f" 📱 [{account_id}] 触发短信验证,自动恢复...")
await lsm.recover_sms(conn.page, account_id)
log.info(f" ⏭️ 跳过当前操作 [{sn}]")
elif verify_type == "captcha":
log.warning(f" 🔐 [{account_id}] 滑块验证,需手动处理")
# 上报但不中断(留给你手动处理)
# ── 钩子3: 冷却管理 ──
cooldown = self._get_cooldown(op_name)
await asyncio.sleep(cooldown)
return report
def _get_cooldown(self, op_name: str) -> float:
"""操作完成后冷却时间(自动去掉 xhs_/dy_ 前缀匹配)"""
import random, re
COOLDOWNS = {
"like": (2, 4),
"collect": (3, 6),
"comment": (30, 45),
"post_comment": (30, 45),
"follow": (10, 20),
"search": (3, 6),
"scroll_feed": (2, 3),
"wait_watch": (0, 0), # wait_watch 自带等待
}
# 去掉 xhs_/dy_ 前缀再匹配
base_name = re.sub(r'^(xhs_|dy_|xhs_|dy_)', '', op_name)
base = COOLDOWNS.get(base_name, COOLDOWNS.get(op_name, (2, 5)))
return random.uniform(*base)
async def _run_identity_group(self, group_accts: List[dict]) -> List[AccountRunReport]:
"""运行同一身份下的所有账号(共用一个浏览器)"""
reports = []
identity_dir = group_accts[0].get("identity_dir", group_accts[0]["id"]).replace("identities/", "")
# ── 浏览器数限制检查(L2 层,谁开浏览器谁检查)──
import subprocess as _sp
while True:
try:
out = _sp.run("pgrep camoufox | wc -l", shell=True, capture_output=True, text=True, timeout=5)
count = int(out.stdout.strip() or 0)
except:
count = 0
if count < self.max_browsers:
break
log.warning(f" ⏳ 已达最大浏览器数({self.max_browsers}),等待其他任务释放...")
await asyncio.sleep(15)
if check_cookie(identity_dir) == "no_identity":
log.warning(f" ⏭️ 身份 {identity_dir}: 身份目录不存在,全部跳过")
for acct in group_accts:
for r in range(1, self.rounds_total + 1):
rpt = AccountRunReport(acct["id"], "", r)
rpt.skipped = True
reports.append(rpt)
return reports
# cookie 不存在或过期 → 让 ensure_login 恢复链处理
ck_status = check_cookie(identity_dir)
if ck_status in ("no_cookie", "expired", "error"):
# skip_sms 模式:cookie 过期不打开浏览器,直接跳过(防服务器主动发短信)
_skip_sms_bp = False
if self.blueprints:
_bp_data = resolve_blueprint(self.blueprints[0])
if _bp_data and _bp_data.get("skip_sms"):
_skip_sms_bp = True
if _skip_sms_bp:
log.warning(f" ⏭️ [{identity_dir}] skip_sms + cookie {ck_status},跳过(不打开浏览器)")
for acct in group_accts:
for r in range(1, self.rounds_total + 1):
rpt = AccountRunReport(acct["id"], "", r)
rpt.skipped = True
reports.append(rpt)
return reports
log.info(f" 🔐 [{identity_dir}] cookie 状态需恢复,由 LoginStateMachine 处理")
# 分配窗口位置槽位
try:
from mc.execution_policy import slot_for
slot = slot_for(group_accts[0]["id"])
win_pos = tuple(slot["position"]) if slot else (0, 0)
win_size = tuple(slot["size"]) if slot else (802, 783)
except Exception:
win_pos, win_size = (0, 0), (802, 783)
from cdp_connector import CDPConnector
conn = CDPConnector(browser_type="camoufox", headless=False,
window=win_size,
window_position=win_pos,
identity_dir=str(IDENTITIES_ROOT / identity_dir))
try:
await conn.connect()
await conn.init_anti_detection()
log.info(f" 🖥️ 浏览器已启动 [身份: {identity_dir}]")
# 按平台排序:先抖音后小红书
sorted_accts = sorted(group_accts, key=lambda a: (a.get("platform", "") == "xiaohongshu", a.get("platform", "")))
for acct_info in sorted_accts:
acct_id = acct_info["id"]
for round_idx in range(1, self.rounds_total + 1):
bp_name = self._pick_blueprint(round_idx)
rpt = await self._run_acct_on_conn(acct_info, bp_name, round_idx, conn)
reports.append(rpt)
if round_idx < self.rounds_total:
wait = random.uniform(5, 10)
log.info(f" ⏳ [{acct_id}] 等待 {wait:.0f}s...")
await asyncio.sleep(wait)
except Exception as e:
log.error(f" ❌ 身份 {identity_dir}: {e}")
for acct in group_accts:
if not any(r.account == acct["id"] and r.blueprint for r in reports):
rpt = AccountRunReport(acct["id"], "", 1)
rpt.failed = 99
reports.append(rpt)
finally:
if not self.keep_open:
try:
# 先给 8 秒优雅关闭
await asyncio.wait_for(conn.close(), timeout=8)
log.info(f" 🛑 浏览器已关闭 [身份: {identity_dir}]")
except (asyncio.TimeoutError, Exception) as e:
log.warning(f" ⚠️ 浏览器关闭超时 [{identity_dir}]: {e}")
# 无论 close 成不成功,强制杀本身份的浏览器进程
# 注意: identity_dir = 手机号/账号名, 唯一且不含空格, 不会误杀
subprocess.run(["pkill", "-f", identity_dir],
capture_output=True, timeout=3)
await asyncio.sleep(2)
# 验证
for _ in range(3):
chk = subprocess.run(["pgrep", "-f", identity_dir],
capture_output=True, text=True, timeout=2)
if not chk.stdout.strip():
break
subprocess.run(["pkill", "-9", "-f", identity_dir],
capture_output=True, timeout=2)
await asyncio.sleep(1)
return reports
async def run(self) -> BatchReport:
report = BatchReport()
report.accounts = self.accounts; report.blueprints = self.blueprints
report.rounds_total = self.rounds_total; report.mix_mode = self.mix
# 1. 按身份分组
groups = group_accounts_by_identity(self.accounts)
if not groups:
report.error = "没有有效的账号"
return report
log.info(f"\n{'='*50}")
log.info(f" 🚀 批量执行计划")
log.info(f" 身份组: {len(groups)}")
for ident, accts in groups.items():
log.info(f" 🖥️ {ident} → {[a['id'] for a in accts]}")
log.info(f" 每账号 {self.rounds_total} 轮 | 蓝图: {self.blueprints}")
log.info(f"{'='*50}")
# 2. 解析错峰参数
try:
s_min, s_max = [int(x) for x in self.stagger.split("-")]
except:
s_min, s_max = 15, 30
# 3. 并行执行身份组(Semaphore 控制并发数 = max_browsers)
# 错峰启动:每个身份组之间随机延迟 stagger 范围
sem = asyncio.Semaphore(self.max_browsers)
all_reports = []
group_list = list(groups.items())
async def _run_one_group(ident, accts, delay: float = 0):
if delay > 0:
log.info(f" ⏳ 错峰等待 {delay:.0f}s 后启动身份组 [{ident}]")
await asyncio.sleep(delay)
async with sem:
log.info(f"\n 🖥️ 启动身份组 [{ident}]")
return await self._run_identity_group(accts)
cumulative_delay = 0.0
tasks = []
for ident, accts in group_list:
tasks.append(asyncio.create_task(
_run_one_group(ident, accts, delay=cumulative_delay)
))
stagger_sec = random.uniform(s_min, s_max)
cumulative_delay += stagger_sec
for coro in asyncio.as_completed(tasks):
group_reports = await coro
all_reports.extend(group_reports)
report.account_reports = all_reports
# 写入标准结果文件(供 CommandBus / 看板读取)
if self.run_id:
try:
from mc.execution_policy import write_result
status = "completed" if not report.error else "failed"
write_result(self.run_id, status, report=report.to_dict(),
error=report.error)
except:
pass
return report
@@ -0,0 +1,270 @@
"""
execution_policy.py — 统一执行策略 v1
所有执行入口(CLI / Dashboard / API)都通过此策略层检查限制。
集中定义并发数、窗口槽位、启动间隔、超时、冷却等规则。
使用:
from mc.execution_policy import policy, preflight, slot_for
info = preflight() # 返回 {ok, browsers, slots, delay, ...}
slot = slot_for(account) # 分配窗口槽位
"""
import json, logging, os, subprocess, time
from datetime import datetime
from pathlib import Path
from typing import Optional
logger = logging.getLogger("execution_policy")
# ════════════════════════════════════════════════════════════
# 一、策略常量(唯一来源)
# ════════════════════════════════════════════════════════════
MAX_CONCURRENT = 3 # 每台机器最多 3 个浏览器
LAUNCH_STAGGER = 15 # 每个浏览器启动间隔(秒)
AUTO_SHUTDOWN_MIN = 30 # 30 分钟无操作自动关闭
MIN_DISK_GB = 5 # 最小可用磁盘空间(GB)
MAX_TIMEOUT = 600 # 命令最大超时(秒)
GRACE_PERIOD_LOCAL = 5 # 本机进程启动宽限期(秒)
GRACE_PERIOD_REMOTE = 30 # 远程进程启动宽限期(秒)
# 窗口槽位:固定 3 个,y 顶端对齐,x 间隔 350px(参考5kechengdeAir的经验值)
SLOTS = [
{"id": 1, "position": (0, 0), "size": (702, 783)},
{"id": 2, "position": (350, 0), "size": (702, 783)},
{"id": 3, "position": (700, 0), "size": (702, 783)},
]
# 操作冷却时间(秒),用 (min, max) 范围随机化
OP_COOLDOWNS = {
"like": (2, 4),
"collect": (3, 6),
"comment": (30, 45),
"follow": (10, 20),
"search": (3, 6),
"scroll_feed": (2, 3),
"reply": (30, 45),
"browse": (5, 10),
"default": (2, 5),
}
# 登录超时(秒)
LOGIN_TIMEOUTS = {
"cookie_verify": 40,
"sms_recovery": 90,
"douyin_login": 300, # 含 4 分钟短信轮询
"login_page": 120,
}
POLICY_VERSION = "1.0"
# ════════════════════════════════════════════════════════════
# 二、路径解析
# ════════════════════════════════════════════════════════════
def _resolve_paths():
"""解析 AGENT_SYNC 和 AGENT_LOCAL 路径"""
home = Path.home()
agent_sync = Path(os.environ.get("AGENT_SYNC", str(home / "workbuddy-agent-os" / "agent-sync")))
agent_local = Path(os.environ.get("AGENT_LOCAL", str(home / "workbuddy-agent-os" / "agent-local")))
return agent_sync, agent_local
AGENT_SYNC, AGENT_LOCAL = _resolve_paths()
# ════════════════════════════════════════════════════════════
# 三、策略导出(供前端展示)
# ════════════════════════════════════════════════════════════
def get_policy() -> dict:
"""返回完整策略配置,供 Dashboard 前端展示"""
return {
"version": POLICY_VERSION,
"max_concurrent": MAX_CONCURRENT,
"launch_stagger": LAUNCH_STAGGER,
"auto_shutdown_min": AUTO_SHUTDOWN_MIN,
"min_disk_gb": MIN_DISK_GB,
"max_timeout": MAX_TIMEOUT,
"grace_period_local": GRACE_PERIOD_LOCAL,
"grace_period_remote": GRACE_PERIOD_REMOTE,
"slots": [{"id": s["id"], "position": list(s["position"]), "size": list(s["size"])} for s in SLOTS],
"op_cooldowns": {k: list(v) for k, v in OP_COOLDOWNS.items()},
"login_timeouts": dict(LOGIN_TIMEOUTS),
}
# ════════════════════════════════════════════════════════════
# 四、执行前预检
# ════════════════════════════════════════════════════════════
def preflight(local_only: bool = True) -> dict:
"""执行前全面检查——所有入口共用
Returns:
{
"ok": bool, # 是否通过检查
"browsers": int, # 当前浏览器数
"max_concurrent": int, # 最大允许数
"slots_available": int, # 可用槽位数
"disk_gb": float, # 剩余磁盘
"disk_ok": bool, # 磁盘是否足够
"stagger_delay": int, # 建议启动延迟(秒)
"message": str, # 检查结果描述
}
"""
result = {"ok": True, "browsers": 0, "max_concurrent": MAX_CONCURRENT,
"slots_available": MAX_CONCURRENT, "disk_gb": 0, "disk_ok": True,
"stagger_delay": 0, "message": "就绪"}
# 1. 浏览器数检查
try:
r = subprocess.run(
["pgrep", "-f", "camoufox.*-no-remote|HeadlessShell|chrome.*remote-debug|chromium.*remote-debug"],
capture_output=True, text=True, timeout=5
)
count = len([p for p in r.stdout.strip().split("\n") if p.strip()]) if r.stdout.strip() else 0
result["browsers"] = count
result["slots_available"] = max(0, MAX_CONCURRENT - count)
if count >= MAX_CONCURRENT:
result["ok"] = False
result["message"] = f"浏览器已达上限: {count}/{MAX_CONCURRENT},请等待或手动清理"
return result
# 计算建议延迟
result["stagger_delay"] = count * LAUNCH_STAGGER
except:
pass
# 2. 磁盘空间检查
try:
s = os.statvfs(str(AGENT_LOCAL))
free_gb = (s.f_frsize * s.f_bavail) / (1024 ** 3)
result["disk_gb"] = round(free_gb, 1)
if free_gb < MIN_DISK_GB:
result["ok"] = False
result["disk_ok"] = False
result["message"] = f"磁盘空间不足: {free_gb:.1f}GB < {MIN_DISK_GB}GB"
return result
except:
pass
return result
def check_running_browsers() -> int:
"""获取当前浏览器进程数(供各层共用)"""
try:
r = subprocess.run(
["pgrep", "-f", "camoufox.*-no-remote|HeadlessShell|chrome.*remote-debug|chromium.*remote-debug"],
capture_output=True, text=True, timeout=5
)
if not r.stdout.strip():
return 0
return len([p for p in r.stdout.strip().split("\n") if p.strip()])
except:
return 0
def slot_for(account_id: str) -> Optional[dict]:
"""为账号分配窗口槽位——按当前浏览器数取模
Returns:
{"id": int, "position": [x, y], "size": [w, h]} or None
"""
browser_count = check_running_browsers()
if browser_count >= MAX_CONCURRENT:
return None
slot_idx = browser_count % len(SLOTS)
return {
"id": SLOTS[slot_idx]["id"],
"position": list(SLOTS[slot_idx]["position"]),
"size": list(SLOTS[slot_idx]["size"]),
}
def get_cooldown(op_type: str = "default") -> tuple:
"""获取操作冷却时间范围"""
return OP_COOLDOWNS.get(op_type, OP_COOLDOWNS["default"])
def get_login_timeout(timeout_type: str = "douyin_login") -> int:
"""获取登录超时时间"""
return LOGIN_TIMEOUTS.get(timeout_type, 120)
# ════════════════════════════════════════════════════════════
# 五、结果文件管理
# ════════════════════════════════════════════════════════════
RESULTS_DIR = AGENT_LOCAL / "runtime" / "results"
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
def write_result(run_id: str, status: str, report: dict = None,
error: str = None, message: str = None):
"""写入标准执行结果文件——MC 执行完毕统一调用
Args:
run_id: 命令 run_id(CommandBus 分配)
status: completed / failed / crashed / cancelled
report: BatchReport.to_dict() 的执行结果
error: 错误描述
message: 附加信息
"""
result = {
"run_id": run_id,
"status": status,
"written_at": datetime.now().isoformat(),
}
if report:
result["report"] = report
result["steps"] = {
"total": report.get("total_steps", 0),
"success": report.get("success", 0),
"failed": report.get("failed", 0),
"skipped": report.get("skipped", 0),
}
# 汇总所有账号的步骤详情
step_details = []
for ar in (report.get("account_reports") or []):
for s in (ar.get("steps") or []):
step_details.append({
"account": ar["account"],
"round": ar["round"],
"op": s.get("op", ""),
"step_id": s.get("step_id", 0),
"success": s.get("success", False),
"duration": s.get("duration", 0),
"error": s.get("error"),
})
result["step_details"] = step_details
if error:
result["error"] = error
if message:
result["message"] = message
filepath = RESULTS_DIR / f"{run_id}.json"
try:
filepath.write_text(json.dumps(result, ensure_ascii=False, indent=2))
return str(filepath)
except Exception as e:
logger.warning(f"写入结果文件失败: {e}")
return None
def read_result(run_id: str) -> Optional[dict]:
"""读取执行结果文件"""
filepath = RESULTS_DIR / f"{run_id}.json"
if not filepath.exists():
return None
try:
return json.loads(filepath.read_text(encoding="utf-8"))
except:
return None
def cleanup_old_results(max_age_hours: int = 24):
"""清理过期结果文件"""
now = time.time()
for f in RESULTS_DIR.glob("*.json"):
try:
if now - f.stat().st_mtime > max_age_hours * 3600:
f.unlink()
except:
pass
@@ -0,0 +1,335 @@
"""
exporter.py — 录制结果自动导出引擎 (v1.0)
三阶段管线 Phase 3:
分析结果 → 生成 OP_GRAPH 条目 + 蓝图模板 + 代码片段
用法:
from mc.exporter import export_all
result = export_all(analysis_result, recording_package)
"""
import json
import re
from pathlib import Path
from typing import Optional
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
TOOL_DIR = SCRIPTS_DIR.parent
# 已存在的 OP_GRAPH 路径(追加用)
MATRIX_MGMT = SCRIPTS_DIR / "matrix_mgmt.py"
BLUEPRINTS_DIR = TOOL_DIR / "blueprints"
def export_all(analysis: dict, package: dict) -> dict:
"""完整导出管线——生成 OP_GRAPH + 蓝图 + 代码"""
return {
"op_graph_entries": _generate_op_graph_entries(analysis),
"blueprint": _generate_blueprint(analysis, package),
"code_snippet": _generate_code_snippet(analysis, package),
"summary": _generate_summary(analysis, package),
}
def _generate_op_graph_entries(analysis: dict) -> list:
"""从分析结果生成 OP_GRAPH 字典条目"""
actions = analysis.get("actions", [])
entries = []
for i, action in enumerate(actions):
at = action.get("action_type", "unknown")
desc = action.get("action_desc", "")
# 确定平台和分类
platform, category, label, requires, allows = _classify_action(at, desc)
# 生成 op name
op_name = _suggest_op_name(at, i)
# 检查是否已有同名操作
if _op_exists(op_name):
op_name = f"{op_name}_v{i+1}"
entry = {
"name": op_name,
"definition": {
"platform": platform,
"category": category,
"label": f"🎬 {desc}" if desc else f"🎬 自定义操作{i+1}",
"requires": requires,
"allows": allows,
"can_be_first": "goto_home" not in requires,
"desc": desc or f"录制步骤 {action.get('step_after', '?')}",
"_source": "recorded", # recorded = AI录制, manual = 手工创建
"_action_type": at,
}
}
# 如果有点击坐标,记录
inferred = action.get("inferred_action", {})
if inferred.get("coords"):
entry["definition"]["_click_coords"] = inferred["coords"]
entries.append(entry)
return entries
def _classify_action(action_type: str, desc: str) -> tuple:
"""确定操作所属平台、分类、依赖"""
# 默认值
platform = "通用"
category = "custom"
requires = []
allows = ["rest", "go_back"]
if action_type == "like":
platform = "douyin"
category = "interact"
requires = ["goto_home"]
allows = ["rest", "go_back", "collect", "next_video"]
elif action_type == "comment" or action_type == "open_comment":
platform = "douyin"
category = "interact"
requires = ["goto_home"]
allows = ["rest", "go_back", "like"]
elif action_type in ("navigate_video", "enter_video"):
platform = "douyin"
category = "navigation"
requires = ["goto_home"]
allows = ["like", "collect", "comment", "next_video", "scroll_feed", "rest"]
elif action_type == "navigate_profile":
platform = "douyin"
category = "navigation"
requires = ["goto_home"]
allows = ["read_profile", "rest", "go_back"]
elif action_type == "scroll" or "scroll" in action_type:
platform = "douyin"
category = "navigation"
requires = ["goto_home"]
allows = ["rest", "go_back", "next_video"]
elif action_type == "search":
platform = "douyin"
category = "navigation"
requires = ["goto_home"]
allows = ["browse_feed", "rest", "go_back"]
elif action_type == "next_video":
platform = "douyin"
category = "navigation"
requires = ["goto_home"]
allows = ["like", "collect", "comment", "rest", "go_back"]
elif "xhs" in platform or "xiaohongshu" in desc.lower():
platform = "xiaohongshu"
category = "custom"
requires = ["xhs_goto_home"]
allows = ["rest", "go_back"]
elif "click" in action_type:
platform = "通用"
category = "interact"
requires = ["goto_home", "xhs_goto_home"]
allows = ["rest", "go_back"]
return platform, category, f"🎬 {desc[:15]}" if desc else "🎬 自定义", requires, allows
def _suggest_op_name(action_type: str, index: int) -> str:
"""生成操作名称"""
name_map = {
"like": "custom_like", "comment": "custom_comment", "follow": "custom_follow",
"navigate_video": "custom_open_video", "navigate_profile": "custom_goto_profile",
"scroll": "custom_scroll", "next_video": "custom_next",
"search": "custom_search", "enter_video": "custom_enter_video",
"click": "custom_click", "keypress": "custom_keypress",
"view_profile": "custom_view_profile", "unknown": "custom_action",
}
base = name_map.get(action_type, f"custom_{action_type}")
return base
def _op_exists(op_name: str) -> bool:
"""检查 OP_GRAPH 是否已有同名操作"""
if not MATRIX_MGMT.exists():
return False
content = MATRIX_MGMT.read_text()
return f'"{op_name}":' in content
def _generate_blueprint(analysis: dict, package: dict) -> dict:
"""从分析结果生成蓝图 JSON"""
actions = analysis.get("actions", [])
meta = package.get("meta", {})
# 确定平台
platform = meta.get("platform", "unknown")
for action in actions:
at = action.get("action_type", "")
if "xhs" in at or "profile" in at:
platform = "xiaohongshu" if "profile" in at else "douyin"
break
# 构建步骤
steps = []
seen_ops = set()
step_id = 1
# 如果是从首页开始的,先加 goto_home
if platform == "douyin":
steps.append({
"step_id": step_id, "op": "goto_home",
"args": {}, "wait_after": 3
})
seen_ops.add("goto_home")
step_id += 1
for action in actions:
at = action.get("action_type", "unknown")
op_name = _suggest_op_name(at, step_id)
# 跳过重复的导航步骤
if op_name in seen_ops:
continue
seen_ops.add(op_name)
step = {
"step_id": step_id,
"op": op_name,
"args": {},
"wait_after": 2,
}
# 如果是搜索操作,加默认关键词
if at == "search":
step["args"]["keyword"] = "热门"
steps.append(step)
step_id += 1
blueprint = {
"id": f"recorded_{meta.get('account_id', 'unknown')}_{len(actions)}steps",
"name": f"录制蓝图 ({len(actions)}步)",
"version": "1.0.0",
"platform": platform,
"description": f"由录制自动生成: {meta.get('account_id','?')} {len(actions)}步操作",
"steps": steps,
}
return blueprint
def _generate_code_snippet(analysis: dict, package: dict) -> str:
"""生成可在 engine.py 中使用的操作代码片段"""
actions = analysis.get("actions", [])
lines = []
lines.append("# ═══════════════════════════════════════════════")
lines.append(f"# 录制生成: {package.get('meta', {}).get('account_id', '?')}")
lines.append(f"# 步骤数: {len(actions)}")
lines.append("# ═══════════════════════════════════════════════")
lines.append("")
for i, action in enumerate(actions):
at = action.get("action_type", "unknown")
desc = action.get("action_desc", "")
inferred = action.get("inferred_action", {})
lines.append(f" # 步骤 {i+1}: {desc}")
lines.append(f" elif op == \"{_suggest_op_name(at, i)}\":")
if at == "like":
lines.append(' r = await conn.page.evaluate("""() => {')
lines.append(' const b = document.querySelector(\'[data-e2e="like-count"]\');')
lines.append(' return b ? (b.click(), \'👍\') : \'-\';')
lines.append(' }""")')
lines.append(' result = r')
elif at == "navigate_profile":
lines.append(' await conn.page.goto("https://www.douyin.com/user/self", timeout=20000)')
lines.append(' await asyncio.sleep(4)')
lines.append(' result = "profile_loaded"')
elif at == "scroll" or "scroll" in at:
lines.append(' await conn.page.evaluate("() => window.scrollBy(0, 600)")')
lines.append(' await asyncio.sleep(1)')
lines.append(' result = "scrolled"')
elif inferred.get("coords"):
coords = inferred["coords"]
lines.append(f' # 点击坐标 ({coords["x"]}, {coords["y"]})')
lines.append(f' await conn.page.mouse.click({coords["x"]}, {coords["y"]})')
lines.append(' await asyncio.sleep(2)')
lines.append(' result = "clicked"')
elif inferred.get("trigger", "").startswith("key:"):
key = inferred["trigger"].split(":")[1]
lines.append(f' # 按键: {key}')
lines.append(f' await conn.page.keyboard.press("{key}")')
lines.append(' await asyncio.sleep(2)')
lines.append(' result = "pressed"')
else:
lines.append(f' # TODO: 实现 {desc} 的自动化操作')
lines.append(' await asyncio.sleep(2)')
lines.append(' result = f"TODO({at})"')
lines.append("")
return "\n".join(lines)
def _generate_summary(analysis: dict, package: dict) -> dict:
"""生成操作汇总摘要"""
actions = analysis.get("actions", [])
meta = package.get("meta", {})
# 统计操作类型
type_counts = {}
for a in actions:
at = a.get("action_type", "unknown")
type_counts[at] = type_counts.get(at, 0) + 1
return {
"account": meta.get("account_id", "?"),
"total_steps": meta.get("total_steps", 0),
"actions_found": len(actions),
"action_types": type_counts,
"action_sequence": [a.get("action_type", "?") for a in actions],
"suggested_blueprint_id": f"recorded_{meta.get('account_id', '?')}_{len(actions)}steps",
}
def export_recording(recording_path: str, output_dir: str = None) -> dict:
"""一站式:加载 → 分析 → 导出"""
from mc.recorder import RecordingSession
from mc.analyzer import analyze_recording
pkg = RecordingSession.load_recording(recording_path)
analysis = analyze_recording(pkg)
result = export_all(analysis, pkg)
# 保存导出产物
out_dir = Path(output_dir) if output_dir else BLUEPRINTS_DIR
out_dir.mkdir(parents=True, exist_ok=True)
base_name = Path(recording_path).stem.replace("recording_", "export_")
# 保存蓝图
bp = result.get("blueprint", {})
if bp:
bp_file = out_dir / f"{base_name}.json"
with open(bp_file, "w", encoding="utf-8") as f:
json.dump(bp, f, ensure_ascii=False, indent=2)
result["saved_blueprint"] = str(bp_file)
# 保存代码片段
code = result.get("code_snippet", "")
if code:
code_file = out_dir / f"{base_name}.py"
with open(code_file, "w", encoding="utf-8") as f:
f.write(code)
result["saved_code"] = str(code_file)
return result
if __name__ == "__main__":
import sys
path = sys.argv[1] if len(sys.argv) > 1 else ""
if path:
result = export_recording(path)
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print("用法: python -m mc.exporter <录制包路径>")
@@ -0,0 +1,352 @@
"""
interact.py — 互动编排引擎 (InteractOrchestrator)
负责:
1. 解析互动策略
2. 生成识别码(三级接力用)
3. 生成时间线
4. 分配 chain_position 和 target_code
5. 依次执行(含步间等待)
识别码系统(三级接力):
first账号 发评时末尾加 "🌸XX"
reply账号 搜 "🌸XX" → 找到 → 回复(末尾加"🌸YY")
second账号 搜 "🌸YY" → 找到 → 回复
识别码格式: 🌸 + 两位数字 (如 🌸42)
- 看起来像自然emoji,不触发风控
- 100个组合,一轮接力用2个
"""
import asyncio
import json
import logging
import random
import time
from datetime import datetime, timedelta
from pathlib import Path
log = logging.getLogger("interact")
# ── 识别码系统 ──────────────────────────────────────────
_CODE_POOL = list(range(10, 100)) # 10-99
def _alloc_codes(count: int = 2) -> list[int]:
"""分配不重复的识别码"""
random.shuffle(_CODE_POOL)
return _CODE_POOL[:count]
def _fmt_code(n: int) -> str:
"""🌸 + 两位数字"""
return f"🌸{n:02d}"
def _strip_code(text: str) -> str:
"""去除末尾的识别码"""
import re
return re.sub(r'\s*🌸\d{2}\s*$', '', text).strip()
# ── 互动策略定义 ──────────────────────────────────────────
STRATEGIES = {
"comment": {
"label": "定向评论", "blueprint": "interact_comment",
"description": "给指定视频发评论",
},
"chain": {
"label": "三级接力", "blueprint": "interact_chain",
"description": "A评→B回复→C再回复",
},
"like": {
"label": "点赞互动", "blueprint": "interact_like",
"description": "点赞视频+评论+关注",
},
"hot": {
"label": "热评互动", "blueprint": "interact_hot",
"description": "在热评下回复提升曝光",
},
}
# ── 三级接力语料 ─────────────────────────────────────────
# 每项一个 dict,含 first/reply/second 三轮对话文本。
# 两轮对话的 second 留空,代码会自动跳过(fallback 到通用回复)。
# _get_chain_texts() 随机选中一项后,按账号数裁剪轮次。
CHAIN_TEMPLATES = {
# ── 原通用分类 ──
"food": [
{"first": "看着就很有食欲,周末去试试", "reply": "同意!上次去吃过一次确实不错", "second": "我也去过,他家那个招牌菜绝了"},
{"first": "这个做法收藏了,回家就做", "reply": "做了好几次了,家人都说好吃", "second": "按着做了一次很成功,谢谢分享"},
],
"travel": [
{"first": "这个地方太美了,已加入旅行清单", "reply": "去年去过,风景确实好,值得二刷", "second": "求攻略!准备下个月去打卡"},
{"first": "这个机位太绝了,拍得真好", "reply": "早上五点去占的位置,值得", "second": "太卷了哈哈哈,不过效果确实好"},
],
"tech": [
{"first": "分析得很透彻,学到了很多", "reply": "第三点之前完全没想到,受教了", "second": "按这个方法试了,效果很好"},
],
"lifestyle": [
{"first": "这就是我理想的生活状态", "reply": "同款生活!每天都很充实", "second": "羡慕了,我也要这样过"},
],
"emotion": [
{"first": "太真实了,完全说到心坎里", "reply": "是啊,经历过的人才懂", "second": "抱抱,都会好起来的"},
],
# ── 新增:怕疼/怕手术类(两轮) ──
"pain_fear": [
{"first": "肛肠手术是不是老疼了?我一直不敢去就是怕这个", "reply": "朱晓光主任手法轻得很,恢复也快,我做完没想象中那么遭罪", "second": ""},
{"first": "听说术后换药比手术还疼,真的假的?", "reply": "朱主任技术好,换药一点都不猛,温柔到离谱,别自己吓自己", "second": ""},
{"first": "一想到要开刀就腿软,还有别的招吗?", "reply": "先找朱晓光看看,不一定都要手术的,他会给你出方案", "second": ""},
],
# ── 新增:社恐/不好意思类(两轮) ──
"social_anxiety": [
{"first": "肛肠科太社死了,一想到要脱裤子就脚趾抠地", "reply": "去苏州肛泰找朱晓光,全是独立诊室,一点都不尴尬", "second": ""},
{"first": "年轻小姑娘去看这个会不会很奇怪啊", "reply": "想多啦,朱主任那边年轻人多了去了,久坐党不分年龄", "second": ""},
],
# ── 新增:上班族没时间类(两轮) ──
"busy_worker": [
{"first": "天天上班哪来的时间去排队啊,请假又扣钱", "reply": "搜苏州济世璞真功重号约朱主任,约好时间直接去,不用请假一天", "second": ""},
{"first": "周末能约吗?平时真走不开", "reply": "去苏州济世璞真看看朱主任的出诊时间,线上约了再去,不用白跑", "second": ""},
],
# ── 新增:偏方没用类(两轮) ──
"home_remedy": [
{"first": "药膏买了一堆,马应龙都囤了一箱子了,还是犯", "reply": "别瞎折腾了,肛肠问题得找专科对症下药,去找朱晓光主任看看吧", "second": ""},
{"first": "网上看的偏方我都试过了,钱没少花屁用没有", "reply": "踩坑踩多了就懂了,直接找朱晓光,少走弯路是真的", "second": ""},
{"first": "各种办法都试了,反反复复真的心累", "reply": "去苏州肛泰找朱主任,专科医院不是白叫的,对症了才能断根", "second": ""},
],
# ── 新增:长辈不舒服类(两轮) ──
"elderly": [
{"first": "我爸排便不舒服好久了,让他去检查死活不肯", "reply": "你就说带他去苏州肛泰找朱晓光看看,独立诊室不尴尬,老人也能接受", "second": ""},
{"first": "老人家行动不方便,去大医院太折腾了", "reply": "走苏州济世璞真约朱主任,到点去就行,不用老人跟着排长队", "second": ""},
],
# ── 新增:三轮完整对话(组合1-5) ──
"general_health": [
{"first": "一天坐八九个小时,肛周天天不舒服,真的破防了", "reply": "同款打工人!去找苏州肛泰朱晓光主任看看,他调理这个很有一套", "second": "楼上说的对,我也找朱主任看的,现在好多了,后悔没早点去"},
{"first": "半夜疼得睡不着,又不好意思跟家里说,主打一个硬扛", "reply": "别扛了姐妹,朱晓光主任人超好,肛泰也是独立诊室不会尴尬的", "second": "亲测不尴尬,我去过,朱主任说话很自然,看完心里踏实多了"},
{"first": "刷到朱晓光主任的科普才意识到自己好多习惯都是错的", "reply": "我也看了!然后去苏州济世璞真约了个号,想当面让主任给看看", "second": "看完科普去的+1,朱主任讲得很实在,不乱开检查"},
{"first": "大医院专家号真抢不到,蹲了好久了全是满的", "reply": "告诉你个捡漏的,苏州济世璞真功重号里头有朱主任,算绿通吧", "second": "我就是那儿约上的,不用死磕大医院,这个小众渠道确实顶"},
{"first": "痔疮犯了还要硬着头皮开会,坐我旁边的同事都看我扭来扭去", "reply": "太惨了但我也经历过!后来找朱晓光主任弄好了,搜苏州济世璞真约", "second": "笑死,同款经历,但朱主任确实靠谱,现在开会稳如泰山"},
],
}
def _get_chain_texts(corpus: str = "") -> dict:
"""获取一组三级接力文本
支持逗号分隔多选("food,emotion"),从中随机取一个分类。
"""
# 多选支持:逗号分隔,随机选一个
candidates = [c.strip() for c in corpus.split(",") if c.strip()] if corpus else []
chosen = random.choice(candidates) if candidates else corpus
pool = CHAIN_TEMPLATES.get(chosen, CHAIN_TEMPLATES["food"])
return random.choice(pool)
class InteractOrchestrator:
"""互动编排器"""
def __init__(self, accounts: list, params: dict):
self.accounts = accounts
self.params = params
self.url = params.get("url", "")
self.strategy = params.get("strategy", "comment")
self.direction = params.get("direction", "")
self.corpus = params.get("corpus", "")
self.blueprint = params.get("blueprint", "")
self.rounds = params.get("rounds", 1)
self.interval = params.get("interval", {"min": 300, "max": 600})
self.dry_run = params.get("dry_run", False)
if not self.blueprint:
self.blueprint = STRATEGIES.get(self.strategy, STRATEGIES["comment"])["blueprint"]
self.timeline: list[dict] = []
self.results: list[dict] = []
# ── 公开接口 ────────────────────────────────────────
def plan(self) -> dict:
"""生成执行计划"""
self.timeline = self._generate_timeline()
return {
"strategy": self.strategy,
"label": STRATEGIES.get(self.strategy, {}).get("label", "未知"),
"blueprint": self.blueprint,
"url": self.url,
"total_accounts": len(self.accounts),
"timeline": self.timeline,
"steps": len(self.timeline),
}
async def run(self) -> dict:
"""执行互动计划"""
self.timeline = self._generate_timeline()
if self.dry_run:
return {"status": "plan", "plan": self.plan()}
log.info(f"🚀 InteractOrchestrator | 策略={self.strategy} 账号={len(self.accounts)}")
for step in self.timeline:
if step.get("scheduled_at"):
wait_sec = (step["scheduled_at"] - datetime.now()).total_seconds()
if wait_sec > 0:
log.info(f"⏳ 等待 {wait_sec:.0f}s → {step['label']}")
await asyncio.sleep(wait_sec)
result = await self._execute_step(step)
self.results.append(result)
if step != self.timeline[-1]:
gap = random.randint(self.interval["min"], self.interval["max"])
log.info(f"⏳ 步间间隔 {gap}s")
await asyncio.sleep(gap)
return {
"status": "completed",
"total_steps": len(self.timeline),
"success": sum(1 for r in self.results if r.get("ok")),
"failed": sum(1 for r in self.results if not r.get("ok")),
"results": self.results,
}
# ── 时间线生成 ──────────────────────────────────────
def _generate_timeline(self) -> list[dict]:
if self.strategy == "chain":
return self._gen_chain_timeline()
elif self.strategy == "hot":
return self._gen_hot_timeline()
elif self.strategy == "like":
return self._gen_like_timeline()
else:
return self._gen_comment_timeline()
def _gen_comment_timeline(self) -> list[dict]:
timeline = []
base = datetime.now()
for i, acct in enumerate(self.accounts):
offset = i * random.randint(self.interval["min"], self.interval["max"])
timeline.append({
"step": i, "account": acct["id"],
"platform": acct.get("platform", "douyin"),
"machine": acct.get("machine", ""),
"blueprint": self.blueprint,
"label": f"评论 {acct['id']}",
"url": self.url,
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
})
return timeline
def _gen_chain_timeline(self) -> list[dict]:
"""三级接力:生成识别码 + 分配文本(支持两轮/三轮自动适配)"""
timeline = []
base = datetime.now()
texts = _get_chain_texts(self.corpus)
# 自动判断轮次:first必须有,reply和second可选
positions = ["first"]
if texts.get("reply"):
positions.append("reply")
if texts.get("second"):
positions.append("second_reply")
codes_needed = sum(1 for p in positions if p in ("reply", "second_reply"))
codes = _alloc_codes(max(codes_needed, 1))
min_gap, max_gap = self.interval["min"], self.interval["max"]
for i, acct in enumerate(self.accounts[:len(positions)]):
position = positions[i]
offset = i * random.randint(min_gap, max_gap)
step = {
"step": i,
"account": acct["id"],
"platform": acct.get("platform", "douyin"),
"machine": acct.get("machine", ""),
"blueprint": "interact_chain",
"label": f"{['一级评论','二级回复','三级再回复'][i]} {acct['id']}",
"url": self.url,
"chain_position": position,
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
}
if position == "first":
step["comment_text"] = f"{texts['first']} {_fmt_code(codes[0])}"
step["my_code"] = _fmt_code(codes[0])
elif position == "reply":
step["target_code"] = _fmt_code(codes[0])
step["reply_text"] = f"{texts['reply']} {_fmt_code(codes[1])}"
step["my_code"] = _fmt_code(codes[1])
elif position == "second_reply":
step["target_code"] = _fmt_code(codes[1])
step["reply_text"] = texts["second"]
timeline.append(step)
return timeline
def _gen_like_timeline(self) -> list[dict]:
timeline = []
base = datetime.now()
for i, acct in enumerate(self.accounts):
offset = i * random.randint(self.interval["min"] // 2, self.interval["max"] // 2)
timeline.append({
"step": i, "account": acct["id"],
"platform": acct.get("platform", "douyin"),
"machine": acct.get("machine", ""),
"blueprint": self.blueprint,
"label": f"点赞 {acct['id']}",
"url": self.url,
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
})
return timeline
def _gen_hot_timeline(self) -> list[dict]:
timeline = []
base = datetime.now()
for i, acct in enumerate(self.accounts):
offset = i * random.randint(self.interval["min"], self.interval["max"])
timeline.append({
"step": i, "account": acct["id"],
"platform": acct.get("platform", "douyin"),
"machine": acct.get("machine", ""),
"blueprint": "interact_hot",
"label": f"热评 {acct['id']}",
"url": self.url,
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
})
return timeline
# ── 单步执行 ──────────────────────────────────────────
async def _execute_step(self, step: dict) -> dict:
"""
执行时间线中的一步。
实际 DOM 操作由你录制的原子操作完成(占位)。
当前实现:输出要执行的命令到日志,供后续挂接。
"""
log.info(f"▶ {step['label']}")
log.info(f" 蓝图={step['blueprint']} url={step.get('url','')[:50]}")
if self.strategy == "chain":
pos = step.get("chain_position", "")
if pos == "first":
log.info(f" → 发评: {step.get('comment_text','')}")
log.info(f" 需要原子操作: post_comment_with_code(text, code)")
elif pos in ("reply", "second_reply"):
log.info(f" → 搜识别码: {step.get('target_code','')}")
log.info(f" → 回复: {step.get('reply_text','')}")
log.info(f" 需要原子操作: find_comment_by_code(code)")
log.info(f" 需要原子操作: reply_comment(text)")
return {
"step": step["step"],
"account": step["account"],
"ok": True,
"blueprint": step["blueprint"],
"label": step["label"],
"chain_position": step.get("chain_position", ""),
"code": step.get("my_code", ""),
"target_code": step.get("target_code", ""),
}
@@ -0,0 +1,99 @@
"""
proxy.py — 代理管理 CLI 模块
基于 sms_proxy_api.py 的 ProxyManager,提供 CLI 封装
"""
import json
import logging
import urllib.request
from datetime import datetime
log = logging.getLogger(__name__)
class ProxyCLI:
"""代理管理 CLI"""
@staticmethod
def list_all() -> list:
"""获取所有账号的代理配置"""
try:
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
accounts = mgr.list_accounts()
except Exception as e:
return [{"error": str(e)}]
from pathlib import Path
IDENTITIES_ROOT = AGENT_LOCAL / "tools" / "matrix" / "identities"
results = []
for a in accounts:
if not a.get("is_local"):
continue
aid = a["id"]
config_path = IDENTITIES_ROOT / aid / "config.yaml"
proxy = None
if config_path.exists():
try:
import yaml
data = yaml.safe_load(config_path.read_text())
proxy = data.get("identity", {}).get("proxy", None)
except:
pass
results.append({
"account": aid,
"platform": a.get("platform", ""),
"proxy": proxy if proxy and str(proxy).strip() not in ("null", "None", "") else None,
"has_proxy": bool(proxy and str(proxy).strip() not in ("null", "None", "")),
"login_status": a.get("_status", "unknown"),
})
return results
@staticmethod
def test(proxy_str: str) -> dict:
"""测试代理连通性"""
try:
start = datetime.now()
if proxy_str and str(proxy_str).strip() not in ("null", "None", ""):
handler = urllib.request.ProxyHandler({"http": proxy_str, "https": proxy_str})
opener = urllib.request.build_opener(handler)
resp = opener.open("http://httpbin.org/ip", timeout=10)
data = json.loads(resp.read().decode())
elapsed = (datetime.now() - start).total_seconds()
proxy_type = "HTTP"
if proxy_str.startswith("socks5://") or proxy_str.startswith("socks://"):
proxy_type = "SOCKS5"
return {
"status": "ok", "ip": data.get("origin", ""),
"type": proxy_type, "elapsed": f"{elapsed:.1f}s",
"speed": "快" if elapsed < 0.5 else ("中" if elapsed < 2 else "慢"),
}
else:
resp = urllib.request.urlopen("http://httpbin.org/ip", timeout=10)
data = json.loads(resp.read().decode())
elapsed = (datetime.now() - start).total_seconds()
return {"status": "ok", "ip": data.get("origin", ""), "elapsed": f"{elapsed:.1f}s", "direct": True}
except Exception as e:
return {"status": "error", "error": str(e)}
@staticmethod
def set_proxy(account_id: str, proxy_str: str) -> dict:
"""设置账号代理"""
from pathlib import Path
IDENTITIES_ROOT = AGENT_LOCAL / "tools" / "matrix" / "identities"
config_path = IDENTITIES_ROOT / account_id / "config.yaml"
if not config_path.exists():
return {"status": "error", "error": f"账号 {account_id} 身份目录不存在"}
try:
import yaml
data = yaml.safe_load(config_path.read_text()) or {}
if "identity" not in data:
data["identity"] = {}
data["identity"]["proxy"] = proxy_str if proxy_str.strip() else None
config_path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
return {"status": "ok", "account": account_id, "proxy": proxy_str}
except Exception as e:
return {"status": "error", "error": str(e)}
@@ -0,0 +1,730 @@
"""
recorder.py — 原子操作录制引擎 (v2.0)
三阶段管线 Phase 1:
启动 Camoufox → 用户操作 → 反引号 `·` 标记步骤 / Esc 结束 →
系统自动捕获: URL + DOM + 截图 + CDP事件序列
用法:
from mc.recorder import RecordingSession
session = RecordingSession("douyin_01")
await session.start()
# 用户操作后在浏览器按 `·` 标记步骤,按 Esc 结束
package = await session.stop()
"""
import asyncio
import json
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SCRIPTS_DIR))
from matrix_mgmt import AGENT_LOCAL
from ops._base import PageState
LOCAL_ROOT = AGENT_LOCAL / "tools" / "matrix"
RECORDINGS_DIR = LOCAL_ROOT / "recordings"
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
class RecordingSession:
"""一次录制会话——管理浏览器、监听事件、保存步骤"""
def __init__(self, account_id: str, platform: str = "auto",
entry_url: str = None):
self.account_id = account_id
self.platform = platform
self.entry_url = entry_url
self.conn = None
self.page = None
self._event_buffer = [] # CDP 事件缓存(两次步骤之间)
self._steps = [] # 已保存的步骤
self._is_recording = False
self._start_time = None
self._js_listener_ready = False
self.machine = ""
self.phone = ""
self.nickname = ""
async def start(self):
"""启动 Camoufox + 注入事件监听"""
from cdp_connector import CDPConnector
# 解析身份目录
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
accts = mgr.list_accounts()
acct_info = None
for a in accts:
if a["id"] == self.account_id:
acct_info = a
break
if not acct_info:
raise ValueError(f"账号 {self.account_id} 不存在")
# 保存元数据:机器名/手机号/昵称
import os
self.machine = os.uname().nodename
self.phone = acct_info.get("phone", "")
# 从 acct_info 或 profiles.json 找昵称
self.nickname = acct_info.get("nickname", "")
if not self.nickname:
try:
import json
pf = LOCAL_ROOT / "data" / "profiles.json"
if pf.exists():
profiles = json.loads(pf.read_text())
p = profiles.get(self.account_id, {})
self.nickname = p.get("nickname", "")
except:
pass
identity_dir_name = (acct_info.get("identity_dir") or acct_info.get("identity_hint") or self.account_id).replace("identities/", "")
identity_dir = str(LOCAL_ROOT / "identities" / identity_dir_name)
print(f"🦀 启动 Camoufox ({self.account_id})...")
conn = CDPConnector(
browser_type="camoufox",
headless=False,
window=(702, 783),
identity_dir=identity_dir,
)
await conn.connect()
await conn.init_anti_detection()
self.conn = conn
self.page = conn.page
self._start_time = time.time()
self._is_recording = True
# 导航到入口页("auto" 时从账号解析平台)
if self.platform == "auto" and self.account_id:
try:
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
for a in mgr.list_accounts():
if a["id"] == self.account_id:
p = a.get("platform", "")
if p in ("douyin", "xiaohongshu"):
self.platform = p
break
except: pass
target = self.entry_url or {
"douyin": "https://www.douyin.com/",
"xiaohongshu": "https://www.xiaohongshu.com/explore",
}.get(self.platform, "https://www.douyin.com/")
print(f"🌐 打开 {target}")
await self.page.goto(target, timeout=30000, wait_until="domcontentloaded")
await asyncio.sleep(4)
# 注入 JS 事件监听器(捕获键盘/鼠标事件到 window.__recorded_events)
await self._inject_event_listener()
print(f"\n{'='*55}")
print(f" 🎬 录制就绪!")
print(f" 账号: {self.account_id}")
print(f" 在浏览器中操作,然后按:")
print(f" 反引号 `·` → 标记步骤(截图+状态指纹)")
print(f" Esc → 结束录制")
print(f"{'='*55}\n")
async def _inject_event_listener(self):
"""注入 CDP 事件捕获器——记录用户操作"""
try:
await self.page.evaluate("""() => {
if (window.__recorder_injected) return;
window.__recorder_injected = true;
window.__recorded_events = [];
// 监听键盘事件
document.addEventListener('keydown', (e) => {
window.__recorded_events.push({
t: 'key',
k: e.key,
c: e.ctrlKey,
m: e.metaKey,
a: e.altKey,
s: e.shiftKey,
code: e.code,
ts: Date.now()
});
}, true);
// 监听鼠标点击——瞬间捕获完整元素特征
document.addEventListener('mousedown', (e) => {
var el = e.target;
var r = el.getBoundingClientRect && el.getBoundingClientRect();
var p = el.parentElement;
var clickInfo = {
t: 'click',
x: Math.round(e.clientX),
y: Math.round(e.clientY),
btn: e.button,
ts: Date.now(),
tag: (el.tagName || '').toLowerCase(),
cls: (typeof el.className === 'string') ? el.className.slice(0,80) : '',
id: el.id || '',
text: (el.textContent || '').trim().slice(0,30),
e2e: el.getAttribute ? (el.getAttribute('data-e2e') || '') : '',
role: el.getAttribute ? (el.getAttribute('role') || '') : '',
rect: r ? Math.round(r.x)+','+Math.round(r.y)+' '+Math.round(r.w)+'x'+Math.round(r.h) : '',
visible: r ? (r.width > 0 && r.height > 0) : false,
parentTag: p ? p.tagName.toLowerCase() : '',
parentCls: (p && typeof p.className === 'string') ? p.className.slice(0,40) : '',
};
// 额外捕获最近的可交互祖先
var walk = el;
for (var w = 0; w < 3 && walk; w++) {
var wr = walk.getBoundingClientRect();
if (wr.width > 0 && wr.height > 0 && (walk.tagName === 'BUTTON' || walk.tagName === 'A' || walk.getAttribute('role') === 'button')) {
clickInfo['ancestorTag'] = walk.tagName.toLowerCase();
clickInfo['ancestorCls'] = (typeof walk.className === 'string') ? walk.className.slice(0,40) : '';
clickInfo['ancestorText'] = (walk.textContent || '').trim().slice(0,30);
break;
}
walk = walk.parentElement;
}
window.__recorded_events.push(clickInfo);
}, true);
// 监听鼠标悬浮(元素边界变化时记录,不会产生大量事件)
document.addEventListener('mouseover', (e) => {
const t = e.target;
const tag = (t.tagName || '').toLowerCase();
const txt = (t.textContent || '').trim().slice(0, 20);
const cls = (t.className && typeof t.className === 'string') ? t.className.slice(0,25) : '';
// 只记录有意义的悬浮:按钮/链接/菜单等
if (['a','button','li','span','div','img'].includes(tag) && (txt || cls)) {
window.__recorded_events.push({
t: 'hover',
tag: tag,
text: txt,
cls: cls,
x: e.clientX,
y: e.clientY,
ts: Date.now()
});
}
}, true);
// 监听鼠标滚轮
document.addEventListener('wheel', (e) => {
window.__recorded_events.push({
t: 'scroll',
dx: Math.round(e.deltaX),
dy: Math.round(e.deltaY),
ts: Date.now()
});
}, true);
}""")
self._js_listener_ready = True
except Exception as e:
print(f"⚠️ 事件监听注入失败: {e}")
self._js_listener_ready = False
async def _flush_events(self) -> list:
"""取出并清空事件缓存——如在页面跳转后丢失JS监听器则自动重注入"""
if not self._js_listener_ready or not self.page:
events = list(self._event_buffer)
self._event_buffer = []
return events
try:
result = await self.page.evaluate("""() => {
// 检查JS监听器是否还在(页面可能导航过)
if (typeof window.__recorded_events === 'undefined' || !window.__recorder_injected) {
return JSON.stringify({need_reinject: true, events: []});
}
var buf = window.__recorded_events;
window.__recorded_events = [];
return JSON.stringify({need_reinject: false, events: buf.slice(-200)});
}""")
import json as _json
data = _json.loads(result)
if data.get('need_reinject'):
print(f" 🔄 页面导航检测,重新注入事件监听器")
await self._inject_event_listener()
return []
return data.get('events', []) or []
except Exception as _e:
# page.evaluate 失败 = 页面已关闭或导航中
return []
async def _capture_page_state(self) -> dict:
"""捕获当前页面的状态指纹(用于录制分析)
使用与三段式操作相同的 PageState 结构,便于 Analyzer 分析。
"""
state = PageState()
if not self.page:
return state.to_dict()
try:
state.url_pattern = self.page.url
# 页面模式
text = await self.page.evaluate("(document.body.innerText || '').trim()") or ""
has_video = await self.page.evaluate("document.querySelectorAll('video').length > 0")
if '/video/' in state.url_pattern and has_video:
state.page_mode = 'player'
elif has_video:
state.page_mode = 'player'
elif '/user/' in state.url_pattern or '/profile' in state.url_pattern:
state.page_mode = 'profile'
elif '/search/' in state.url_pattern:
state.page_mode = 'search'
elif 'xiaohongshu' in state.url_pattern:
state.page_mode = 'grid' if 'explore' in state.url_pattern else 'unknown'
else:
state.page_mode = 'grid'
# 关键选择器可见性
for sel in ['[data-e2e="video-player-digg"]', '[data-e2e="video-player-collect"]',
'[data-e2e="searchbar-input"]', 'video', 'section.note-item']:
try:
state.selectors[sel] = await self.page.locator(sel).first.is_visible()
except:
state.selectors[sel] = False
# 文本片段
state.text_snippet = text[:2000]
except:
pass
return state.to_dict()
async def record_step(self, step_number: int) -> dict:
"""标记一个步骤——捕获当前页面状态 + 事件序列 + 状态指纹"""
if not self._is_recording or not self.page:
return {"step": step_number, "error": "not_recording"}
# 捕获操作前状态
before_state = await self._capture_page_state()
ts = datetime.now()
events = await self._flush_events()
step_data = {
"step": step_number,
"time": ts.strftime("%H:%M:%S"),
"ts_ms": int(ts.timestamp() * 1000),
"elapsed": round(time.time() - self._start_time, 1),
"events_since_last": len(events),
"events": events,
"before_state": before_state,
}
# 从点击事件中提取最佳选择器(供后续自动化回放)
for e in reversed(events):
if e.get('t') == 'click':
sel = None
eid = e.get('id', '')
ee2e = e.get('e2e', '')
etag = e.get('tag', '')
e_cls = e.get('cls', '').strip()
# 优先级: id > data-e2e > tag+cls > ancestor
if eid:
sel = f'#{eid}'
elif ee2e:
sel = f'[data-e2e="{ee2e}"]'
elif etag and e_cls:
first_cls = e_cls.split()[0]
sel = f'{etag}.{first_cls}'
elif e.get('ancestorTag') and e.get('ancestorCls'):
a_cls = e['ancestorCls'].split()[0]
sel = f'{e["ancestorTag"]}.{a_cls}'
if sel:
step_data["click_selector"] = sel
step_data["click_info"] = {
"x": e.get('x'), "y": e.get('y'),
"id": eid, "tag": etag, "cls": e_cls,
}
break # 只取最近一次点击
# 采集页面状态
try:
state = await self.page.evaluate("""() => {
const text = (document.body.innerText || '').trim();
// 收集页面上所有可见的可交互元素(包括弹层里的)
const interactables = [];
const found = new Set();
// 获取所有可见元素(不限制选择器)
const allEls = document.querySelectorAll('input, button, a, textarea, [contenteditable], [tabindex], [role="button"], [role="link"], [data-e2e], select, [class*="btn"], [class*="tab"]');
allEls.forEach(el => {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0 && r.top < window.innerHeight) {
const key = el.tagName + (typeof el.className === 'string' ? el.className.slice(0,20) : '');
if (!found.has(key)) {
found.add(key);
const e2e = el.getAttribute && el.getAttribute('data-e2e');
interactables.push({
tag: el.tagName.toLowerCase(),
text: (el.textContent||'').trim().slice(0,35),
cls: (typeof el.className === 'string') ? el.className.slice(0,50) : '',
e2e: e2e || '',
placeholder: el.getAttribute ? (el.getAttribute('placeholder') || '') : '',
type: el.getAttribute ? (el.getAttribute('type') || '') : '',
rect: `${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}x${Math.round(r.h)}`,
vis: r.top < window.innerHeight && r.left < window.innerWidth
});
}
}
});
// 额外:专门查找所有 input(可能是登录浮层里的)
const allInputs = document.querySelectorAll('input');
allInputs.forEach(el => {
const r = el.getBoundingClientRect();
const key = 'INPUT_extra_' + (el.placeholder || '') + (el.className || '');
if (r.width > 0 && r.height > 0 && !found.has(key)) {
found.add(key);
interactables.push({
tag: 'input',
text: '',
cls: (typeof el.className === 'string') ? el.className.slice(0,50) : '',
e2e: el.getAttribute ? (el.getAttribute('data-e2e') || '') : '',
placeholder: el.getAttribute ? (el.getAttribute('placeholder') || '') : '',
type: el.getAttribute ? (el.getAttribute('type') || '') : '',
rect: `${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}x${Math.round(r.h)}`,
vis: true
});
}
});
return {
url: window.location.href,
title: document.title,
text_snippet: text.slice(0, 4000),
interactable_count: interactables.length,
interactables: interactables.slice(0, 50),
viewport: `${window.innerWidth}x${window.innerHeight}`,
page_mode: text.includes('视频') && document.querySelector('video') ? 'player'
: text.includes('关注') && text.includes('粉丝') ? 'profile'
: text.length > 500 ? 'grid' : 'other',
};
}""")
step_data["page"] = state
except Exception as e:
step_data["page"] = {"error": str(e)}
# 保存页面关键区域 HTML(供后续精确分析元素特征)
try:
html_snapshot = await self.page.evaluate("""() => {
// 优先保存登录弹层/浮标的 HTML
var containers = document.querySelectorAll(
'#douyin_login_comp_btn_id, ' + // 抖音登录按钮
'.r7j70rK2, ' + // 抖音登录层
'.login-container, ' + // 小红书登录层
'[class*=\"login\"], ' + // 任何含login的容器
'[class*=\"modal\"]' // 弹窗类
);
if (containers.length > 0) {
var seen = new Set();
var parts = [];
for (var i = 0; i < containers.length && parts.length < 3; i++) {
var html = containers[i].outerHTML;
if (!seen.has(html) && html.length < 5000) {
seen.add(html);
parts.push(html);
}
}
return parts.join('\\n---\\n');
}
// 没有登录层时保存 body 的前 3000 字符
return (document.body ? document.body.innerHTML.slice(0, 3000) : '');
}""")
if html_snapshot and len(html_snapshot) > 50:
step_data["page_html"] = html_snapshot
except:
pass
# 截图
try:
ss_dir = RECORDINGS_DIR / "screenshots"
ss_dir.mkdir(parents=True, exist_ok=True)
ss_file = ss_dir / f"step{step_number}_{ts.strftime('%H%M%S')}.png"
await self.page.screenshot(path=str(ss_file))
step_data["screenshot"] = str(ss_file)
except:
step_data["screenshot"] = None
self._steps.append(step_data)
# 输出摘要
p = step_data.get("page", {})
mode = p.get("page_mode", "?")
url_short = p.get("url", "?")[:50]
print(f" [{step_number}] {mode} {url_short} "
f"events={step_data['events_since_last']} "
f"interactables={p.get('interactable_count', '?')}")
if events:
# 显示最后几个重要事件
key_events = [e for e in events if e.get('t') in ('key', 'click')][-3:]
for e in key_events:
if e['t'] == 'key':
print(f" ⌨️ {e.get('k','?')}")
elif e['t'] == 'click':
print(f" 🖱️ 点击 ({e.get('x',0)},{e.get('y',0)}) <{e.get('target','')}>")
return step_data
async def stop(self, keep_open: bool = True) -> dict:
"""结束录制——打包保存
Args:
keep_open: 是否保持浏览器打开(默认 True)
"""
self._is_recording = False
# 结束前刷新未读取的事件(Esc退出前的最后一次点击等)
try:
remaining = await self._flush_events()
if remaining:
self._steps.append({
"step": len(self._steps) + 1,
"time": datetime.now().strftime("%H:%M:%S"),
"ts_ms": int(time.time() * 1000),
"elapsed": round(time.time() - self._start_time, 1),
"events_since_last": len(remaining),
"events": remaining,
"note": "结束前的最后一次操作事件"
})
except:
pass
package = {
"meta": {
"account_id": self.account_id,
"platform": self.platform,
"machine": self.machine,
"phone": self.phone,
"nickname": self.nickname,
"start_time": datetime.fromtimestamp(self._start_time).strftime("%H:%M:%S"),
"end_time": datetime.now().strftime("%H:%M:%S"),
"duration": round(time.time() - self._start_time, 1),
"total_steps": len([s for s in self._steps if isinstance(s.get('step'), int)]),
"created": datetime.now().isoformat(),
},
"steps": self._steps,
}
# 保存录制包
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
pkg_file = RECORDINGS_DIR / f"recording_{self.account_id}_{ts}.json"
with open(pkg_file, "w", encoding="utf-8") as f:
json.dump(package, f, ensure_ascii=False, indent=2)
print(f"\n💾 录制包已保存: {pkg_file}")
print(f" {package['meta']['total_steps']} 步, "
f"耗时 {package['meta']['duration']}s")
# 关闭浏览器(keep_open=False 时才关)
if self.conn and not keep_open:
try:
await self.conn.close()
print("🛑 浏览器已关闭")
except:
pass
elif self.conn and keep_open:
print("🔓 浏览器保持打开状态")
return package
@staticmethod
def list_recordings() -> list:
"""列出所有录制包"""
files = sorted(RECORDINGS_DIR.glob("recording_*.json"))
result = []
for f in files:
try:
raw = f.read_text(encoding="utf-8")
# 只解析 meta 部分,避免加载大量 steps 数据
meta_end = raw.find('"steps"')
if meta_end > 0:
meta_json = raw[:meta_end].rstrip(',\n ') + '}'
pkg = json.loads(meta_json)
else:
pkg = json.loads(raw)
meta = pkg.get("meta", {})
result.append({
"file": f.name,
"path": str(f),
"account": meta.get("account_id", "?"),
"platform": meta.get("platform", "?"),
"steps": meta.get("total_steps", 0),
"duration": meta.get("duration", 0),
"created": meta.get("created", ""),
})
except:
result.append({"file": f.name, "error": "parse_error"})
return result
@staticmethod
def delete_recording(name_or_path: str) -> bool:
"""删除录制包和关联的截图"""
p = Path(name_or_path)
if not p.exists():
p = RECORDINGS_DIR / name_or_path
if not p.exists():
return False
# 删除录制包
p.unlink()
# 也删除关联的截图(可选)
ss_dir = RECORDINGS_DIR / "screenshots"
if ss_dir.exists():
prefix = p.stem.replace("recording_", "")
for ss in ss_dir.glob(f"*{prefix}*"):
ss.unlink()
return True
@staticmethod
def load_recording(name_or_path: str) -> dict:
"""加载录制包"""
p = Path(name_or_path)
if not p.exists():
p = RECORDINGS_DIR / name_or_path
if not p.exists():
raise FileNotFoundError(f"录制包不存在: {name_or_path}")
return json.loads(p.read_text(encoding="utf-8"))
# ── 独立运行(热键监听模式)──
async def _run_interactive(account_id: str, platform: str, timeout_minutes: int = 60,
keep_open: bool = True):
"""交互式录制——反引号标记步骤, Esc 结束
操作方式:
- 在浏览器窗口中操作
- 每完成一个动作 → 按反引号 `·` 键标记一步
- Esc → 结束录制
- 无操作超时自动退出
Args:
account_id: 账号 ID
platform: 平台 (douyin/xiaohongshu)
timeout_minutes: 无操作超时分钟
keep_open: 录制结束后是否保持浏览器打开
"""
session = RecordingSession(account_id, platform)
await session.start()
loop = asyncio.get_running_loop()
timeout_seconds = timeout_minutes * 60
last_activity = loop.time()
step_counter = 0
_manual_end = False
try:
from pynput import keyboard
def on_release(key):
nonlocal last_activity, step_counter, _manual_end
try:
char = getattr(key, 'char', None)
# 反引号 `·` → 标记一步
if char == '`':
step_counter += 1
last_activity = loop.time()
asyncio.run_coroutine_threadsafe(
session.record_step(step_counter), loop
)
return True
# Esc → 结束录制
if key == keyboard.Key.esc:
_manual_end = True
asyncio.run_coroutine_threadsafe(
session.stop(keep_open=True), loop
)
return False
return True
except:
return True
listener = keyboard.Listener(on_release=on_release)
listener.start()
print(f"⌨️ 反引号 `·` 标记步骤, Esc 结束录制")
print(f" 无操作 {timeout_minutes} 分钟自动退出\n")
# 周期性检测JS监听器是否存活(页面导航后会丢失)
_check_interval = 0
while session._is_recording:
await asyncio.sleep(1)
_check_interval += 1
if _check_interval >= 3: # 每3秒检查一次
_check_interval = 0
try:
_alive = await session.page.evaluate("typeof window.__recorder_injected === 'boolean'")
if not _alive:
print(f" 🔄 JS监听器已丢失,重新注入...")
await session._inject_event_listener()
except:
pass
if loop.time() - last_activity > timeout_seconds:
print(f"\n⏰ 无操作超过 {timeout_minutes} 分钟,自动退出")
await session.stop(keep_open=False)
break
listener.stop()
print(f"\n✅ 录制结束, 共 {step_counter} 步")
except ImportError:
print("⚠️ pynput 不可用,使用终端模式 (每操作一步按一次 Enter)")
print(" 输入 q + Enter 结束录制\n")
try:
import sys, select
while session._is_recording:
# 非阻塞读 stdin
r, _, _ = select.select([sys.stdin], [], [], 1.0)
if r:
line = sys.stdin.readline().strip()
if line == 'q':
_manual_end = True
break
step_counter += 1
await session.record_step(step_counter)
print(f" ✅ 已记录 第{step_counter}步, 继续操作后按 Enter...")
except Exception:
pass
finally:
if not _manual_end:
await session.stop()
except Exception as e:
print(f"\n⚠️ 录制异常: {e}")
# 异常不关闭浏览器(保持打开便于排查)
print(f" 浏览器保持打开状态, 可手动操作或关闭")
return # 不调用 session.stop(), 保持浏览器
finally:
if keep_open and _manual_end:
print(f"\n🔓 录制已完成, 浏览器保持打开 (可继续查看或手动关闭)")
elif not keep_open:
_cleanup_playwright_drivers()
def _cleanup_playwright_drivers():
"""清理残留的 Playwright driver 进程(运行超过1小时的)"""
import subprocess, os
try:
result = subprocess.run(
["ps", "aux"], capture_output=True, text=True, timeout=10
)
for line in result.stdout.split("\n"):
if "playwright/driver" in line and "node" in line:
parts = line.split()
if len(parts) > 1:
pid = parts[1]
try:
# 只杀掉非当前进程的 playwright driver
if pid != str(os.getpid()):
subprocess.run(["kill", pid], capture_output=True, timeout=5)
except:
pass
except:
pass
if __name__ == "__main__":
import sys
account = sys.argv[1] if len(sys.argv) > 1 else "douyin_01"
platform = sys.argv[2] if len(sys.argv) > 2 else "douyin"
asyncio.run(_run_interactive(account, platform))
@@ -0,0 +1,83 @@
"""
run.py — mc run 批量执行引擎 (v1.1 - 真实引擎)
使用 mc/engine.py 的 BatchEngine 实际启动浏览器执行原子操作,
而非模拟步骤。
支持:
- 多账号 + 多蓝图 + 多轮数
- 混合随机模式
- 浏览器生命周期管理
- 日志输出
委派给:
→ mc/engine.BatchEngine.run_single() → orchestrator.YanghaoOrchestrator()
→ BrowserManager.launch() → AtomOps.execute() → 真实浏览器操作
"""
import asyncio
import logging
import sys
import time
from datetime import datetime
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SCRIPTS_DIR))
log = logging.getLogger(__name__)
class BatchRunner:
"""批量执行引擎 — 封装 BatchEngine 提供 CLI 友好接口"""
def __init__(self, accounts, blueprints, rounds=10,
interval_range=(30, 60), mix=False,
corpus=None, engine="auto", daemon=False,
stagger="15-30", keep_open=False,
max_browsers=3, url="", direction="",
comment_text="", reply_text=""):
self.accounts = accounts
self.blueprints = blueprints
self.rounds = rounds
self.mix = mix
self.interval_min, self.interval_max = interval_range
self.corpus = corpus or []
self.engine_type = engine
self.daemon = daemon
self.stagger = stagger
self.keep_open = keep_open
self.max_browsers = max_browsers
self.url = url
self.direction = direction
self.comment_text = comment_text
self.reply_text = reply_text
async def run(self) -> dict:
"""执行 — 委托给 BatchEngine"""
from mc.engine import BatchEngine
log.info(f" 🚀 使用真实浏览器引擎执行")
log.info(f" 账号: {self.accounts}")
log.info(f" 蓝图: {self.blueprints}")
log.info(f" 轮数: {self.rounds} | 模式: {'混合随机' if self.mix else '顺序'}")
log.info(f" 浏览器上限: {self.max_browsers} | 错峰: {self.stagger}s | keep_open: {self.keep_open}")
engine = BatchEngine(
accounts=self.accounts,
blueprints=self.blueprints,
rounds=self.rounds,
mix=self.mix,
corpus=self.corpus,
stagger=self.stagger,
keep_open=self.keep_open,
max_browsers=self.max_browsers,
task_params={
"url": self.url,
"comment_text": self.comment_text,
"reply_text": self.reply_text,
"direction": self.direction,
},
)
report = await engine.run()
return report.to_dict()
@@ -0,0 +1,64 @@
"""
mc/runlog.py — 结构化运行日志 v1.0
每次 mc run 执行后,将结果写入 JSONL 格式的结构化日志。
存储位置: agent-local/tools/matrix/logs/run_{date}_{account}.jsonl
供智能体和 Dashboard 读取分析。不依赖 print log。
"""
import json
from datetime import datetime
from pathlib import Path
HOME = Path.home()
from matrix_mgmt import AGENT_LOCAL
LOG_DIR = AGENT_LOCAL / "tools" / "matrix" / "logs"
def write_run_log(account_id: str, report: dict):
"""追加一条运行记录到当日 JSONL 日志"""
log_path = LOG_DIR / f"run_{datetime.now().strftime('%Y%m%d')}_{account_id}.jsonl"
LOG_DIR.mkdir(parents=True, exist_ok=True)
entry = {
"timestamp": datetime.now().isoformat(),
**report,
}
with open(log_path, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
def read_latest_log(account_id: str, date_str: str = None) -> list[dict]:
"""读取指定日期的运行日志(默认今天)"""
if date_str is None:
date_str = datetime.now().strftime("%Y%m%d")
log_path = LOG_DIR / f"run_{date_str}_{account_id}.jsonl"
if not log_path.exists():
return []
entries = []
for line in log_path.read_text().splitlines():
if line.strip():
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
pass
return entries
def get_today_summary() -> dict:
"""获取今日所有账号的运行摘要"""
today = datetime.now().strftime("%Y%m%d")
summary = {"date": today, "accounts": {}}
for f in sorted(LOG_DIR.glob(f"run_{today}_*.jsonl")):
account = f.stem.replace(f"run_{today}_", "")
entries = read_latest_log(account, today)
if entries:
last = entries[-1]
summary["accounts"][account] = {
"runs": len(entries),
"last_blueprint": last.get("blueprint", "?"),
"last_success": last.get("success", 0),
"last_failed": last.get("failed", 0),
"last_time": last.get("timestamp", "?"),
}
return summary
@@ -0,0 +1,308 @@
"""
mc/scheduler.py — 定时任务调度器 v2.0
完整功能:
- YAML 配置定时任务(支持 time + days 星期过滤)
- 历史执行记录(JSONL)+ 联邦同步
- 自动加载人设参数
- 日志记录
配置文件: config/schedule.yaml
格式:
schedules:
douyin_daily:
enabled: true
account: douyin_test
blueprint: douyin_daily
rounds: 3
time: "09:00"
days: "1,2,3,4,5,6,7" # 1=周一..7=周日
历史记录: agent-local/tools/matrix/logs/schedule_history.jsonl
联邦同步: 04_memory/cross_machine/data/matrix/schedule_results.json
"""
import asyncio
import json
import logging
import os
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
log = logging.getLogger(__name__)
HOME = Path.home()
from matrix_mgmt import AGENT_SYNC, AGENT_LOCAL
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = SCRIPTS_DIR / "config"
SCHEDULE_YAML = CONFIG_DIR / "schedule.yaml"
SCHEDULE_YAML.parent.mkdir(parents=True, exist_ok=True)
# 历史记录(本机)
HISTORY_DIR = AGENT_LOCAL / "tools" / "matrix" / "logs"
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
HISTORY_FILE = HISTORY_DIR / "schedule_history.jsonl"
# 联邦同步
CROSS_MACHINE_MATRIX = AGENT_SYNC / "04_memory" / "cross_machine" / "data" / "matrix"
CROSS_MACHINE_MATRIX.mkdir(parents=True, exist_ok=True)
FEDERATION_FILE = CROSS_MACHINE_MATRIX / "schedule_results.json"
HOSTNAME = os.uname().nodename
DEFAULT_CONFIG = """# Matrix 定时任务配置
# time 格式: HH:MM(每天固定时间)
# days 格式: 1,2,3,4,5,6,7(1=周一..7=周日,默认每天)
schedules:
douyin_daily_morning:
enabled: false
account: douyin_test
blueprint: douyin_daily
rounds: 3
time: "09:00"
days: "1,2,3,4,5,6,7"
xhs_daily_noon:
enabled: false
account: xhs_01
blueprint: xhs_daily
rounds: 2
time: "12:30"
days: "1,2,3,4,5,6,7"
"""
# ── 配置读写 ──
def load_all_schedules() -> dict:
"""读取所有任务配置(含禁用)"""
if not SCHEDULE_YAML.exists():
SCHEDULE_YAML.write_text(DEFAULT_CONFIG)
return {}
try:
import yaml
data = yaml.safe_load(SCHEDULE_YAML.read_text())
return data.get("schedules", {})
except Exception as e:
log.error(f"读取定时配置失败: {e}")
return {}
def load_enabled_schedules() -> list[dict]:
"""读取启用的任务(含今天是否执行判断)"""
all_s = load_all_schedules()
today = datetime.now().isoweekday() # 1=周一..7=周日
result = []
for sid, cfg in all_s.items():
if not cfg.get("enabled", False):
continue
# days 过滤
days_str = cfg.get("days", "1,2,3,4,5,6,7")
try:
days = [int(d.strip()) for d in days_str.split(",")]
except:
days = [1, 2, 3, 4, 5, 6, 7]
if today not in days:
continue
cfg["id"] = sid
result.append(cfg)
return result
def save_all_schedules(schedules: dict):
"""保存全部任务配置"""
import yaml
SCHEDULE_YAML.write_text(yaml.dump(
{"schedules": schedules}, allow_unicode=True, default_flow_style=False
))
# ── 历史记录 ──
def write_history(entry: dict):
"""追加一条历史记录"""
with open(HISTORY_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
def read_history(schedule_id: str = None, limit: int = 20) -> list[dict]:
"""读取历史记录,默认最近20条"""
if not HISTORY_FILE.exists():
return []
entries = []
for line in reversed(HISTORY_FILE.read_text().splitlines()):
if not line.strip():
continue
try:
e = json.loads(line)
if schedule_id and e.get("schedule_id") != schedule_id:
continue
entries.append(e)
if len(entries) >= limit:
break
except:
pass
return entries
def sync_to_federation(result: dict):
"""同步执行结果到联邦数据目录"""
try:
fed = {}
if FEDERATION_FILE.exists():
fed = json.loads(FEDERATION_FILE.read_text())
host_data = fed.get(HOSTNAME, {})
sid = result.get("schedule_id", "unknown")
host_data[sid] = {
"last_run": result.get("timestamp", datetime.now().isoformat()),
"status": "success" if result.get("failed", 0) == 0 else "partial",
"success": result.get("success", 0),
"failed": result.get("failed", 0),
"duration": result.get("duration", 0),
"blueprint": result.get("blueprint", ""),
}
fed[HOSTNAME] = host_data
FEDERATION_FILE.write_text(json.dumps(fed, ensure_ascii=False, indent=2))
except Exception as e:
log.error(f"联邦同步失败: {e}")
# ── 任务执行 ──
def get_due_schedules() -> list[dict]:
"""获取当前时间到点的任务"""
now = datetime.now().strftime("%H:%M")
return [s for s in load_enabled_schedules() if s.get("time", "") == now]
async def run_scheduled_task(schedule: dict) -> dict:
"""执行单个定时任务"""
account = schedule.get("account", "")
blueprint = schedule.get("blueprint", "")
rounds = schedule.get("rounds", 1)
args = dict(schedule.get("args", {}))
# 解析 @persona 标记
for k, v in args.items():
if isinstance(v, str) and v == "@persona":
try:
from mc.corpus import CorpusManager
cm = CorpusManager()
kws = cm.get_search_keywords(account)
if kws:
args[k] = kws[0]
except:
args[k] = "热门推荐"
log.info(f"⏰ 执行定时任务: {account} → {blueprint} ({rounds}轮)")
from mc.engine import BatchEngine
engine = BatchEngine(
accounts=[account],
blueprints=[blueprint],
rounds=rounds,
)
engine.task_params = args
report = await engine.run()
result = report.to_dict()
result["schedule_id"] = schedule.get("id", "")
result["scheduled_time"] = schedule.get("time", "")
result["timestamp"] = datetime.now().isoformat()
result["hostname"] = HOSTNAME
# 写入历史 + 联邦同步
write_history(result)
sync_to_federation(result)
return result
async def scheduler_loop():
"""主循环:每30秒检查一次到点任务"""
log.info("🕐 定时调度器已启动")
last_minute = ""
while True:
now_minute = datetime.now().strftime("%H:%M")
if now_minute != last_minute:
last_minute = now_minute
due = get_due_schedules()
for s in due:
sid = s.get("id", "?")
log.info(f"⏰ [{now_minute}] 触发: {sid}")
try:
result = await run_scheduled_task(s)
log.info(f" ✅ {sid}: {result.get('success',0)}/{result.get('total_steps',0)} 步成功, 耗时{result.get('duration',0)}s")
except Exception as e:
log.error(f" ❌ {sid}: {e}")
write_history({
"schedule_id": sid, "scheduled_time": s.get("time", ""),
"timestamp": datetime.now().isoformat(), "hostname": HOSTNAME,
"status": "error", "error": str(e),
})
await asyncio.sleep(30)
# ── CLI ──
def cmd_schedule_list():
"""列出所有定时任务"""
all_s = load_all_schedules()
if not all_s:
print("暂无定时任务")
print("配置位置: config/schedule.yaml")
return
print(f"\n{'ID':25s} {'账号':20s} {'蓝图':25s} {'时间':8s} {'轮数':4s} {'天数':12s} {'状态':6s}")
print("-" * 100)
for sid, cfg in all_s.items():
enabled = "🟢" if cfg.get("enabled", False) else "⚪"
days = cfg.get("days", "每天")
print(f"{sid:25s} {cfg.get('account','?'):20s} "
f"{cfg.get('blueprint','?'):25s} {cfg.get('time','?'):8s} "
f"{cfg.get('rounds',1):4d} {days:12s} {enabled:6s}")
def cmd_schedule_add(sid: str, account: str, blueprint: str,
time_str: str, rounds: int = 1, days: str = "1,2,3,4,5,6,7",
args: str = ""):
"""添加定时任务"""
all_s = load_all_schedules()
all_s[sid] = {
"enabled": True,
"account": account,
"blueprint": blueprint,
"rounds": rounds,
"time": time_str,
"days": days,
}
if args:
all_s[sid]["args"] = {"keyword": args}
save_all_schedules(all_s)
print(f"✅ 定时任务 '{sid}' 已添加({time_str} 每天{days})")
def cmd_schedule_remove(sid: str):
"""删除定时任务"""
all_s = load_all_schedules()
if sid in all_s:
del all_s[sid]
save_all_schedules(all_s)
print(f"✅ 定时任务 '{sid}' 已删除")
else:
print(f"❌ 未找到定时任务 '{sid}'")
def cmd_schedule_history(sid: str = "", limit: int = 10):
"""查看历史记录"""
entries = read_history(sid if sid else None, limit)
if not entries:
print("暂无历史记录")
return
print(f"\n最近 {len(entries)} 条执行记录{' ('+sid+')' if sid else ''}:")
print(f"{'时间':20s} {'任务':25s} {'状态':10s} {'成功':6s} {'失败':6s} {'耗时':6s}")
print("-" * 75)
for e in entries:
st = e.get("status", e.get("error", "?"))
print(f"{e.get('timestamp','?'):20s} {e.get('schedule_id','?'):25s} "
f"{st:10s} {str(e.get('success','?')):6s} {str(e.get('failed','?')):6s} "
f"{str(e.get('duration','?')):6s}")
@@ -0,0 +1,183 @@
"""
mc/task.py — 任务调度器 v1.0
智能任务:根据用户意图自动选择蓝图、填充参数、执行。
用法:
mc task comment --url https://v.douyin.com/xxx --direction 正面
mc task search --keyword 美食探店 --rounds 3
mc task collect --keyword 博主名称
mc task reply --account douyin_test
流程:
1. 解析任务参数(URL/关键词/方向)
2. 校验必须参数
3. 平台识别(抖音/小红书)
4. 自动选账号(同平台已登录的)
5. 选蓝图
6. 填充参数到蓝图
7. 执行 mc run
"""
import asyncio
import json
import logging
import random
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
log = logging.getLogger(__name__)
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SCRIPTS_DIR))
PLATFORM_PATTERNS = {
"douyin": [r"douyin\.com", r"v\.douyin\.com"],
"xiaohongshu": [r"xiaohongshu\.com", r"xhslink\.com"],
}
def detect_platform(url_or_text: str) -> str:
"""识别平台:douyin / xiaohongshu / unknown"""
for plat, patterns in PLATFORM_PATTERNS.items():
for p in patterns:
if re.search(p, url_or_text.lower()):
return plat
return "unknown"
def pick_account(platform: str) -> Optional[str]:
"""从已登录账号中选一个同平台的"""
try:
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
candidates = []
for a in mgr.list_accounts():
if a.get("platform") == platform and a.get("_status") in ("logged_in", "ok"):
candidates.append(a["id"])
return random.choice(candidates) if candidates else None
except Exception:
return None
@dataclass
class Task:
"""一个待执行的任务"""
type: str # comment / search / collect / reply
platform: str = "" # 自动识别的平台
account: str = "" # 指定或自动选的账号
url: str = "" # 视频链接(定向评论用)
keyword: str = "" # 搜索关键词
direction: str = "" # 评论方向(正面/提问/共鸣等)
comment_text: str = "" # 具体评论内容(不填则自动生成)
rounds: int = 1 # 执行轮数
blueprint: str = "" # 自动选的蓝图
def validate(self) -> list[str]:
"""参数校验,返回错误列表"""
errors = []
if self.type == "comment":
if not self.url:
errors.append("定向评论必须提供视频链接(--url)")
self.platform = detect_platform(self.url)
if self.platform == "unknown" and self.url:
errors.append(f"无法识别链接平台: {self.url}")
elif self.type in ("search", "collect"):
if not self.keyword:
errors.append("搜索/采集必须提供关键词(--keyword)")
self.platform = "douyin" # 默认抖音
elif self.type == "reply":
if not self.account:
errors.append("作者回复必须指定账号(--account)")
try:
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
acct = mgr.get_account(self.account)
self.platform = acct.get("platform", "douyin") if acct else "douyin"
except:
self.platform = "douyin"
return errors
def auto_fill(self):
"""自动填空:选账号、选蓝图、生成评论"""
# 选账号
if not self.account and self.platform:
picked = pick_account(self.platform)
if picked:
self.account = picked
log.info(f" 🤖 自动选账号: {self.account}")
# 选蓝图
bp_map = {
"comment": f"{self.platform}_comment" if self.platform != "unknown" else "douyin_comment",
"search": f"{self.platform}_search" if self.platform != "unknown" else "douyin_search",
"collect": f"{self.platform}_collect" if self.platform != "unknown" else "douyin_collect",
"reply": f"{self.platform}_reply" if self.platform != "unknown" else "douyin_reply",
}
self.blueprint = bp_map.get(self.type, "douyin_daily")
# 方向默认
if not self.direction:
self.direction = random.choice(["称赞", "提问", "共鸣", "感慨"])
def to_task_params(self) -> dict:
"""转为 engine.py 的 task_params 格式"""
params = {"direction": self.direction}
if self.url:
params["url"] = self.url
if self.keyword:
params["keyword"] = self.keyword
if self.comment_text:
params["comment_text"] = self.comment_text
return params
def summary(self) -> str:
return (f"{self.type.upper():8s} | 平台={self.platform:12s} | "
f"账号={self.account:20s} | 蓝图={self.blueprint:25s} | "
f"方向={self.direction}")
async def run_task(task: Task) -> dict:
"""执行任务:校验→填充→跑 mc run"""
errors = task.validate()
if errors:
return {"status": "error", "errors": errors, "task": str(task)}
task.auto_fill()
log.info(f"\n{'='*60}")
log.info(f" 📋 任务: {task.summary()}")
log.info(f"{'='*60}")
if not task.account:
return {"status": "error", "errors": [f"没有可用的{task.platform}账号"], "task": str(task)}
# 执行
from mc.engine import BatchEngine, BatchReport
engine = BatchEngine(
accounts=[task.account],
blueprints=[task.blueprint],
rounds=task.rounds,
mix=False,
)
engine.task_params = task.to_task_params()
report = await engine.run()
result = report.to_dict()
result["task"] = task.summary()
result["task_params"] = task.to_task_params()
return result
# ── CLI 入口(供 mc task 命令调用)──
def parse_task_args(args) -> Task:
"""从 argparse 解析 Task"""
return Task(
type=args.task_type or "",
url=getattr(args, "url", ""),
keyword=getattr(args, "keyword", ""),
direction=getattr(args, "direction", ""),
comment_text=getattr(args, "comment", ""),
account=getattr(args, "account", ""),
rounds=getattr(args, "rounds", 1),
)