feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user