Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/services/mediacrawler_adapter.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

458 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.
"""
mediacrawler_adapter.py — 抖音视频数据采集器 v3
全新架构:不再依赖 CDP/Playwright/浏览器页面。
直接从 Chrome profile 读取 cookie,通过 HTTP 请求调用抖音 API。
全程无窗口、无标签页、无闪烁。
"""
import asyncio, json, logging, os, re, time, sqlite3, urllib.request, urllib.error
from pathlib import Path
from typing import Optional
logger = logging.getLogger("dashboard.mediacrawler_adapter")
# HTTP 请求头(模拟浏览器)
DOUYIN_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/150.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "https://www.douyin.com/",
"Origin": "https://www.douyin.com",
}
# ── Playwright CDP 连接管理(复用,避免反复建连) ──
# Chrome 新版把 cookie 值加密存在 SQLite 里,必须通过 CDP 读解密后的值。
_PW = None
_BROWSER = None
async def _ensure_cdp():
"""确保 Playwright CDP 连接可用(全局复用)"""
global _PW, _BROWSER
if _BROWSER and _PW:
try:
if _BROWSER.is_connected():
return _BROWSER.contexts[0] if _BROWSER.contexts else None
except:
pass
# 断连了,重建
try:
await _BROWSER.close()
except:
pass
try:
await _PW.stop()
except:
pass
_BROWSER = None
_PW = None
try:
from playwright.async_api import async_playwright
_PW = await async_playwright().start()
_BROWSER = await _PW.chromium.connect_over_cdp("http://127.0.0.1:9222")
ctx = _BROWSER.contexts[0] if _BROWSER.contexts else None
return ctx
except Exception as e:
_BROWSER = None
_PW = None
logger.warning(f"CDP 连接失败: {e}")
return None
async def _get_cookies() -> dict:
"""从 CDP 连接读取 Chrome cookie(解密后的值)"""
ctx = await _ensure_cdp()
if not ctx:
return {}
try:
all_cookies = await ctx.cookies()
result = {}
for c in all_cookies:
domain = c.get("domain", "")
if "douyin" in domain or "amemv" in domain:
result[c["name"]] = c["value"]
return result
except Exception as e:
logger.warning(f"读 cookie 失败: {e}")
return {}
def _cookie_str(cookies: dict) -> str:
"""将 cookie 字典转为 HTTP Header 字符串"""
return "; ".join(f"{k}={v}" for k, v in cookies.items())
# ── HTTP API 请求(不打开任何浏览器页面) ──
def _http_get(url: str, cookies: dict, timeout: int = 15) -> dict:
"""发起 HTTP GET 请求并返回 JSON(cookies 必传,由调用方从 CDP 获取)"""
req = urllib.request.Request(
url,
headers={
**DOUYIN_HEADERS,
"Cookie": _cookie_str(cookies),
},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
# ── 工具函数 ──
def _extract_aweme_id(url: str):
m = re.search(r'douyin\.com/video/(\d+)', url)
if m:
return m.group(1)
m = re.search(r'iesdouyin\.com/share/video/(\d+)', url)
if m:
return m.group(1)
# 抖音精选页 / 发现页:modal_id 就是视频 aweme_id(jingxuan?modal_id=xxx)
m = re.search(r'(?:jingxuan|discover|modal_id)[=?/](\d+)', url)
if m:
return m.group(1)
return None
def _resolve_shortlink(url: str):
import subprocess
try:
result = subprocess.run(
["curl", "-sI", url], capture_output=True, text=True, timeout=10
)
for line in result.stdout.split("\n"):
if line.lower().startswith("location:"):
loc = line.split(":", 1)[1].strip()
m = re.search(r'/video/(\d+)', loc)
if m:
return m.group(1)
except Exception:
pass
return None
# ── 核心:获取视频数据(纯 HTTP,无浏览器) ──
async def get_video_data(url: str) -> dict:
"""
统一入口:传入抖音视频 URL,返回完整数据
通过 HTTP API 获取,不打开任何浏览器页面
"""
aweme_id = _extract_aweme_id(url)
if not aweme_id:
aweme_id = _resolve_shortlink(url)
if not aweme_id:
return {"error": f"无法解析视频 ID: {url}"}
t0 = time.time()
result = {
"aweme_id": aweme_id,
"title": "",
"author": "",
"likes": 0,
"comments": 0,
"collects": 0,
"shares": 0,
"comment_texts": [],
"collected_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
# 读取登录态 cookie(通过 CDP,解密后的值)
cookies = await _get_cookies()
has_session = bool(cookies.get("sessionid"))
if not has_session:
result["error"] = "抖音登录已过期,请点击顶部「📱 打开抖音登录」按钮重新登录"
result["login_expired"] = True
return result
# 调抖音 API 获取视频详情
try:
api_url = (
f"https://www.douyin.com/aweme/v1/web/aweme/detail/"
f"?aweme_id={aweme_id}&version_code=170400&app_name=aweme&device_platform=web"
)
detail = await asyncio.to_thread(_http_get, api_url, cookies)
except urllib.error.HTTPError as e:
result["error"] = f"HTTP {e.code}: {e.reason}"
return result
except Exception as e:
result["error"] = f"请求失败: {e}"
return result
aweme = detail.get("aweme_detail", {})
if not aweme:
if detail.get("status_code") == 2:
result["error"] = "抖音登录已过期,请重新登录"
result["login_expired"] = True
return result
if detail.get("filter_detail"):
# filter_detail = 视频被平台过滤(已删除/违规/推广限制),接口层详情不可查
result["filtered"] = True
# 兜底策略:接口被过滤,改用页面浏览方式(打开网址读页面标题/meta),补全标题/博主
fb = await _page_fallback(url, aweme_id)
if fb:
result.update({
"title": fb.get("title", ""),
"author": fb.get("author", ""),
"published_at": fb.get("published_at", ""),
"source": "page_fallback",
"api_error": "⚠️ 视频被平台过滤(接口层),已用页面兜底补全标题/博主",
})
return result
result["error"] = "⚠️ 视频被平台过滤,无法采集(可能已删除/违规/推广限制)"
return result
result["error"] = f"API 未返回数据: {str(detail)[:200]}"
return result
statistics = aweme.get("statistics", {})
author_info = aweme.get("author", {})
result.update({
"title": aweme.get("desc", ""),
"author": author_info.get("nickname", ""),
"author_uid": author_info.get("uid", ""), # 数字ID(博主精确身份)
"author_sec_uid": author_info.get("sec_uid", ""), # 加密ID
"author_unique_id": author_info.get("unique_id", ""), # 抖音号
"likes": int(statistics.get("digg_count", 0)),
"comments": int(statistics.get("comment_count", 0)),
"collects": int(statistics.get("collect_count", 0)),
"shares": int(statistics.get("share_count", 0)),
})
# 获取热评(复用已有 Chrome 页面,不创建新标签页)
# Chrome 已有 douyin.com 页面,直接用它的 JS 上下文执行 fetch
# ⚠️ 不要 new_page() — 那会在 Chrome 中闪出新标签页
try:
_ctx2 = await _ensure_cdp()
if _ctx2:
_pages = _ctx2.pages
if _pages:
_page = _pages[0] # 复用已有页面(about:blank 或 douyin.com)
_cmt_json = await _page.evaluate(f"""async () => {{
try {{
const r = await fetch(
'https://www.douyin.com/aweme/v1/web/comment/list/?aweme_id={aweme_id}&count=20&cursor=0',
{{ headers: {{ 'Accept': 'application/json, text/plain, */*', 'Referer': 'https://www.douyin.com/' }} }}
);
return JSON.stringify(await r.json());
}} catch(e) {{ return JSON.stringify({{error: e.message}}); }}
}}""")
_cmt_data = json.loads(_cmt_json)
if "error" not in _cmt_data:
for c in _cmt_data.get("comments", [])[:20]:
user = c.get("user", {})
result["comment_texts"].append({
"nickname": user.get("nickname", ""),
"text": c.get("text", ""),
"likes": c.get("digg_count", 0),
})
except Exception as e:
logger.debug(f"评论获取失败: {e}")
result["_method"] = "http_api"
result["_duration"] = round(time.time() - t0, 2)
return result
async def _page_fallback(url: str, aweme_id: str) -> Optional[dict]:
"""兜底策略:接口被平台过滤(filter_detail)时,用 Chrome 打开页面(网址层面浏览),
读页面标题 + meta description 补全标题/博主/发布时间。
返回 {title, author, published_at, source:'page_fallback'} 或 None(CDP 不可用/超时/解析失败)
"""
try:
ctx = await _ensure_cdp()
if not ctx or not ctx.pages:
return None
page = ctx.pages[0] # 复用现有页面,不新建标签页
try:
await page.goto(url, timeout=20000, wait_until="domcontentloaded")
except Exception:
await asyncio.sleep(2)
# 轮询等待页面加载出标题/meta(最多 12s,抖音页面加载/重定向较慢)
title, desc, author, published = "", "", "", ""
for _ in range(12):
try:
raw = await page.evaluate(
"""() => {
const d = { title: document.title || '', desc: '' };
const md = document.querySelector('meta[name="description"]');
if (md) d.desc = md.getAttribute('content') || '';
return JSON.stringify(d);
}"""
)
data = json.loads(raw)
title = (data.get("title") or "").strip()
desc = (data.get("desc") or "").strip()
if title or desc:
break
except Exception:
pass
await asyncio.sleep(1)
m = re.search(r"[-—]\s*([^\s于]+)于(\d{8})发布", desc)
if m:
author, published = m.group(1), m.group(2)
# 标题兜底:desc 去掉「- 作者于时间发布」后缀
if not title and desc:
title = re.sub(r"\s*[-—]\s*[^\s于]+于\d{8}发布.*$", "", desc).strip()
return {
"title": title,
"author": author,
"published_at": published,
"source": "page_fallback",
}
except Exception as e:
logger.debug(f"页面兜底失败: {e}")
return None
# ── 博主采集(抖音博主监控) ──
# ⚠️ 注意:profile/other 和 aweme/post 接口要用「数字 uid」,
# 不能用 sec_user_id(会报 UserId不合法)。
# 数字 uid 从视频详情 API 的 author.uid 字段获取。
async def get_author_profile(uid: str) -> dict:
"""获取博主主页信息(粉丝数/获赞数/作品数/昵称/签名)
Args:
uid: 博主数字 ID(从视频详情的 author.uid 获取)
Returns:
{"uid", "nickname", "unique_id", "fans", "total_favorited",
"works", "following", "signature", "avatar", "sec_uid", ...}
失败时含 "error" 字段
"""
result = {"uid": str(uid)}
cookies = await _get_cookies()
has_session = bool(cookies.get("sessionid"))
if not has_session:
result["error"] = "抖音登录已过期"
result["login_expired"] = True
return result
try:
api_url = (
f"https://www.douyin.com/aweme/v1/web/user/profile/other/"
f"?user_id={uid}&device_platform=webapp&aid=6383"
)
data = await asyncio.to_thread(_http_get, api_url, cookies)
except urllib.error.HTTPError as e:
result["error"] = f"HTTP {e.code}: {e.reason}"
return result
except Exception as e:
result["error"] = f"请求失败: {e}"
return result
user = data.get("user", {})
if not user:
result["error"] = f"未返回用户数据: {data.get('status_msg', '')} (code={data.get('status_code')})"
return result
result.update({
"nickname": user.get("nickname", ""),
"unique_id": user.get("unique_id", ""),
"sec_uid": user.get("sec_uid", ""),
"fans": user.get("follower_count", 0),
"total_favorited": user.get("total_favorited", 0),
"works": user.get("aweme_count", 0),
"following": user.get("following_count", 0),
"signature": user.get("signature", ""),
"avatar": (user.get("avatar_thumb") or {}).get("url_list", [""])[0] if user.get("avatar_thumb") else "",
"collected_at": time.strftime("%Y-%m-%d %H:%M:%S"),
})
return result
async def get_author_videos(uid: str, count: int = 20) -> dict:
"""获取博主最新发布的视频列表(按发布时间倒序)
Args:
uid: 博主数字 ID
count: 拉取条数(默认20)
Returns:
{"uid", "videos": [{aweme_id, title, likes, comments, collects,
shares, create_time, url}], "has_more"}
"""
result = {"uid": str(uid), "videos": [], "has_more": False}
cookies = await _get_cookies()
has_session = bool(cookies.get("sessionid"))
if not has_session:
result["error"] = "抖音登录已过期"
result["login_expired"] = True
return result
try:
api_url = (
f"https://www.douyin.com/aweme/v1/web/aweme/post/"
f"?user_id={uid}&count={count}&max_cursor=0&device_platform=webapp&aid=6383"
)
data = await asyncio.to_thread(_http_get, api_url, cookies)
except urllib.error.HTTPError as e:
result["error"] = f"HTTP {e.code}: {e.reason}"
return result
except Exception as e:
result["error"] = f"请求失败: {e}"
return result
result["has_more"] = bool(data.get("has_more"))
for a in data.get("aweme_list", [])[:count]:
st = a.get("statistics", {})
result["videos"].append({
"aweme_id": str(a.get("aweme_id", "")),
"title": a.get("desc", ""),
"likes": int(st.get("digg_count", 0)),
"comments": int(st.get("comment_count", 0)),
"collects": int(st.get("collect_count", 0)),
"shares": int(st.get("share_count", 0)),
"create_time": a.get("create_time", 0),
"url": f"https://www.douyin.com/video/{a.get('aweme_id', '')}",
})
return result
# ── 登录状态检测(从 cookie 文件直接读取,无窗口操作) ──
async def check_login_status() -> dict:
"""检测抖音登录状态(通过 CDP,无页面操作)"""
cookies = await _get_cookies()
has_session = bool(cookies.get("sessionid"))
return {
"logged_in": has_session,
"cookie_sessionid": has_session,
"cookies_count": len(cookies),
}
# ── 打开登录页(用 AppleScript 控制 Chrome,不需要 CDP) ──
async def open_login_page():
"""在 Chrome 中打开抖音首页,让用户登录
使用 macOS 的 open 命令(比 AppleScript 更可靠,不依赖辅助功能权限)。
登录后 cookie 自动保存到 Chrome profile,两个 Chrome 都会检测。
"""
import subprocess as _sp
try:
# 先用 open 命令让 Chrome 激活并打开抖音首页
# open -a 会激活已有的 Chrome 窗口(无论是日常还是采集)
_sp.run(
["open", "-a", "Google Chrome", "https://www.douyin.com/"],
capture_output=True, timeout=10
)
logger.info("已请求 Chrome 打开抖音首页")
return {
"success": True,
"message": "已打开抖音首页,请在 Chrome 中点击右上角「登录」扫码",
}
except Exception as e:
logger.error(f"打开抖音首页失败: {e}")
return {"success": False, "error": f"打开失败: {e}"}