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

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

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

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

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

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

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

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

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,701 @@
"""
corpus.py — 评论语料库管理
双平台隔离: 抖音/小红书 各自独立语料
每个平台下分多个分类,含权重、启用状态
文件结构:
corpus/
├── douyin.yaml ← 抖音语料
└── xiaohongshu.yaml ← 小红书语料
YAML 格式:
version: "1.0"
categories:
赞美:
weight: 30
enabled: true
label: "赞美评论"
comments:
- "讲得太好了,受益匪浅!"
- "这个观点很新颖,学习了"
搞笑:
weight: 20
enabled: true
label: "搞笑评论"
comments:
- "笑死我了哈哈哈哈哈"
"""
import asyncio
import json
import logging
import os
import random
import re
from pathlib import Path
from typing import List, Optional
import yaml
log = logging.getLogger(__name__)
TOOL_DIR = Path(__file__).resolve().parent.parent.parent
CORPUS_DIR = TOOL_DIR / "corpus"
CONFIG_DIR = TOOL_DIR / "config"
# 项目根目录(agent-sync/05_tools/.. 的上一层,即 workbuddy-agent-os)
PROJECT_ROOT = TOOL_DIR.parent.parent.parent
PROFILES_PATH = PROJECT_ROOT / "agent-local/tools/matrix/data/profiles.json"
# ── 关键词 → 方向 映射 ──────────────────────────────────────────
KEYWORD_CATEGORY_MAP = [
(["科技", "数码", "手机"], ["称赞", "提问"]),
(["美食", "做饭", "菜"], ["称赞", "共鸣"]),
(["旅游", "风景", "旅行"], ["称赞", "提问"]),
(["情感", "生活", "感悟"], ["共鸣", "安慰"]),
(["知识", "科普", "教育"], ["提问", "补充"]),
]
# 方向名称 → 实际分类名称(兼容两个平台已有的分类)
DIRECTION_TO_CATEGORY = {
"称赞": "赞美",
"正面": "赞美",
"提问": "提问",
"共鸣": "感慨",
"安慰": "感慨",
"补充": "客观",
}
# 默认方向标签(用于 fallback)
ALL_DIRECTIONS = list(DIRECTION_TO_CATEGORY.keys())
CORPUS_DIR.mkdir(parents=True, exist_ok=True)
# 默认跨平台通用分类
DEFAULT_DOUYIN_COMMENTS = {
"赞美": {
"weight": 30, "enabled": True, "label": "赞美评论",
"comments": [
"讲得太好了,受益匪浅!",
"这个观点很新颖,学习了",
"说得真是振聋发聩",
"干货满满,感谢分享",
"条理清晰,逻辑严密",
"每一句话都说到了心坎里",
],
"templates": ["关于{keyword}的观点很到位", "感谢分享{keyword}的心得", "说得太好了,{keyword}这部分启发很大"],
},
"搞笑": {
"weight": 20, "enabled": True, "label": "搞笑评论",
"comments": ["哈哈哈哈哈笑死", "有才,这评论比视频还精彩", "笑不活了家人们", "你要把我笑死"],
"templates": ["{keyword}这个梗笑死我了", "哈哈哈哈{keyword}太有才了"],
},
"客观": {
"weight": 15, "enabled": True, "label": "客观评价",
"comments": ["比较客观", "抛开立场看,说得有道理", "中肯的评价", "理性分析,值得一读"],
"templates": ["关于{keyword}的分析很客观", "理性的声音,{keyword}这部分说得好"],
},
"提问": {
"weight": 10, "enabled": True, "label": "提问互动",
"comments": ["请问这是在哪里?", "能分享一下具体方法吗?", "新手想问一下怎么入门", "这个需要什么工具?"],
"templates": ["{keyword}具体怎么操作?", "能详细讲讲{keyword}吗?"],
},
"感慨": {
"weight": 10, "enabled": True, "label": "生活感慨",
"comments": ["生活不易,且行且珍惜", "每个人都有自己的故事", "看得我热泪盈眶", "这就是生活啊"],
"templates": ["看到{keyword}想起了自己", "{keyword}真的太真实了"],
},
}
# 小红书专属分类
DEFAULT_XHS_COMMENTS = {
"穿搭": {
"weight": 25, "enabled": True, "label": "穿搭评论",
"comments": ["这套搭配好好看!", "求链接!", "色系搭配太舒服了", "超有气质", "这个风格很适合你"],
"templates": ["{keyword}这套太好看了", "求{keyword}的链接"],
},
"美妆": {
"weight": 20, "enabled": True, "label": "美妆评论",
"comments": ["这个妆面好干净", "求粉底色号", "口红颜色太美了", "教程很详细,收藏了"],
"templates": ["{keyword}这个妆容好干净", "{keyword}是什么色号"],
},
"种草": {
"weight": 20, "enabled": True, "label": "种草评论",
"comments": ["种草了!", "我也买了同款,真的不错", "已加购物车", "求推荐更多好物"],
"templates": ["被{keyword}种草了", "{keyword}已加入清单"],
},
"美食": {
"weight": 15, "enabled": True, "label": "美食评论",
"comments": ["看着好好吃", "求地址", "收藏了,周末去试试", "这个做法很简单,明天就试"],
"templates": ["{keyword}看着好好吃", "求{keyword}的店名"],
},
"家居": {
"weight": 10, "enabled": True, "label": "家居评论",
"comments": ["布置得好温馨", "求家具链接", "这个风格好喜欢", "家的感觉真好"],
"templates": ["{keyword}的布置好温馨", "求{keyword}的链接"],
},
}
# ═══════════════════════════════════════════════════════════════
# AI 评论生成器
# ═══════════════════════════════════════════════════════════════
class AIGenerator:
"""AI 评论生成器(OpenAI 兼容 API)
使用简单的 HTTP 请求,不依赖 openai 库。
没有配置 API key 时跳过,不影响现有功能。
"""
def __init__(self, api_key: str = None, base_url: str = None, model: str = None):
config = self._load_config()
raw_key = api_key or config.get("api_key", "") or os.environ.get("OPENAI_API_KEY", "")
self.api_key = raw_key.strip() if raw_key else ""
self.base_url = (base_url or config.get("base_url") or "https://api.openai.com/v1").rstrip("/")
self.model = model or config.get("model") or "gpt-4o-mini"
self.temperature = config.get("temperature", 0.8)
# ── 配置加载 ────────────────────────────────────────────
@staticmethod
def _load_config() -> dict:
"""从 config/ai.yaml 加载配置"""
path = CONFIG_DIR / "ai.yaml"
if path.exists():
try:
raw = yaml.safe_load(path.read_text())
return (raw or {}).get("ai", {})
except Exception as exc:
log.warning(" ⚠️ ai.yaml 解析失败: %s", exc)
return {}
# ── 公共 API ────────────────────────────────────────────
@property
def available(self) -> bool:
"""是否有 API key 可用"""
return bool(self.api_key)
async def generate_comment(
self,
video_title: str,
video_desc: str = "",
direction: str = "称赞",
persona: dict = None,
) -> Optional[str]:
"""根据视频标题和方向生成评论
Args:
video_title: 视频标题
video_desc: 视频描述(可选)
direction: 评论方向(称赞/提问/共鸣/补充/搞笑)
persona: 账号人设字典(可选)
Returns:
生成的评论文本,失败返回 None
"""
if not self.available:
log.debug(" ⏭️ AI 生成跳过(未配置 API key)")
return None
prompt = self._build_prompt(video_title, video_desc, direction, persona)
text = await self._call_api(prompt)
return text
# ── 内部方法 ────────────────────────────────────────────
def _build_prompt(self, video_title: str, video_desc: str, direction: str, persona: dict = None) -> str:
"""构建 prompt"""
style_hint = f"(风格要求:{direction})"
persona_hint = ""
if persona:
parts = []
if persona.get("age_group"):
parts.append(f"年龄层: {persona['age_group']}")
if persona.get("gender"):
parts.append(f"性别: {persona['gender']}")
if persona.get("interests"):
parts.append(f"兴趣: {'、'.join(persona['interests'])}")
if persona.get("comment_style"):
parts.append(f"评论风格: {'、'.join(persona['comment_style'])}")
if parts:
persona_hint = f"\n账号人设: {'; '.join(parts)}"
desc_part = f"\n视频描述: {video_desc}" if video_desc else ""
return (
f"你是一个短视频平台的活跃用户。请根据以下视频信息写一条 {direction} 方向的评论{style_hint}。"
f"{persona_hint}"
f"\n视频标题: {video_title}"
f"{desc_part}"
"\n\n要求:"
"\n- 语言自然,像真实用户的评论"
"\n- 长度 10~50 字"
"\n- 不要用 emoji"
"\n- 直接输出评论内容,不要加引号或前缀"
)
async def _call_api(self, prompt: str) -> Optional[str]:
"""调用 OpenAI 兼容 API 生成文本"""
import httpx
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"temperature": self.temperature,
"max_tokens": 150,
},
)
if resp.status_code != 200:
log.warning(" ⚠️ AI API 返回 %s: %s", resp.status_code, resp.text[:200])
return None
data = resp.json()
choices = data.get("choices", [])
if not choices:
return None
text = choices[0].get("message", {}).get("content", "").strip()
return text if text else None
except Exception as exc:
log.warning(" ⚠️ AI API 调用失败: %s", exc)
return None
# ═══════════════════════════════════════════════════════════════
# 语料库管理器
# ═══════════════════════════════════════════════════════════════
class CorpusManager:
"""语料库管理器"""
def __init__(self):
self._cache = {}
self._personas = {} # account_id → persona dict
self._ai_gen = None # AIGenerator 懒实例化
self._ensure_files()
# ── 语料文件管理 ────────────────────────────────────────
def _ensure_files(self):
"""确保语料库 YAML 文件存在"""
for platform, data in [("douyin", DEFAULT_DOUYIN_COMMENTS), ("xiaohongshu", DEFAULT_XHS_COMMENTS)]:
path = CORPUS_DIR / f"{platform}.yaml"
if not path.exists():
content = {"version": "1.0", "categories": data}
path.write_text(yaml.dump(content, default_flow_style=False, allow_unicode=True, sort_keys=False))
log.info(f" 📝 创建语料文件: {path.name} ({sum(1 for c in data.values() for _ in c.get('comments',[]))} 条)")
def _load(self, platform: str) -> dict:
"""加载指定平台的语料"""
if platform not in self._cache:
path = CORPUS_DIR / f"{platform}.yaml"
if path.exists():
self._cache[platform] = yaml.safe_load(path.read_text()) or {}
else:
self._cache[platform] = {"version": "1.0", "categories": {}}
return self._cache[platform]
# ── 人设管理 ──────────────────────────────────────────
def load_personas(self) -> dict:
"""从 profiles.json 加载所有人设,返回 account_id → persona dict"""
log.info(f" 📂 加载人设数据: {PROFILES_PATH}")
try:
if PROFILES_PATH.exists():
raw = json.loads(PROFILES_PATH.read_text())
self._personas = {}
for account_id, profile in raw.items():
persona = profile.get("persona")
if persona:
self._personas[account_id] = persona
log.info(f" ✅ 加载 {len(self._personas)} 个人设")
else:
log.warning(" ⚠️ 人设文件不存在: %s", PROFILES_PATH)
self._personas = {}
except Exception as exc:
log.warning(" ⚠️ 加载人设失败: %s", exc)
self._personas = {}
return self._personas
def get_persona(self, account_id: str) -> Optional[dict]:
"""获取指定账号的人设
Args:
account_id: 账号 ID
Returns:
人设字典,或 None
"""
if not self._personas:
self.load_personas()
persona = self._personas.get(account_id)
if persona is None:
log.debug(f" ℹ️ 账号 %s 无人设", account_id)
return persona
def get_search_keywords(self, account_id: str) -> list:
"""获取账号人设中的搜索关键词
Args:
account_id: 账号 ID
Returns:
关键词列表(无人设时返回空列表)
"""
persona = self.get_persona(account_id)
if persona:
return persona.get("search_keywords", [])
return []
# ── v2 格式支持 ────────────────────────────────────────
def list_personas(self) -> list:
"""列出所有身份(v2格式)"""
result = []
for platform in ["douyin", "xiaohongshu"]:
data = self._load(platform)
for pid, info in data.get("personas", {}).items():
result.append({
"platform": platform,
"id": pid,
"name": info.get("name", pid),
"tags": info.get("tags", []),
"style": info.get("style", ""),
})
return result
def list_scenes(self, persona_id: str = None) -> list:
"""列出所有场景(v2格式)"""
scenes_set = {}
for platform in ["douyin", "xiaohongshu"]:
data = self._load(platform)
for pid, pinfo in data.get("personas", {}).items():
if persona_id and pid != persona_id:
continue
for scene_id, sinfo in data.get("scenes", {}).items():
key = f"{pid}.{scene_id}"
if key not in scenes_set:
scenes_set[key] = {
"persona": pid,
"id": scene_id,
"label": sinfo.get("label", scene_id),
"rounds": sinfo.get("rounds", 1),
}
return list(scenes_set.values())
def get_comment_for_scene(self, persona: str, scene: str,
keyword: str = "", round_num: int = 1) -> Optional[str]:
"""根据身份和场景获取评论(v2格式)
Args:
persona: 身份ID(如 health_lover)
scene: 场景ID(如 first_comment)
keyword: 关键词
round_num: 多轮对话第几轮
Returns:
评论文本,或 None
"""
for platform in ["douyin", "xiaohongshu"]:
data = self._load(platform)
content = data.get("content", {})
# 尝试精确匹配 persona.scene
key = f"{persona}.{scene}"
if key in content:
items = content[key]
if isinstance(items, list):
return random.choice(items).replace("{keyword}", keyword) if items else None
# 多轮格式
round_key = f"round_{round_num}"
if isinstance(items, dict) and round_key in items:
round_items = items[round_key]
if round_items:
return random.choice(round_items).replace("{keyword}", keyword)
# 尝试匹配 scene 但不限定 persona
for ck, cv in content.items():
if ck.endswith(f".{scene}"):
if isinstance(cv, list) and cv:
return random.choice(cv).replace("{keyword}", keyword)
return None
# ── 语料查询 ──────────────────────────────────────────
def list_categories(self) -> list:
"""列出所有平台的分类"""
result = []
for platform in ["douyin", "xiaohongshu"]:
data = self._load(platform)
for name, info in data.get("categories", {}).items():
count = len(info.get("comments", [])) + len(info.get("templates", []))
result.append({
"platform": platform,
"name": name,
"weight": info.get("weight", 0),
"enabled": info.get("enabled", True),
"count": count,
"label": info.get("label", name),
})
return result
def get_comments(self, categories: List[str] = None, platform: str = None, count: int = 20) -> List[str]:
"""获取评论列表
Args:
categories: 指定分类(None = 所有启用分类)
platform: 指定平台(None = 所有平台)
count: 最多返回数量
Returns:
评论文本列表
"""
results = []
platforms = [platform] if platform else ["douyin", "xiaohongshu"]
for p in platforms:
data = self._load(p)
for name, info in data.get("categories", {}).items():
# 筛选分类
if categories and name not in categories:
continue
if not info.get("enabled", True):
continue
if info.get("weight", 0) <= 0:
continue
# 取评论
comments = info.get("comments", [])
templates = info.get("templates", [])
combined = comments + templates
# 按权重比例取
take = max(1, int(count * info.get("weight", 10) / 100))
results.extend(random.sample(combined, min(take, len(combined))))
random.shuffle(results)
return results[:count]
def add_comment(self, category: str, text: str, platform: str = "douyin"):
"""添加一条评论到指定平台指定分类"""
data = self._load(platform)
cats = data.get("categories", {})
if category not in cats:
cats[category] = {"weight": 10, "enabled": True, "label": category, "comments": [], "templates": []}
if "comments" not in cats[category]:
cats[category]["comments"] = []
cats[category]["comments"].append(text)
data["categories"] = cats
path = CORPUS_DIR / f"{platform}.yaml"
path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False))
self._cache.pop(platform, None) # 清除缓存
log.info(f" ✅ 已添加评论到 [{platform}/{category}]: {text}")
def delete_comment(self, category: str, index: int, platform: str = "douyin"):
"""删除指定索引的评论"""
data = self._load(platform)
cats = data.get("categories", {}).get(category, {})
comments = cats.get("comments", [])
if 0 <= index < len(comments):
removed = comments.pop(index)
cats["comments"] = comments
path = CORPUS_DIR / f"{platform}.yaml"
path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False))
self._cache.pop(platform, None)
log.info(f" 🗑 已删除评论 [{platform}/{category}]: {removed}")
def update_category(self, platform: str, category: str, **kwargs):
"""更新分类设置(weight/enabled/label 等)"""
data = self._load(platform)
cats = data.get("categories", {})
if category not in cats:
cats[category] = {"weight": 10, "enabled": True, "label": category, "comments": [], "templates": []}
for k, v in kwargs.items():
if k in ("weight", "enabled", "label"):
cats[category][k] = v
data["categories"] = cats
path = CORPUS_DIR / f"{platform}.yaml"
path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False))
self._cache.pop(platform, None)
log.info(f" ✅ 已更新分类 [{platform}/{category}]: {kwargs}")
# ── 账号上下文 ──────────────────────────────────────────
def set_account_id(self, account_id: str):
"""设置当前账号 ID,方便后续跟踪上下文"""
self._account_id = account_id
log.info(f" 👤 设置账号: {account_id}")
# ── AI 生成器 ──────────────────────────────────────────
def _get_ai_generator(self) -> AIGenerator:
"""懒获取 AI 生成器实例"""
if self._ai_gen is None:
self._ai_gen = AIGenerator()
return self._ai_gen
# ── 视频标题匹配 → 评论 ──────────────────────────────────
@staticmethod
def _extract_first_keyword(video_title: str) -> Optional[str]:
"""从视频标题中提取第一个匹配的关键词"""
for keywords, _ in KEYWORD_CATEGORY_MAP:
for kw in keywords:
if kw in video_title:
return kw
# fallback: 取标题第一个有意义的词
words = re.findall(r'[\w\u4e00-\u9fff]{2,}', video_title)
return words[0] if words else ""
@staticmethod
def _match_keywords(video_title: str) -> list:
"""根据视频标题匹配关键词,返回匹配到的方向列表"""
matched_directions = []
for keywords, directions in KEYWORD_CATEGORY_MAP:
for kw in keywords:
if kw in video_title:
matched_directions.extend(directions)
break # 一个关键词组只匹配一次
return matched_directions
def _get_comment_from_categories(self, category_names: list, platform: str = None,
allow_templates: bool = True) -> Optional[str]:
"""从指定的分类名列表中随机取一条评论(遍历所有平台)
Args:
allow_templates: 是否允许使用含 {keyword} 的模板
"""
platforms = [platform] if platform else ["douyin", "xiaohongshu"]
candidates = []
for p in platforms:
data = self._load(p)
for cat_name, info in data.get("categories", {}).items():
if cat_name not in category_names:
continue
if not info.get("enabled", True):
continue
if allow_templates:
candidates.extend(info.get("comments", []) + info.get("templates", []))
else:
candidates.extend(info.get("comments", []))
if candidates:
return random.choice(candidates)
return None
def _get_random_comment(self, platform: str = None) -> Optional[str]:
"""从所有启用分类中随机取一条评论"""
platforms = [platform] if platform else ["douyin", "xiaohongshu"]
all_comments = []
for p in platforms:
data = self._load(p)
for name, info in data.get("categories", {}).items():
if not info.get("enabled", True):
continue
if info.get("weight", 0) <= 0:
continue
comments = info.get("comments", []) # 随机兜底不包含 {keyword} 模板
all_comments.extend(comments)
if all_comments:
return random.choice(all_comments)
return None
def get_comment_for_video(
self,
video_title: str,
direction: str = None,
account_id: str = None,
) -> Optional[str]:
"""根据视频标题(和可选方向/账号人设)从语料库中匹配一条评论
Args:
video_title: 视频标题文本
direction: 可选,指定方向,如 "称赞"/"提问"/"共鸣"/"补充"
account_id: 可选,账号 ID,用于根据人设的 comment_style 优先选择方向
Returns:
匹配到的评论文本,或 None
"""
# 0) 有人设 → 优先用人设的 comment_style 作为方向
persona = None
if account_id:
persona = self.get_persona(account_id)
if persona and not direction:
# 如果未指定 direction,用人设的第一个 comment_style
styles = persona.get("comment_style", [])
if styles:
# 先用 comment_style 去匹配,最后 fallback 到随机
pass # 后面有具体逻辑
# 1) 从标题匹配关键词,得到方向列表
matched_directions = self._match_keywords(video_title)
# 2) 如果用户指定了 direction,优先使用
if direction and direction in DIRECTION_TO_CATEGORY:
if direction in matched_directions:
matched_directions.remove(direction)
matched_directions = [direction] + matched_directions
elif direction:
pass
# 2.5) 如果有人设且未指定 direction,把人设的 comment_style 优先排列
if persona and not direction:
styles = persona.get("comment_style", [])
# 把人设风格中在 matched_directions 里的提到最前面
for style in reversed(styles):
if style in matched_directions:
matched_directions.remove(style)
matched_directions = [style] + matched_directions
elif style in DIRECTION_TO_CATEGORY:
# 人设风格虽未匹配到关键词,也尝试作为候选方向
matched_directions = [style] + matched_directions
# 3) 有匹配的方向 → 尝试依次取对应分类的评论
if matched_directions:
for d in matched_directions:
cat = DIRECTION_TO_CATEGORY.get(d)
if not cat:
continue
kw = self._extract_first_keyword(video_title)
# 有关键词 → 可用模板(含 {keyword} 替换)
if kw:
comment = self._get_comment_from_categories([cat], allow_templates=True)
if comment:
return comment.replace("{keyword}", kw)
# 无关键词 → 只用成品评论(不含模板)
comment = self._get_comment_from_categories([cat], allow_templates=False)
if comment:
return comment
# 4) 没有语料匹配 → fallback: 尝试 AI 生成
ai = self._get_ai_generator()
if ai.available:
try:
# 同步上下文执行异步 AI 调用
comment = asyncio.run(
ai.generate_comment(
video_title=video_title,
direction=direction or (persona.get("comment_style", [""])[0] if persona else "称赞"),
persona=persona,
)
)
if comment:
log.info(" 🤖 AI 生成评论: %s", comment[:40])
return comment
except Exception as exc:
log.warning(" ⚠️ AI 生成失败: %s", exc)
# 5) 最后兜底 → 随机返回一条
comment = self._get_random_comment()
if comment and "{keyword}" in comment:
kw = self._extract_first_keyword(video_title)
if kw:
comment = comment.replace("{keyword}", kw)
return comment
@@ -0,0 +1,30 @@
{
"id": "daily_comment",
"name": "日常-评论激活",
"description": "日常评论:打开视频 → 观看 → 评论 → 关闭。每天2-3次保持账号活跃,弹验证自动处理",
"version": "1.1",
"platform": "douyin",
"steps": [
{"step_id": 1, "op": "goto_home", "args": {}, "pre": {}, "post": {}},
{"step_id": 2, "op": "scroll_feed", "args": {}, "pre": {}, "post": {}},
{"step_id": 3, "op": "open_video", "args": {}, "pre": {}, "post": {}},
{"step_id": 4, "op": "wait_watch", "args": {"seconds": null}, "pre": {}, "post": {}},
{"step_id": 5, "op": "like", "args": {}, "pre": {}, "post": {}},
{"step_id": 6, "op": "open_comments", "args": {}, "pre": {}, "post": {}},
{"step_id": 7, "op": "post_comment", "args": {"text": "@corpus"}, "pre": {}, "post": {}},
{"step_id": 8, "op": "auto_verify", "args": {}, "pre": {}, "post": {}},
{"step_id": 9, "op": "close_comments", "args": {}, "pre": {}, "post": {}},
{"step_id": 10, "op": "scroll_feed", "args": {}, "pre": {}, "post": {}},
{"step_id": 11, "op": "open_video", "args": {}, "pre": {}, "post": {}},
{"step_id": 12, "op": "wait_watch", "args": {"seconds": null}, "pre": {}, "post": {}},
{"step_id": 13, "op": "open_comments", "args": {}, "pre": {}, "post": {}},
{"step_id": 14, "op": "post_comment", "args": {"text": "@corpus"}, "pre": {}, "post": {}},
{"step_id": 15, "op": "auto_verify", "args": {}, "pre": {}, "post": {}},
{"step_id": 16, "op": "close_comments", "args": {}, "pre": {}, "post": {}}
],
"daily_limit": 3,
"min_interval_minutes": 30,
"args_schema": {
"corpus": {"type": "string", "desc": "语料分类"}
}
}
@@ -0,0 +1,40 @@
{
"id": "douyin_comment",
"name": "抖音-定向评论",
"description": "给视频链接 → 打开 → 观看 → 评论",
"version": "1.0",
"steps": [
{
"step_id": 1,
"op": "goto_url",
"args": {
"url": "@url"
}
},
{
"step_id": 2,
"op": "wait_watch",
"args": {
"seconds": null
}
},
{
"step_id": 3,
"op": "open_comments",
"args": {}
},
{
"step_id": 4,
"op": "post_comment",
"args": {
"text": "@comment_text"
}
},
{
"step_id": 5,
"op": "close_comments",
"args": {}
}
],
"platform": "douyin"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
{
"id": "douyin_reply",
"name": "抖音-作者回复",
"description": "打开自己视频 → 读评论 → 回复评论",
"version": "1.0",
"steps": [
{
"step_id": 1,
"op": "goto_home",
"args": {}
},
{
"step_id": 2,
"op": "goto_profile",
"args": {}
},
{
"step_id": 3,
"op": "read_my_comments",
"args": {}
},
{
"step_id": 4,
"op": "reply_comment",
"args": {
"text": "@reply_text"
}
},
{
"step_id": 5,
"op": "go_back",
"args": {}
}
],
"platform": "douyin"
}
@@ -0,0 +1,534 @@
"""
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"
for f in sorted(bp_dir.glob("*.json")):
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 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:
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)
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)}步)")
# 导航到该平台的首页
home_url = "https://www.xiaohongshu.com/explore" if platform == "xiaohongshu" else "https://www.douyin.com/"
try:
await conn.page.goto(home_url, timeout=30000, wait_until="domcontentloaded")
await asyncio.sleep(5)
except:
await conn.page.goto(home_url, timeout=60000, wait_until="load")
await asyncio.sleep(5)
# ── 钩子1: 登录状态检测(执行前确保登录)──
from matrix_modules.account.login_state_machine import LoginStateMachine
lsm = LoginStateMachine()
login_ok = await lsm.ensure_login(conn.page, account_id, platform)
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 == "sms":
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 -f 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 恢复链处理
if check_cookie(identity_dir) in ("no_cookie", "expired", "error"):
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)
sem = asyncio.Semaphore(self.max_browsers)
all_reports = []
async def _run_one_group(ident, accts):
async with sem:
log.info(f"\n 🖥️ 启动身份组 [{ident}]")
return await self._run_identity_group(accts)
group_list = list(groups.items())
tasks = [asyncio.create_task(_run_one_group(ident, accts))
for ident, accts in group_list]
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,298 @@
"""
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": "在热评下回复提升曝光",
},
}
# ── 三级接力语料 ─────────────────────────────────────────
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": "抱抱,都会好起来的"},
],
}
def _get_chain_texts(corpus: str = "") -> dict:
"""获取一组三级接力文本"""
pool = CHAIN_TEMPLATES.get(corpus, 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()
codes = _alloc_codes(2) # 分配两个识别码
texts = _get_chain_texts(self.corpus)
min_gap, max_gap = self.interval["min"], self.interval["max"]
for i, acct in enumerate(self.accounts[:3]): # 最多3个
position = ["first", "reply", "second_reply"][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,18 @@
{
"id": "interact_comment",
"name": "互动-定向评论",
"description": "打开视频链接 → 等待加载 → 定位评论区 → 输入评论 → 提交",
"version": "1.3",
"platform": "douyin",
"steps": [
{"step_id": 1, "op": "goto_url", "args": {"url": "@url"}, "pre": {}, "post": {}},
{"step_id": 2, "op": "wait_watch", "args": {"seconds": 3}, "pre": {}, "post": {}},
{"step_id": 3, "op": "open_comments","args": {}, "pre": {}, "post": {}},
{"step_id": 4, "op": "post_comment", "args": {"text": "@corpus"},"pre": {}, "post": {}},
{"step_id": 5, "op": "auto_verify", "args": {}, "pre": {}, "post": {}}
],
"args_schema": {
"url": {"type": "string", "required": true, "desc": "目标视频链接"},
"corpus": {"type": "string", "desc": "语料分类"}
}
}