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,287 @@
"""
cookie_manager.py — Cookie 安全备份与恢复模块 v1.0
核心问题:XHS 和 Douyin 共享 identity_dir(douyin_01_camo = douyin_01 + xhs_01)
清 XHS cookie 时容易误删抖音 session。
解决方案:
1. 每次操作前自动全量备份 cookies.sqlite(带平台+时间戳标签)
2. 跨平台操作时自动保护对方平台 cookie(备份后再操作)
3. 支持按平台精准恢复(只恢复某个平台的 cookie 行)
文件结构:
backups/cookies/{identity_dir_name}/
cookies.{timestamp}.{platform}.bak ← 全量备份
cookies.{timestamp}.restore_point.bak ← 操作前检查点
使用:
from matrix_modules.utils.cookie_manager import CookieGuard
guard = CookieGuard("douyin_01_camo")
guard.backup(platform="douyin") # 备份当前状态
guard.protect("douyin") # 保护抖音(=备份后操作其他平台)
guard.restore(platform="douyin") # 恢复抖音 cookie
"""
import shutil
import os
import time
import sqlite3
from pathlib import Path
from typing import Optional, List
import glob
from matrix_mgmt import AGENT_LOCAL
LOCAL_ROOT = AGENT_LOCAL / "tools" / "matrix"
IDENTITIES_DIR = LOCAL_ROOT / "identities"
BACKUP_DIR = LOCAL_ROOT / "backups" / "cookies"
# 共享 identity_dir → 涉及的平台映射
SHARED_MAP = {
"douyin_01_camo": ["douyin", "xiaohongshu"],
"douyin_02_camo": ["douyin", "xiaohongshu"],
"douyin_camo01": ["douyin", "xiaohongshu"],
}
class CookieGuard:
"""Cookie 安全防护工具"""
def __init__(self, identity_dir_name: str):
"""
Args:
identity_dir_name: identity 目录名, 如 "douyin_01_camo"
"""
self.identity_name = identity_dir_name
self.user_data_dir = IDENTITIES_DIR / identity_dir_name / "user_data"
self.cookie_file = self.user_data_dir / "cookies.sqlite"
self.backup_subdir = BACKUP_DIR / identity_dir_name
self.backup_subdir.mkdir(parents=True, exist_ok=True)
# ──────────────── 公共 API ────────────────
def backup(self, platform: str = None, label: str = "") -> str:
"""
全量备份 cookies.sqlite
Args:
platform: 平台标签(douyin/xiaohongshu),用于标识
label: 额外标签(如 "pre_xhs_clear")
Returns:
备份文件路径,失败返回空字符串
"""
if not self.cookie_file.exists():
return ""
ts = time.strftime("%Y%m%d_%H%M%S")
parts = ["cookies", ts]
if platform:
parts.append(platform)
if label:
parts.append(label)
bak_name = ".".join(parts) + ".bak"
bak_path = self.backup_subdir / bak_name
try:
shutil.copy2(str(self.cookie_file), str(bak_path))
return str(bak_path)
except Exception as e:
return ""
def restore(self, backup_file: str = None, platform: str = None) -> bool:
"""
恢复 cookies.sqlite 到目标目录
Args:
backup_file: 指定备份文件路径;None 时自动找最新的
platform: 指定按平台恢复(仅恢复该平台的 cookie 行)
None 时全量恢复
Returns:
True=成功
"""
if not backup_file:
latest = self._find_latest_backup(platform)
if not latest:
return False
backup_file = str(latest)
src = Path(backup_file)
if not src.exists():
return False
if platform:
# ── 按平台精准恢复(只覆盖指定平台的 cookie 行) ──
return self._restore_platform_only(src, platform)
else:
# ── 全量恢复 ──
try:
# 先备份当前文件(万一恢复错了还能回退)
current_bak = self.backup(platform=platform, label="pre_restore")
# 复制备份覆盖
shutil.copy2(str(src), str(self.cookie_file))
return True
except Exception:
return False
def protect(self, platform_to_protect: str) -> str:
"""
保护指定平台的 cookie:操作其他平台前先调用
例如 protect("douyin") 会在清 XHS cookie 前,
先备份当前 cookies.sqlite(含抖音 session)。
Args:
platform_to_protect: 要保护的平台 ("douyin" 或 "xiaohongshu")
Returns:
备份文件路径
"""
return self.backup(platform=platform_to_protect, label="protected")
def delete_platform_cookies_safe(self, platform: str) -> bool:
"""
安全删除指定平台的 cookie(自动备份对方平台)
Args:
platform: 要删除的平台 ("douyin" 或 "xiaohongshu")
Returns:
bool: True=删除成功
"""
# 1. 先保护对方平台
other = self._get_other_platform(platform)
if other:
self.protect(other)
# 2. 再全量备份当前状态
self.backup(platform=platform, label="pre_delete")
# 3. 执行删除
return self._delete_platform_cookies(platform)
def list_backups(self, platform: str = None) -> List[str]:
"""列出可用备份"""
pattern = f"cookies.*{platform + '.' if platform else ''}*.bak"
files = sorted(self.backup_subdir.glob(pattern), reverse=True)
return [str(f) for f in files]
# ──────────────── 内部方法 ────────────────
def _find_latest_backup(self, platform: str = None):
"""找最新的备份文件"""
pattern = f"cookies.*{platform + '.' if platform else ''}*.bak"
files = sorted(self.backup_subdir.glob(pattern), reverse=True)
return files[0] if files else None
def _get_other_platform(self, platform: str) -> Optional[str]:
"""获取共享 identity 中的对方平台名"""
platforms = SHARED_MAP.get(self.identity_name, [])
for p in platforms:
if p != platform:
return p
return None
def _delete_platform_cookies(self, platform: str) -> bool:
"""通过 sqlite3 删除指定平台的 cookie"""
domain_patterns = {
"douyin": "%douyin.com%",
"xiaohongshu": "%xiaohongshu%",
}
pattern = domain_patterns.get(platform, f"%{platform}%")
if not self.cookie_file.exists():
return False
try:
conn = sqlite3.connect(str(self.cookie_file))
c = conn.cursor()
c.execute("DELETE FROM moz_cookies WHERE host LIKE ?", (pattern,))
deleted = c.rowcount
conn.commit()
conn.close()
# 删除 -wal 和 -shm 文件(Firefox 可能未正常关闭)
for ext in ["-wal", "-shm"]:
f = str(self.cookie_file) + ext
if os.path.exists(f):
os.remove(f)
return deleted > 0
except Exception:
return False
def _restore_platform_only(self, src: Path, platform: str) -> bool:
"""
精准恢复:从备份文件中提取指定平台的 cookie 行,
合并到当前 cookies.sqlite
流程:
1. 从备份文件读取指定平台的 cookie 行
2. 从当前文件删除该平台的旧 cookie 行
3. 将备份中的行插入当前文件
"""
domain_patterns = {
"douyin": "%douyin.com%",
"xiaohongshu": "%xiaohongshu%",
}
pattern = domain_patterns.get(platform, f"%{platform}%")
if not self.cookie_file.exists():
return False
try:
# 连接备份文件,读取目标平台的 cookie 行
src_conn = sqlite3.connect(str(src))
src_c = src_conn.cursor()
rows = src_c.execute(
"SELECT * FROM moz_cookies WHERE host LIKE ?", (pattern,)
).fetchall()
columns = [d[0] for d in src_c.description]
src_conn.close()
if not rows:
return False
# 连接当前文件,删除旧行后插入
dst_conn = sqlite3.connect(str(self.cookie_file))
dst_c = dst_conn.cursor()
# 删除该平台旧 cookie
dst_c.execute("DELETE FROM moz_cookies WHERE host LIKE ?", (pattern,))
# 构造 INSERT 语句
placeholders = ",".join(["?"] * len(columns))
col_names = ",".join(columns)
dst_c.executemany(
f"INSERT INTO moz_cookies ({col_names}) VALUES ({placeholders})",
rows
)
dst_conn.commit()
dst_conn.close()
# 清理 wal/shm
for ext in ["-wal", "-shm"]:
f = str(self.cookie_file) + ext
if os.path.exists(f):
os.remove(f)
return True
except Exception:
return False
# ──────────────── 简便函数(不用实例化) ────────────────
def backup_all_identities(platform: str = None, label: str = "") -> dict:
"""备份所有账号的 cookie"""
results = {}
for identity in SHARED_MAP:
guard = CookieGuard(identity)
path = guard.backup(platform=platform, label=label)
results[identity] = path
return results
def quick_backup(identity_dir_name: str, platform: str = None) -> str:
"""快速备份单个账号"""
guard = CookieGuard(identity_dir_name)
return guard.backup(platform=platform)