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,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": "语料分类"}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user