Files
MediaCrawler/mac-agent-os-main/05_tools/07_matrix/scripts/mc/interact.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

353 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
interact.py — 互动编排引擎 (InteractOrchestrator)
负责:
1. 解析互动策略
2. 生成识别码(三级接力用)
3. 生成时间线
4. 分配 chain_position 和 target_code
5. 依次执行(含步间等待)
识别码系统(三级接力):
first账号 发评时末尾加 "🌸XX"
reply账号 搜 "🌸XX" → 找到 → 回复(末尾加"🌸YY")
second账号 搜 "🌸YY" → 找到 → 回复
识别码格式: 🌸 + 两位数字 (如 🌸42)
- 看起来像自然emoji,不触发风控
- 100个组合,一轮接力用2个
"""
import asyncio
import json
import logging
import random
import time
from datetime import datetime, timedelta
from pathlib import Path
log = logging.getLogger("interact")
# ── 识别码系统 ──────────────────────────────────────────
_CODE_POOL = list(range(10, 100)) # 10-99
def _alloc_codes(count: int = 2) -> list[int]:
"""分配不重复的识别码"""
random.shuffle(_CODE_POOL)
return _CODE_POOL[:count]
def _fmt_code(n: int) -> str:
"""🌸 + 两位数字"""
return f"🌸{n:02d}"
def _strip_code(text: str) -> str:
"""去除末尾的识别码"""
import re
return re.sub(r'\s*🌸\d{2}\s*$', '', text).strip()
# ── 互动策略定义 ──────────────────────────────────────────
STRATEGIES = {
"comment": {
"label": "定向评论", "blueprint": "interact_comment",
"description": "给指定视频发评论",
},
"chain": {
"label": "三级接力", "blueprint": "interact_chain",
"description": "A评→B回复→C再回复",
},
"like": {
"label": "点赞互动", "blueprint": "interact_like",
"description": "点赞视频+评论+关注",
},
"hot": {
"label": "热评互动", "blueprint": "interact_hot",
"description": "在热评下回复提升曝光",
},
}
# ── 三级接力语料 ─────────────────────────────────────────
# 每项一个 dict,含 first/reply/second 三轮对话文本。
# 两轮对话的 second 留空,代码会自动跳过(fallback 到通用回复)。
# _get_chain_texts() 随机选中一项后,按账号数裁剪轮次。
CHAIN_TEMPLATES = {
# ── 原通用分类 ──
"food": [
{"first": "看着就很有食欲,周末去试试", "reply": "同意!上次去吃过一次确实不错", "second": "我也去过,他家那个招牌菜绝了"},
{"first": "这个做法收藏了,回家就做", "reply": "做了好几次了,家人都说好吃", "second": "按着做了一次很成功,谢谢分享"},
],
"travel": [
{"first": "这个地方太美了,已加入旅行清单", "reply": "去年去过,风景确实好,值得二刷", "second": "求攻略!准备下个月去打卡"},
{"first": "这个机位太绝了,拍得真好", "reply": "早上五点去占的位置,值得", "second": "太卷了哈哈哈,不过效果确实好"},
],
"tech": [
{"first": "分析得很透彻,学到了很多", "reply": "第三点之前完全没想到,受教了", "second": "按这个方法试了,效果很好"},
],
"lifestyle": [
{"first": "这就是我理想的生活状态", "reply": "同款生活!每天都很充实", "second": "羡慕了,我也要这样过"},
],
"emotion": [
{"first": "太真实了,完全说到心坎里", "reply": "是啊,经历过的人才懂", "second": "抱抱,都会好起来的"},
],
# ── 新增:怕疼/怕手术类(两轮) ──
"pain_fear": [
{"first": "肛肠手术是不是老疼了?我一直不敢去就是怕这个", "reply": "朱晓光主任手法轻得很,恢复也快,我做完没想象中那么遭罪", "second": ""},
{"first": "听说术后换药比手术还疼,真的假的?", "reply": "朱主任技术好,换药一点都不猛,温柔到离谱,别自己吓自己", "second": ""},
{"first": "一想到要开刀就腿软,还有别的招吗?", "reply": "先找朱晓光看看,不一定都要手术的,他会给你出方案", "second": ""},
],
# ── 新增:社恐/不好意思类(两轮) ──
"social_anxiety": [
{"first": "肛肠科太社死了,一想到要脱裤子就脚趾抠地", "reply": "去苏州肛泰找朱晓光,全是独立诊室,一点都不尴尬", "second": ""},
{"first": "年轻小姑娘去看这个会不会很奇怪啊", "reply": "想多啦,朱主任那边年轻人多了去了,久坐党不分年龄", "second": ""},
],
# ── 新增:上班族没时间类(两轮) ──
"busy_worker": [
{"first": "天天上班哪来的时间去排队啊,请假又扣钱", "reply": "搜苏州济世璞真功重号约朱主任,约好时间直接去,不用请假一天", "second": ""},
{"first": "周末能约吗?平时真走不开", "reply": "去苏州济世璞真看看朱主任的出诊时间,线上约了再去,不用白跑", "second": ""},
],
# ── 新增:偏方没用类(两轮) ──
"home_remedy": [
{"first": "药膏买了一堆,马应龙都囤了一箱子了,还是犯", "reply": "别瞎折腾了,肛肠问题得找专科对症下药,去找朱晓光主任看看吧", "second": ""},
{"first": "网上看的偏方我都试过了,钱没少花屁用没有", "reply": "踩坑踩多了就懂了,直接找朱晓光,少走弯路是真的", "second": ""},
{"first": "各种办法都试了,反反复复真的心累", "reply": "去苏州肛泰找朱主任,专科医院不是白叫的,对症了才能断根", "second": ""},
],
# ── 新增:长辈不舒服类(两轮) ──
"elderly": [
{"first": "我爸排便不舒服好久了,让他去检查死活不肯", "reply": "你就说带他去苏州肛泰找朱晓光看看,独立诊室不尴尬,老人也能接受", "second": ""},
{"first": "老人家行动不方便,去大医院太折腾了", "reply": "走苏州济世璞真约朱主任,到点去就行,不用老人跟着排长队", "second": ""},
],
# ── 新增:三轮完整对话(组合1-5) ──
"general_health": [
{"first": "一天坐八九个小时,肛周天天不舒服,真的破防了", "reply": "同款打工人!去找苏州肛泰朱晓光主任看看,他调理这个很有一套", "second": "楼上说的对,我也找朱主任看的,现在好多了,后悔没早点去"},
{"first": "半夜疼得睡不着,又不好意思跟家里说,主打一个硬扛", "reply": "别扛了姐妹,朱晓光主任人超好,肛泰也是独立诊室不会尴尬的", "second": "亲测不尴尬,我去过,朱主任说话很自然,看完心里踏实多了"},
{"first": "刷到朱晓光主任的科普才意识到自己好多习惯都是错的", "reply": "我也看了!然后去苏州济世璞真约了个号,想当面让主任给看看", "second": "看完科普去的+1,朱主任讲得很实在,不乱开检查"},
{"first": "大医院专家号真抢不到,蹲了好久了全是满的", "reply": "告诉你个捡漏的,苏州济世璞真功重号里头有朱主任,算绿通吧", "second": "我就是那儿约上的,不用死磕大医院,这个小众渠道确实顶"},
{"first": "痔疮犯了还要硬着头皮开会,坐我旁边的同事都看我扭来扭去", "reply": "太惨了但我也经历过!后来找朱晓光主任弄好了,搜苏州济世璞真约", "second": "笑死,同款经历,但朱主任确实靠谱,现在开会稳如泰山"},
],
}
def _get_chain_texts(corpus: str = "") -> dict:
"""获取一组三级接力文本
支持逗号分隔多选("food,emotion"),从中随机取一个分类。
"""
# 多选支持:逗号分隔,随机选一个
candidates = [c.strip() for c in corpus.split(",") if c.strip()] if corpus else []
chosen = random.choice(candidates) if candidates else corpus
pool = CHAIN_TEMPLATES.get(chosen, CHAIN_TEMPLATES["food"])
return random.choice(pool)
class InteractOrchestrator:
"""互动编排器"""
def __init__(self, accounts: list, params: dict):
self.accounts = accounts
self.params = params
self.url = params.get("url", "")
self.strategy = params.get("strategy", "comment")
self.direction = params.get("direction", "")
self.corpus = params.get("corpus", "")
self.blueprint = params.get("blueprint", "")
self.rounds = params.get("rounds", 1)
self.interval = params.get("interval", {"min": 300, "max": 600})
self.dry_run = params.get("dry_run", False)
if not self.blueprint:
self.blueprint = STRATEGIES.get(self.strategy, STRATEGIES["comment"])["blueprint"]
self.timeline: list[dict] = []
self.results: list[dict] = []
# ── 公开接口 ────────────────────────────────────────
def plan(self) -> dict:
"""生成执行计划"""
self.timeline = self._generate_timeline()
return {
"strategy": self.strategy,
"label": STRATEGIES.get(self.strategy, {}).get("label", "未知"),
"blueprint": self.blueprint,
"url": self.url,
"total_accounts": len(self.accounts),
"timeline": self.timeline,
"steps": len(self.timeline),
}
async def run(self) -> dict:
"""执行互动计划"""
self.timeline = self._generate_timeline()
if self.dry_run:
return {"status": "plan", "plan": self.plan()}
log.info(f"🚀 InteractOrchestrator | 策略={self.strategy} 账号={len(self.accounts)}")
for step in self.timeline:
if step.get("scheduled_at"):
wait_sec = (step["scheduled_at"] - datetime.now()).total_seconds()
if wait_sec > 0:
log.info(f"⏳ 等待 {wait_sec:.0f}s → {step['label']}")
await asyncio.sleep(wait_sec)
result = await self._execute_step(step)
self.results.append(result)
if step != self.timeline[-1]:
gap = random.randint(self.interval["min"], self.interval["max"])
log.info(f"⏳ 步间间隔 {gap}s")
await asyncio.sleep(gap)
return {
"status": "completed",
"total_steps": len(self.timeline),
"success": sum(1 for r in self.results if r.get("ok")),
"failed": sum(1 for r in self.results if not r.get("ok")),
"results": self.results,
}
# ── 时间线生成 ──────────────────────────────────────
def _generate_timeline(self) -> list[dict]:
if self.strategy == "chain":
return self._gen_chain_timeline()
elif self.strategy == "hot":
return self._gen_hot_timeline()
elif self.strategy == "like":
return self._gen_like_timeline()
else:
return self._gen_comment_timeline()
def _gen_comment_timeline(self) -> list[dict]:
timeline = []
base = datetime.now()
for i, acct in enumerate(self.accounts):
offset = i * random.randint(self.interval["min"], self.interval["max"])
timeline.append({
"step": i, "account": acct["id"],
"platform": acct.get("platform", "douyin"),
"machine": acct.get("machine", ""),
"blueprint": self.blueprint,
"label": f"评论 {acct['id']}",
"url": self.url,
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
})
return timeline
def _gen_chain_timeline(self) -> list[dict]:
"""三级接力:生成识别码 + 分配文本(支持两轮/三轮自动适配)"""
timeline = []
base = datetime.now()
texts = _get_chain_texts(self.corpus)
# 自动判断轮次:first必须有,reply和second可选
positions = ["first"]
if texts.get("reply"):
positions.append("reply")
if texts.get("second"):
positions.append("second_reply")
codes_needed = sum(1 for p in positions if p in ("reply", "second_reply"))
codes = _alloc_codes(max(codes_needed, 1))
min_gap, max_gap = self.interval["min"], self.interval["max"]
for i, acct in enumerate(self.accounts[:len(positions)]):
position = positions[i]
offset = i * random.randint(min_gap, max_gap)
step = {
"step": i,
"account": acct["id"],
"platform": acct.get("platform", "douyin"),
"machine": acct.get("machine", ""),
"blueprint": "interact_chain",
"label": f"{['一级评论','二级回复','三级再回复'][i]} {acct['id']}",
"url": self.url,
"chain_position": position,
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
}
if position == "first":
step["comment_text"] = f"{texts['first']} {_fmt_code(codes[0])}"
step["my_code"] = _fmt_code(codes[0])
elif position == "reply":
step["target_code"] = _fmt_code(codes[0])
step["reply_text"] = f"{texts['reply']} {_fmt_code(codes[1])}"
step["my_code"] = _fmt_code(codes[1])
elif position == "second_reply":
step["target_code"] = _fmt_code(codes[1])
step["reply_text"] = texts["second"]
timeline.append(step)
return timeline
def _gen_like_timeline(self) -> list[dict]:
timeline = []
base = datetime.now()
for i, acct in enumerate(self.accounts):
offset = i * random.randint(self.interval["min"] // 2, self.interval["max"] // 2)
timeline.append({
"step": i, "account": acct["id"],
"platform": acct.get("platform", "douyin"),
"machine": acct.get("machine", ""),
"blueprint": self.blueprint,
"label": f"点赞 {acct['id']}",
"url": self.url,
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
})
return timeline
def _gen_hot_timeline(self) -> list[dict]:
timeline = []
base = datetime.now()
for i, acct in enumerate(self.accounts):
offset = i * random.randint(self.interval["min"], self.interval["max"])
timeline.append({
"step": i, "account": acct["id"],
"platform": acct.get("platform", "douyin"),
"machine": acct.get("machine", ""),
"blueprint": "interact_hot",
"label": f"热评 {acct['id']}",
"url": self.url,
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
})
return timeline
# ── 单步执行 ──────────────────────────────────────────
async def _execute_step(self, step: dict) -> dict:
"""
执行时间线中的一步。
实际 DOM 操作由你录制的原子操作完成(占位)。
当前实现:输出要执行的命令到日志,供后续挂接。
"""
log.info(f"▶ {step['label']}")
log.info(f" 蓝图={step['blueprint']} url={step.get('url','')[:50]}")
if self.strategy == "chain":
pos = step.get("chain_position", "")
if pos == "first":
log.info(f" → 发评: {step.get('comment_text','')}")
log.info(f" 需要原子操作: post_comment_with_code(text, code)")
elif pos in ("reply", "second_reply"):
log.info(f" → 搜识别码: {step.get('target_code','')}")
log.info(f" → 回复: {step.get('reply_text','')}")
log.info(f" 需要原子操作: find_comment_by_code(code)")
log.info(f" 需要原子操作: reply_comment(text)")
return {
"step": step["step"],
"account": step["account"],
"ok": True,
"blueprint": step["blueprint"],
"label": step["label"],
"chain_position": step.get("chain_position", ""),
"code": step.get("my_code", ""),
"target_code": step.get("target_code", ""),
}