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