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 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
探测脚本 v2:检测抖音SMS登录被"必须使用App登录"拦截
|
||||
|
||||
v2 核心变化:不需要 SMS 验证码!
|
||||
因为账号被标记后,抖音可能根本不发短信。
|
||||
我们只要导航到登录页、填入手机号、点击获取验证码,
|
||||
观察页面是否立即出现"必须使用App"的提示。
|
||||
|
||||
用法:
|
||||
python3 detect_app_login.py --phone=15370108283
|
||||
|
||||
输出:
|
||||
/tmp/detect_login_result.json — 包含红色提示文字和截图
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_SCRIPT_DIR / "matrix_modules"))
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
|
||||
async def detect(phone: str, output_dir: str) -> dict:
|
||||
"""执行一次登录探测,不需要验证码"""
|
||||
result = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"phone": phone,
|
||||
"steps": [],
|
||||
"red_texts": [],
|
||||
"hint_texts": [],
|
||||
"near_login_texts": [],
|
||||
"page_url": "",
|
||||
"page_title": "",
|
||||
"screenshot_path": "",
|
||||
"full_text_snippet": "",
|
||||
"found_app_login": False,
|
||||
"app_keyword_context": [],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
from camoufox.async_api import AsyncCamoufox
|
||||
screenshot_path = os.path.join(output_dir, "login_detect.png")
|
||||
|
||||
try:
|
||||
fox = AsyncCamoufox(
|
||||
headless=False,
|
||||
persistent_context=False,
|
||||
locale="zh-CN",
|
||||
os="windows",
|
||||
humanize=1.5,
|
||||
)
|
||||
browser = await fox.start()
|
||||
if browser.contexts:
|
||||
context = browser.contexts[0]
|
||||
else:
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1280, "height": 800}
|
||||
)
|
||||
page = context.pages[0] if context.pages else await context.new_page()
|
||||
|
||||
# ── Step 1: 导航到登录页 ──
|
||||
log("导航到抖音登录页...")
|
||||
await page.goto(
|
||||
"https://www.douyin.com/passport/sso/login/",
|
||||
timeout=30000, wait_until="domcontentloaded"
|
||||
)
|
||||
await asyncio.sleep(4)
|
||||
result["steps"].append("navigate_login")
|
||||
result["page_url"] = page.url
|
||||
result["page_title"] = await page.title()
|
||||
|
||||
# ── Step 2: 找手机号输入框并填号 ──
|
||||
log("查找手机号输入框...")
|
||||
phone_filled = False
|
||||
for sel in [
|
||||
'input[placeholder*="手机"]',
|
||||
'input[type="tel"]',
|
||||
'input[name="mobile"]',
|
||||
]:
|
||||
try:
|
||||
el = page.locator(sel).first
|
||||
if await el.count() > 0 and await el.is_visible():
|
||||
await el.click()
|
||||
await asyncio.sleep(0.3)
|
||||
await el.fill(phone)
|
||||
phone_filled = True
|
||||
log(f"已填入手机号: {phone}")
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not phone_filled:
|
||||
# JS 兜底
|
||||
try:
|
||||
await page.evaluate(f"""() => {{
|
||||
const inputs = document.querySelectorAll('input');
|
||||
for (const inp of inputs) {{
|
||||
if (inp.offsetParent !== null && inp.value === '') {{
|
||||
inp.focus();
|
||||
inp.value = '{phone}';
|
||||
inp.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}}""")
|
||||
await asyncio.sleep(1)
|
||||
phone_filled = True
|
||||
log("JS 填手机号成功")
|
||||
except Exception as e:
|
||||
result["error"] = f"无法填手机号: {e}"
|
||||
await browser.close()
|
||||
return result
|
||||
|
||||
result["steps"].append("filled_phone")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# ── Step 3: 截图(填写手机号后的页面状态)──
|
||||
try:
|
||||
await page.screenshot(path=screenshot_path, full_page=False)
|
||||
result["screenshot_path"] = screenshot_path
|
||||
except:
|
||||
pass
|
||||
|
||||
# ── Step 4: 捕捉"点击获取验证码"前的页面状态 ──
|
||||
log("捕捉页面上现有文本...")
|
||||
try:
|
||||
full_text = await page.evaluate("document.body.innerText") or ""
|
||||
result["full_text_snippet"] = full_text[:2000]
|
||||
# 搜索 App 相关关键词
|
||||
for kw in ["App", "app", "APP", "应用", "抖音App", "客户端", "扫一扫",
|
||||
"请使用", "请打开", "打开抖音", "扫码"]:
|
||||
idx = full_text.find(kw)
|
||||
if idx >= 0:
|
||||
nearby = full_text[max(0, idx-20):idx+80]
|
||||
result["app_keyword_context"].append(nearby)
|
||||
log(f"⚠️ 关键词 '{kw}': ...{nearby}...")
|
||||
except:
|
||||
pass
|
||||
|
||||
# ── Step 5: 点击获取验证码 ──
|
||||
log("点击获取验证码...")
|
||||
clicked = False
|
||||
for text in ["获取验证码", "获取"]:
|
||||
try:
|
||||
btn = page.locator(f'button:has-text("{text}"), span:has-text("{text}")').first
|
||||
if await btn.count() > 0 and await btn.is_visible():
|
||||
await btn.click()
|
||||
clicked = True
|
||||
log(f"点击 '{text}'")
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not clicked:
|
||||
try:
|
||||
await page.evaluate("""() => {
|
||||
const all = document.querySelectorAll('button, span, div');
|
||||
for (const el of all) {
|
||||
if (el.offsetParent === null) continue;
|
||||
const t = el.textContent.trim();
|
||||
if (t.includes('获取验证码') || t.includes('获取')) {
|
||||
el.click(); return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}""")
|
||||
clicked = True
|
||||
log("JS 点击获取验证码")
|
||||
except:
|
||||
pass
|
||||
|
||||
if not clicked:
|
||||
result["error"] = "无法点击获取验证码"
|
||||
await browser.close()
|
||||
return result
|
||||
|
||||
result["steps"].append("clicked_get_code")
|
||||
|
||||
# ── Step 6: 等待页面响应(关键!捕捉错误提示)──
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# 截图(点击获取验证码后的页面)
|
||||
try:
|
||||
await page.screenshot(path=screenshot_path.replace(".png", "_after_code.png"))
|
||||
except:
|
||||
pass
|
||||
|
||||
result["page_url"] = page.url
|
||||
|
||||
# ── Step 7: 全面捕捉页面文本 ──
|
||||
log("捕捉页面文本...")
|
||||
|
||||
full_text = ""
|
||||
try:
|
||||
full_text = await page.evaluate("document.body.innerText") or ""
|
||||
except:
|
||||
pass
|
||||
result["full_text_snippet"] = full_text[:2000]
|
||||
|
||||
# 搜索 App 关键词
|
||||
app_kws = ["App", "app", "APP", "应用", "抖音App", "客户端", "扫一扫",
|
||||
"请使用", "请打开", "打开抖音", "扫码", "下载", "安装"]
|
||||
for kw in app_kws:
|
||||
idx = full_text.find(kw)
|
||||
if idx >= 0:
|
||||
nearby = full_text[max(0, idx-20):idx+80]
|
||||
result["app_keyword_context"].append(nearby)
|
||||
result["found_app_login"] = True
|
||||
log(f"✅ 关键词 '{kw}': ...{nearby}...")
|
||||
|
||||
# ── Step 8: 捕捉红色文字 ──
|
||||
try:
|
||||
red_texts = await page.evaluate("""() => {
|
||||
const results = [];
|
||||
const all = document.querySelectorAll('*');
|
||||
for (const el of all) {
|
||||
if (el.offsetParent === null) continue;
|
||||
const style = window.getComputedStyle(el);
|
||||
const color = style.color;
|
||||
if (color.includes('rgb(255,') || color === 'red' ||
|
||||
color.includes('#e5') || color.includes('#f0') ||
|
||||
color.includes('#d32')) {
|
||||
const t = el.textContent.trim();
|
||||
if (t.length > 2 && t.length < 200) {
|
||||
results.push({
|
||||
text: t,
|
||||
color: color,
|
||||
tag: el.tagName,
|
||||
class: (el.className || '').substring(0, 80)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}""")
|
||||
seen = set()
|
||||
for rt in red_texts:
|
||||
t = rt["text"][:40]
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
result["red_texts"].append(rt)
|
||||
if result["red_texts"]:
|
||||
log(f"找到 {len(result['red_texts'])} 条红色文字:")
|
||||
for rt in result["red_texts"][:10]:
|
||||
log(f" ⛔ {rt['text'][:100]}")
|
||||
except Exception as e:
|
||||
log(f"红色文字捕捉异常: {e}")
|
||||
|
||||
# ── Step 9: 捕捉提示/错误信息容器 ──
|
||||
try:
|
||||
hints = await page.evaluate("""() => {
|
||||
const results = [];
|
||||
const containers = document.querySelectorAll(
|
||||
'.error, .tip, .msg, .toast, .hint, [class*="error"], ' +
|
||||
'[class*="tip"], [class*="msg"], [class*="warn"], ' +
|
||||
'[class*="alert"], [class*="notice"]'
|
||||
);
|
||||
for (const c of containers) {
|
||||
if (c.offsetParent !== null && c.textContent.trim().length > 0) {
|
||||
results.push({
|
||||
text: c.textContent.trim().substring(0, 300),
|
||||
class: (c.className || '').substring(0, 100),
|
||||
tag: c.tagName
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}""")
|
||||
result["hint_texts"] = hints
|
||||
if hints:
|
||||
log(f"找到 {len(hints)} 个提示容器:")
|
||||
for h in hints[:10]:
|
||||
log(f" 📋 {h['text'][:120]}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# ── Step 10: 检测登录框状态 ──
|
||||
# 如果 URL 仍然是登录页,说明没有自动登录成功
|
||||
current_url = page.url
|
||||
result["page_url"] = current_url
|
||||
still_on_login = "passport" in current_url.lower() or "login" in current_url.lower()
|
||||
if still_on_login:
|
||||
log("⚠️ 仍在登录页 — 登录被拦截")
|
||||
result["found_app_login"] = True
|
||||
|
||||
await browser.close()
|
||||
log("探测完成")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc()
|
||||
result["error"] = f"{type(e).__name__}: {e}\n{tb[:300]}"
|
||||
log(f"❌ 异常: {e}")
|
||||
try:
|
||||
await browser.close()
|
||||
except:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="探测抖音 App 登录要求 (v2, 不需要SMS)")
|
||||
parser.add_argument("--phone", default="15370108283", help="手机号")
|
||||
parser.add_argument("--output-dir", default="/tmp", help="输出目录")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
log(f"开始探测: phone={args.phone}")
|
||||
log(f"注意: 此版本不需要 SMS 验证码,只观察页面响应")
|
||||
|
||||
result = await detect(args.phone, args.output_dir)
|
||||
|
||||
result_file = os.path.join(args.output_dir, "detect_login_result.json")
|
||||
with open(result_file, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
log(f"结果已保存: {result_file}")
|
||||
|
||||
# 输出摘要
|
||||
log("\n" + "=" * 60)
|
||||
if result.get("found_app_login"):
|
||||
log("✅ 结论: 检测到 App 登录要求!")
|
||||
for ctx in result.get("app_keyword_context", []):
|
||||
log(f" 关键词上下文: {ctx}")
|
||||
else:
|
||||
log("⚠️ 未检测到 App 登录特征")
|
||||
log("可能是账号还可以正常 SMS 登录,或需要实际获得验证码才能触发")
|
||||
|
||||
if result.get("red_texts"):
|
||||
log("\n所有红色文字:")
|
||||
for rt in result["red_texts"]:
|
||||
log(f" ⛔ {rt['text']}")
|
||||
if result.get("hint_texts"):
|
||||
log("\n所有提示容器:")
|
||||
for h in result["hint_texts"]:
|
||||
log(f" 📋 {h['text']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user