Files
MediaCrawler/mac-agent-os-main/05_tools/07_matrix/scripts/detect_app_login.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

354 lines
13 KiB
Python

#!/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())