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,311 @@
#!/usr/bin/env python3
"""
日常养号调度器 v1.0
规则:
1. 最多 3 个浏览器并行,每个间隔 15 秒启动
2. 步骤顺序每次随机打乱
3. 每身份每平台约运行 10 分钟
4. 身份完成后休息 30 秒
5. 动作池: 浏览/搜索/点赞/收藏/评论/关注
用法:
python nurture_daily.py # 所有身份按规则开始养号
python nurture_daily.py --dry-run # 只看计划,不执行
python nurture_daily.py --phone 185xxx # 只养指定手机号
"""
import argparse, asyncio, json, random, subprocess, sys, time
from datetime import datetime
from pathlib import Path
TOOL_DIR = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = TOOL_DIR / "scripts"
BP_DIR = TOOL_DIR / "blueprints"
from matrix_mgmt import AGENT_LOCAL
LOCAL_ROOT = AGENT_LOCAL / "tools" / "matrix"
CONFIG_DIR = LOCAL_ROOT / "config"
sys.path.insert(0, str(SCRIPTS_DIR))
# ── 配置 ──
MAX_BROWSERS = 3 # 最大并发浏览器
STAGGER_SECS = 15 # 启动间隔
SESSION_MINS = 10 # 每身份每平台预计分钟
REST_SECS = 30 # 身份间休息
WATCH_MIN = 6 # 浏览最小时长(秒)
WATCH_MAX = 25 # 浏览最大时长(秒)
GAUSS_JITTER = 0.3 # 高斯抖动系数
# ── 搜索关键词池(每次随机选) ──
KEYWORDS_DOUYIN = [
"美食推荐", "旅行日记", "搞笑视频", "电影解说", "宠物日常",
"穿搭分享", "美妆教程", "健身打卡", "音乐现场", "游戏解说",
"科技数码", "汽车评测", "家居装修", "户外运动", "亲子日常",
"职场干货", "学习技巧", "手工制作", "摄影教程", "美食探店",
]
KEYWORDS_XHS = [
"穿搭推荐", "美妆教程", "旅行攻略", "美食打卡", "家居好物",
"护肤心得", "读书笔记", "运动打卡", "育儿经验", "摄影技巧",
]
def gaussian_sleep(base):
"""base 秒 ±30% 高斯抖动"""
delay = base * (1 + random.uniform(-GAUSS_JITTER, GAUSS_JITTER))
return max(2, delay)
def load_accounts():
"""从 registry + override 读取本机所有账号(跨机兼容)"""
from matrix_mgmt import MatrixManager
mgr = MatrixManager()
all_accts = mgr.list_accounts()
return [a for a in all_accts if a.get("is_local") and a.get("enabled", True)]
def load_blueprints():
"""加载所有蓝图"""
bps = {}
for f in sorted(BP_DIR.glob("*.json")):
try:
bp = json.loads(f.read_text())
name = bp.get("name", bp.get("id", f.stem))
platform = bp.get("platform", bp.get("type", "douyin"))
steps = bp.get("steps", [])
if steps:
bps[name] = {"name": name, "platform": platform, "steps": steps}
except: pass
return bps
def group_by_identity(accounts):
"""按手机号分组"""
groups = {}
for a in accounts:
phone = a.get("phone", "")
if not phone:
continue
if phone not in groups:
groups[phone] = {
"phone": phone,
"display_name": a.get("display_name", phone),
"accounts": [],
}
groups[phone]["accounts"].append(a)
return list(groups.values())
def shuffle_steps(steps, platform="douyin"):
"""随机打乱步骤顺序 + 关键词替换 + 时间抖动"""
shuffled = steps.copy()
random.shuffle(shuffled)
# 替换 @keyword 和 @corpus
keywords = KEYWORDS_DOUYIN if platform == "douyin" else KEYWORDS_XHS
for s in shuffled:
args = s.get("args", {})
for k, v in args.items():
if isinstance(v, str) and "@keyword" in v:
args[k] = random.choice(keywords)
elif isinstance(v, str) and "@corpus" in v:
# 评论语料
texts = [
"这个太棒了!", "学到了学到了", "收藏了~",
"好厉害!", "这是什么神仙", "太实用了吧",
"爱了爱了", "求教程!", "这个真的绝",
"已收藏慢慢看", "讲得太清楚了", "干货满满",
]
args[k] = random.choice(texts)
# 等待时长加抖动
if "seconds" in args and args["seconds"] and isinstance(args["seconds"], (int, float)):
args["seconds"] = round(gaussian_sleep(args["seconds"]), 1)
# 控制步数在 15-30 步之间(保证约10分钟)
target_steps = random.randint(15, 30)
if len(shuffled) > target_steps:
shuffled = shuffled[:target_steps]
return shuffled
def pick_blueprint(blueprints, platform):
"""为平台选一个合适的蓝图"""
candidates = [b for b in blueprints.values() if b["platform"] == platform]
if not candidates:
candidates = [b for b in blueprints.values()] # 不限平台
if not candidates:
return None
return random.choice(candidates)
def build_nurture_command(phone, display_name, account, blueprint_name, rounds=1):
"""构建养号命令 — 用 mc run(新引擎,跨机兼容)"""
mc_path = TOOL_DIR / "mc"
return [
"bash", str(mc_path), "run",
"--accounts", account["id"],
"--blueprints", blueprint_name,
"--rounds", str(rounds),
"--mix",
"--interval", "45-90",
]
async def run_nurture_session(identity, blueprints, session_id):
"""运行一个身份的养号"""
phone = identity["phone"]
name = identity["display_name"]
accounts = identity["accounts"]
results = []
print(f"\n{'='*55}")
print(f" 📱 [{name}]({phone}) 开始养号")
print(f" {'='*55}")
for acct in accounts:
platform = acct.get("platform", "douyin")
bp = pick_blueprint(blueprints, platform)
if not bp:
print(f" ⚠️ {platform} 无可用蓝图,跳过")
continue
# 选蓝图并随机打乱步骤
shuffled = shuffle_steps(bp["steps"], platform)
bp_name = bp["name"]
# 预估时长
est_secs = len(shuffled) * 20 + 30 # 每步~20s + 缓冲
est_min = round(est_secs / 60, 1)
print(f" 🎯 {acct['id']} ({platform})")
print(f" 蓝图: {bp_name} → 打乱后 {len(shuffled)} 步")
print(f" 预计: ~{est_min}分钟")
results.append({
"account": acct["id"],
"platform": platform,
"blueprint": bp_name,
"steps": len(shuffled),
"estimated_min": est_min,
})
# 实际执行(通过 yanghao_runner)
cmd = build_nurture_command(phone, name, acct, bp_name, rounds=random.randint(1, 3))
print(f" 执行: {' '.join(cmd[:6])}...")
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=900)
results[-1]["returncode"] = p.returncode
results[-1]["stdout_len"] = len(p.stdout)
except subprocess.TimeoutExpired:
print(f" ⏰ {acct['id']} 超时")
results[-1]["returncode"] = -1
except Exception as e:
print(f" ❌ {e}")
results[-1]["error"] = str(e)
return results
async def main():
parser = argparse.ArgumentParser(description="日常养号调度器")
parser.add_argument("--dry-run", action="store_true", help="只看计划,不执行")
parser.add_argument("--phone", default="", help="只养指定手机号")
args = parser.parse_args()
print(f"\n{'='*60}")
print(f" 📊 日常养号调度器 v1.0")
print(f" {'='*60}")
print(f" 规则:")
print(f" - 最多 {MAX_BROWSERS} 个浏览器并行")
print(f" - 每个间隔 {STAGGER_SECS} 秒启动")
print(f" - 步骤顺序随机打乱")
print(f" - 每身份约 {SESSION_MINS} 分钟")
if args.dry_run:
print(f" - 🔍 仅查看计划 (--dry-run)")
accounts = load_accounts()
blueprints = load_blueprints()
groups = group_by_identity(accounts)
if args.phone:
groups = [g for g in groups if g["phone"] == args.phone]
print(f"\n 📋 账号: {len(accounts)} | 蓝图: {len(blueprints)} | 身份: {len(groups)}")
if not groups:
print(" ❌ 没有匹配的身份")
return
# 排期表
print(f"\n{'='*60}")
print(f" 📅 排期表")
print(f" {'='*60}")
total_est_min = 0
for i, g in enumerate(groups):
batch = i // MAX_BROWSERS + 1
pos = i % MAX_BROWSERS
start_delay = pos * STAGGER_SECS + (batch - 1) * (SESSION_MINS * 60 + REST_SECS * MAX_BROWSERS)
start_min = round(start_delay / 60, 1)
plats = ", ".join(a["platform"] for a in g["accounts"])
bps = []
for a in g["accounts"]:
bp = pick_blueprint(blueprints, a["platform"])
bps.append(bp["name"] if bp else "无蓝图")
est = SESSION_MINS
total_est_min += est
print(f" 批次{batch}#{pos+1} | +{start_min}min | {g['display_name']:16s} | {plats:20s} | ~{est}min")
total_min = round(total_est_min / MAX_BROWSERS + (len(groups) / MAX_BROWSERS) * 0.5, 1)
print(f"\n 📊 预估总耗时: ~{total_min} 分钟 ({round(total_min/60,1)} 小时)")
print(f" 身份数: {len(groups)} | 批次: {(len(groups)+MAX_BROWSERS-1)//MAX_BROWSERS}")
if args.dry_run:
print(f"\n 🔍 这是计划预览,加 --dry-run 去掉就是实际执行")
return
# 实际执行
print(f"\n{'='*60}")
print(f" 🚀 开始执行 ({datetime.now().strftime('%H:%M')})")
print(f" {'='*60}")
all_results = []
batch_num = 0
for i in range(0, len(groups), MAX_BROWSERS):
batch = groups[i:i + MAX_BROWSERS]
batch_num += 1
print(f"\n--- 批次 {batch_num} ({len(batch)} 个身份) ---")
# 用 asyncio.gather 错开启动
async def launch_with_stagger(idx, g):
if idx > 0:
await asyncio.sleep(STAGGER_SECS * idx)
return await run_nurture_session(g, blueprints, f"b{batch_num}p{idx+1}")
tasks = [launch_with_stagger(idx, g) for idx, g in enumerate(batch)]
batch_results = await asyncio.gather(*tasks)
all_results.extend(batch_results)
# 批次间休息
if i + MAX_BROWSERS < len(groups):
print(f"\n ⏸️ 批次 {batch_num} 完成,休息 {REST_SECS}s...")
await asyncio.sleep(REST_SECS)
# 汇总
print(f"\n{'='*60}")
print(f" ✅ 全部完成! ({datetime.now().strftime('%H:%M')})")
print(f" {'='*60}")
ok = sum(1 for r in all_results for rr in r if rr.get("returncode") == 0)
total = sum(len(r) for r in all_results)
print(f" 成功: {ok}/{total}")
if __name__ == "__main__":
asyncio.run(main())