Files
2026-07-17 09:24:47 +08:00

915 lines
32 KiB
Python
Raw Permalink 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.
import asyncio
import json
import os
import sys
import threading
import time
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
from douyin_collector_bootstrap import browser_launch_kwargs, fix_playwright_env
fix_playwright_env()
from app_paths import app_root, clear_browser_ready, is_gui_mode, launch_splash, signal_browser_ready, use_incognito_ui
from douyin_collector_server import (
STATE,
append_log,
bind_start_event,
open_browser,
publish_error,
publish_result,
start_server,
wait_for_shutdown,
)
try:
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError
except Exception as exc:
msg = f"缺少 Playwright{exc}"
if is_gui_mode():
publish_error(msg + "\n\n请运行 fix_browser.bat 或 setup.bat")
wait_for_shutdown()
else:
print("缺少 Playwright,请先安装:")
print(" pip install playwright")
print(" python -m playwright install chromium")
print(f"\n错误:{exc}")
input("\n按回车退出...")
raise SystemExit(1)
class _UiLogWriter:
"""GUI 模式下把 print 同步到网页日志。"""
def __init__(self, mirror) -> None:
self._mirror = mirror
def write(self, text: str) -> None:
if not text:
return
if self._mirror:
self._mirror.write(text)
for line in text.splitlines():
if line.strip():
append_log(line.strip())
def flush(self) -> None:
if self._mirror:
self._mirror.flush()
def _install_ui_logging() -> None:
sys.stdout = _UiLogWriter(getattr(sys, "__stdout__", None))
sys.stderr = _UiLogWriter(getattr(sys, "__stderr__", None))
CHAT_URL = "https://creator.douyin.com/creator-micro/data/following/chat"
OUT_FILE = app_root() / "douyin_storage_state.json"
BROWSER_ARGS = [
"--disable-blink-features=AutomationControlled",
"--incognito",
]
WS_HOOK_INIT = """
(() => {
function __dyIsImWs(u) {
u = String(u || '');
if (!u || !/^wss?:/i.test(u)) return false;
if (!/[?&]token=/.test(u)) return false;
if (/frontier/i.test(u)) return true;
if (/zijieapi\\.com/i.test(u)) return true;
return /\\/ws\\/v2/i.test(u) && /douyin/i.test(u);
}
function __dySaveWs(u) {
if (!__dyIsImWs(u)) return;
let a = [];
try { a = JSON.parse(sessionStorage.getItem('__dy_captured_ws') || '[]'); } catch (e) {}
if (a.indexOf(u) < 0) a.unshift(u);
try { sessionStorage.setItem('__dy_captured_ws', JSON.stringify(a.slice(0, 8))); } catch (e) {}
}
if (window.__dyWsHookInstalled) return;
window.__dyWsHookInstalled = 1;
const O = window.WebSocket;
if (!O) return;
try {
const origSend = O.prototype.send;
O.prototype.send = function () {
try { __dySaveWs(this.url); } catch (e) {}
return origSend.apply(this, arguments);
};
} catch (e) {}
function W(u, p) {
try { __dySaveWs(String(u || '')); } catch (e) {}
return arguments.length > 1 ? new O(u, p) : new O(u);
}
W.prototype = O.prototype;
Object.keys(O).forEach(k => { try { W[k] = O[k]; } catch (e) {} });
['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'].forEach(k => { if (k in O) W[k] = O[k]; });
window.WebSocket = W;
try {
const perfObs = new PerformanceObserver(list => {
list.getEntries().forEach(e => __dySaveWs(e.name));
});
perfObs.observe({ entryTypes: ['resource'] });
(performance.getEntries() || []).forEach(e => __dySaveWs(e.name));
} catch (e) {}
})();
"""
HARVEST_WS_JS = """
() => {
const found = [];
function add(u) {
u = String(u || '');
if (!u || !/^wss?:/i.test(u) || !/[?&]token=/.test(u)) return;
if (!(/frontier/i.test(u) || /zijieapi\\.com/i.test(u) || (/\\/ws\\/v2/i.test(u) && /douyin|creator/i.test(u)))) return;
if (found.indexOf(u) < 0) found.unshift(u);
}
function scanText(t) {
t = String(t || '');
const re = /wss?:\\/\\/[^\\s"'`,)\\]}]+/gi;
let m;
while ((m = re.exec(t)) !== null) add(m[0]);
}
try { JSON.parse(sessionStorage.getItem('__dy_captured_ws') || '[]').forEach(add); } catch (e) {}
try { (performance.getEntries() || []).forEach(e => add(e.name)); } catch (e) {}
try {
for (let i = 0; i < localStorage.length; i++) scanText(localStorage.getItem(localStorage.key(i)));
for (let j = 0; j < sessionStorage.length; j++) scanText(sessionStorage.getItem(sessionStorage.key(j)));
} catch (e) {}
return found;
}
"""
CHAT_SCROLL_JS = """
() => {
try { window.scrollTo(0, document.body.scrollHeight); } catch (e) {}
try {
const nodes = document.querySelectorAll('[class*="list"], [class*="chat"], [class*="message"]');
nodes.forEach(n => { try { n.dispatchEvent(new Event('scroll', { bubbles: true })); } catch (e) {} });
} catch (e) {}
}
"""
LS_KEYS = [
"security-sdk/s_sdk_crypt_sdk",
"security-sdk/s_sdk_sign_data_key/web_protect",
"tea_cache_tokens",
"web_runtime_security_uid",
"security-sdk/s_sdk_cert_key",
"security-sdk/s_sdk_sign_data_key/token",
]
def cookie_header(cookies):
return "; ".join(
f"{c.get('name')}={c.get('value')}"
for c in cookies
if c.get("name") and c.get("value") is not None
)
def parse_web_protect(raw):
if not raw:
return {}
try:
obj = json.loads(raw)
if isinstance(obj, str):
obj = json.loads(obj)
data = obj.get("data") if isinstance(obj, dict) else None
if isinstance(data, str):
return json.loads(data)
if isinstance(data, dict):
return data
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
def get_query(url, key):
try:
value = parse_qs(urlparse(url).query).get(key, [""])[0]
return unquote(value) if value else ""
except Exception:
return ""
def pick_local_storage(ls, exact, contains=()):
if ls.get(exact):
return ls[exact]
for key, value in ls.items():
lower = key.lower()
if value and any(item in lower for item in contains):
return value
return ""
def parse_jsonish(raw):
if not raw:
return None
try:
obj = json.loads(raw)
if isinstance(obj, str):
try:
return json.loads(obj)
except Exception:
return obj
return obj
except Exception:
return None
def find_security_value(ls, expected_fields, key_contains=()):
for key, value in ls.items():
lower = key.lower()
if key_contains and not any(item in lower for item in key_contains):
continue
obj = parse_jsonish(value)
candidates = []
if isinstance(obj, dict):
data = obj.get("data")
data_obj = parse_jsonish(data) if isinstance(data, str) else data
if isinstance(data_obj, dict):
candidates.append(data_obj)
candidates.append(obj)
for item in candidates:
if all(item.get(field) for field in expected_fields):
return value
return ""
def build_web_protect_from_token(raw):
obj = parse_jsonish(raw)
if not isinstance(obj, dict):
return ""
data = obj.get("data")
data_obj = parse_jsonish(data) if isinstance(data, str) else data
if not isinstance(data_obj, dict):
return ""
if not (data_obj.get("ticket") and data_obj.get("ts_sign") and data_obj.get("client_cert")):
return ""
return json.dumps({
"data": json.dumps({
"ticket": data_obj.get("ticket"),
"ts_sign": data_obj.get("ts_sign"),
"client_cert": data_obj.get("client_cert"),
"create_time": int(time.time()),
}, ensure_ascii=False)
}, ensure_ascii=False)
def normalize_tea_key(ls):
raw = pick_local_storage(ls, "tea_cache_tokens", ["tea_cache_tokens"])
if raw:
uid, _ = _parse_tea_uid(raw)
if uid:
return raw
for key in ("__tea_cache_tokens_6383", "__tea_cache_tokens_1661", "__tea_cache_tokens_5231", "__tea_cache_tokens_2906"):
raw = ls.get(key)
if not raw:
continue
uid, _ = _parse_tea_uid(str(raw))
if uid:
return str(raw)
return ""
def _parse_tea_obj(raw):
if not raw:
return {}
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
def _parse_tea_uid(raw: str) -> tuple[str, str]:
"""返回 (user_unique_id, web_id),忽略伪造条目(user_unique_id == web_id)。"""
obj = _parse_tea_obj(raw)
uid = str(obj.get("user_unique_id") or "").strip()
wid = str(obj.get("web_id") or "").strip()
if uid and wid and uid == wid and len(uid) > 12:
return "", wid
return uid, wid
def extract_my_uid_from_ls(ls: dict) -> str:
"""IM 数字 UID:优先 tea_cache_tokens,跳过 creator 侧伪造的 __tea_cache_tokens_*。"""
priority_keys = (
"tea_cache_tokens",
"__tea_cache_tokens_6383",
"__tea_cache_tokens_1661",
"__tea_cache_tokens_5231",
"__tea_cache_tokens_2906",
)
for key in priority_keys:
raw = ls.get(key)
if not raw:
continue
uid, _ = _parse_tea_uid(raw)
if uid and uid.isdigit():
return uid
for key, raw in ls.items():
if "tea_cache" not in key.lower() or not raw:
continue
uid, _ = _parse_tea_uid(str(raw))
if uid and uid.isdigit():
return uid
return ""
def extract_web_id_from_ls(ls: dict) -> str:
for key in ("tea_cache_tokens", "__tea_cache_tokens_6383", "__tea_cache_tokens_1661", "__tea_cache_tokens_5231", "__tea_cache_tokens_2906"):
raw = ls.get(key)
if not raw:
continue
_, wid = _parse_tea_uid(raw)
uid, _ = _parse_tea_uid(raw)
if wid and wid.isdigit():
return wid
if uid and uid.isdigit():
return uid
return ""
def extract_creator_uid(ls: dict) -> str:
"""创作者中心私信页 UIDaid=2906 场景,与 WS device_id 对齐)。"""
import re
for key in ("__tea_cache_tokens_1661", "__tea_cache_tokens_2906", "tea_cache_tokens"):
raw = ls.get(key)
if not raw:
continue
uid, _ = _parse_tea_uid(str(raw))
if uid and uid.isdigit():
return uid
slardar = str(ls.get("SLARDARdouyin_creator") or "")
m = re.search(r"userId[^0-9]{0,8}(\d{6,})", slardar)
if m:
return m.group(1)
for value in ls.values():
if not isinstance(value, str):
continue
m2 = re.search(r'"uuid"\s*:\s*"(\d{6,})"', value)
if m2:
return m2.group(1)
return ""
def resolve_canonical_uid(www_ls: dict, creator_ls: dict, ws_urls: list) -> tuple[str, str]:
"""创作者私信 WSaid=2906)优先:UID 与 WS device_id 对齐。"""
best_ws = pick_best_ws_url(ws_urls)
creator_uid = extract_creator_uid(creator_ls)
www_uid = extract_my_uid_from_ls(www_ls)
if best_ws and "aid=2906" in best_ws:
dev = ws_device_id(best_ws)
my_uid = dev if dev.isdigit() else (creator_uid or www_uid)
else:
my_uid = www_uid or creator_uid
if not my_uid and best_ws:
dev = ws_device_id(best_ws)
if dev.isdigit():
my_uid = dev
web_id = extract_web_id_from_ls(www_ls) or extract_web_id_from_ls(creator_ls)
return my_uid, web_id
def is_im_ws_url(url: str) -> bool:
if not url or "token=" not in url:
return False
if "frontier-im.douyin.com" in url:
return True
if "zijieapi.com" in url and ("/ws/v2" in url or "frontier" in url.lower()):
return True
return "/ws/v2" in url and ("douyin" in url or "creator" in url)
def ws_rank(url: str) -> int:
"""创作者私信页 WSaid=2906 + sdk_cert)优先级最高。"""
if not url:
return 0
score = 1
if "sdk_cert=" in url:
score += 25
if "aid=2906" in url or "device_platform=douyin_pc" in url:
score += 20
if is_real_captured_ws(url):
score += 5
if "frontier-im.douyin.com" in url:
score += 3
if "aid=6383" in url:
score -= 5
return score
def pick_best_ws_url(ws_urls: list) -> str:
candidates = [u for u in (ws_urls or []) if is_im_ws_url(u)]
if not candidates:
return ""
candidates.sort(key=ws_rank, reverse=True)
return candidates[0]
def ws_device_id(url: str) -> str:
return get_query(url, "device_id")
def merge_ws_urls(target: list, incoming: list) -> None:
for url in incoming or []:
if not url or not is_im_ws_url(url):
continue
if url not in target:
target.append(url)
target.sort(key=ws_rank, reverse=True)
def is_real_captured_ws(url: str) -> bool:
"""真实抓包 WS 的 token 是编码串,不是裸 sessionid。"""
if not is_im_ws_url(url):
return False
token = get_query(url, "token")
sid_like = len(token) < 40 and token.replace("_", "").replace("-", "").isalnum()
return not sid_like
def attach_frontier_ws(result: dict, ws_urls: list, my_uid: str, web_id: str) -> str:
chosen = pick_best_ws_url(ws_urls)
if not chosen:
print(" 未捕获 frontier WS(请在创作者私信页等消息列表加载后重采)")
return ""
if not is_real_captured_ws(chosen) and "sdk_cert=" not in chosen:
print(" 警告:抓到的 WS 无 sdk_cert,可能无法实时收消息")
ws_dev = ws_device_id(chosen)
if ws_dev and ws_dev.isdigit():
if "aid=2906" in chosen:
result["my_uid"] = int(ws_dev)
my_uid = ws_dev
elif not my_uid:
result["my_uid"] = int(ws_dev)
result["frontier_ws_url"] = chosen
result["ws_source"] = "creator_chat" if "aid=2906" in chosen else "douyin_web"
sdk_cert = get_query(chosen, "sdk_cert")
ts_sign = get_query(chosen, "ts_sign")
if sdk_cert:
result["sdk_cert"] = sdk_cert
if ts_sign:
result["ts_sign"] = ts_sign
print(f" 已选用 WSaid={'2906 创作者私信' if 'aid=2906' in chosen else '其他'}device_id={ws_dev or '?'}")
return chosen
def normalize_cookie(cookie):
same_site = cookie.get("sameSite") or "Lax"
if same_site not in ("Strict", "Lax", "None"):
same_site = "Lax"
return {
"name": cookie["name"],
"value": cookie.get("value", ""),
"domain": cookie.get("domain") or ".douyin.com",
"path": cookie.get("path") or "/",
"expires": int(cookie.get("expires") or -1),
"httpOnly": bool(cookie.get("httpOnly", False)),
"secure": bool(cookie.get("secure", True)),
"sameSite": same_site,
}
def add_cookie_if_missing(cookies, name, value):
if not value or any(c.get("name") == name for c in cookies):
return
cookies.append({
"name": name,
"value": value,
"domain": ".douyin.com",
"path": "/",
"expires": int(time.time()) + 60 * 60 * 24 * 180,
"httpOnly": True,
"secure": True,
"sameSite": "None",
})
def complete_session_cookies(cookies):
values = {c.get("name"): c.get("value", "") for c in cookies}
sid = values.get("sessionid") or values.get("sessionid_ss") or values.get("sid_tt")
guard = values.get("sid_guard")
if not sid and guard:
try:
sid = unquote(guard).split("|")[0].strip()
except Exception:
sid = guard.split("|")[0].strip()
add_cookie_if_missing(cookies, "sessionid", sid)
add_cookie_if_missing(cookies, "sessionid_ss", sid)
async def wait_for_login(context, page):
STATE.set_waiting("正在打开无痕浏览器…")
print("\n=== 抖音 IM 凭证采集(无痕窗口)===")
print("① 切换到「② 抖音登录」标签页扫码")
print("② 登录后自动采集,回到「① 凭证采集」标签页复制\n")
start_event = threading.Event()
bind_start_event(start_event)
logged_in_at = None
chat_at = None
navigated_chat = False
start = time.time()
async def on_chat_page() -> bool:
url = page.url or ""
return "/following/chat" in url or CHAT_URL in url
async def readiness_score() -> int:
score = 0
cookies = await context.cookies(["https://www.douyin.com", "https://creator.douyin.com"])
names = {c.get("name") for c in cookies}
if "sessionid" in names or "sessionid_ss" in names or "sid_guard" in names:
score += 10
if await on_chat_page():
score += 5
try:
ls = await page.evaluate(
"""
() => {
try {
const wp = localStorage.getItem('security-sdk/s_sdk_sign_data_key/web_protect');
const crypt = localStorage.getItem('security-sdk/s_sdk_crypt_sdk');
return { wp: !!wp, crypt: !!crypt };
} catch (e) { return { wp: false, crypt: false }; }
}
"""
)
if ls.get("wp"):
score += 15
if ls.get("crypt"):
score += 10
except Exception:
pass
return score
while True:
cookies = await context.cookies(["https://www.douyin.com", "https://creator.douyin.com"])
names = {c.get("name") for c in cookies}
logged_in = "sessionid" in names or "sessionid_ss" in names or "sid_guard" in names or "sid_tt" in names
if logged_in:
if logged_in_at is None:
logged_in_at = time.time()
print("✓ 已检测到登录")
if not navigated_chat:
navigated_chat = True
STATE.set_waiting("已登录,正在打开私信页…")
print("正在打开创作者私信页…")
try:
await page.goto(CHAT_URL, wait_until="domcontentloaded", timeout=45000)
await page.wait_for_timeout(2000)
except Exception:
pass
if await on_chat_page():
if chat_at is None:
chat_at = time.time()
score = await readiness_score()
waited = time.time() - chat_at
if score >= 35:
print("\n凭证已就绪,开始自动采集…")
STATE.set_collecting("正在采集凭证…")
return
if waited >= 18 and score >= 15:
print("\n开始采集(请确保私信页消息列表已出现)…")
STATE.set_collecting("正在采集凭证…")
return
remain = max(0, 18 - int(waited))
STATE.set_waiting(
f"已登录 ✓ 等待私信页…(约 {remain} 秒后自动采集,可点「① 凭证采集」页按钮)"
)
else:
STATE.set_waiting("已登录 ✓ 正在跳转私信页…")
else:
STATE.set_waiting("请切换到「② 抖音登录」标签页扫码")
if start_event.is_set():
print("\n收到开始采集信号…")
STATE.set_collecting("正在采集凭证…")
return
if time.time() - start > 600:
print("\n等待超时,尝试采集当前状态…")
STATE.set_collecting("等待超时,尝试采集当前状态…")
return
await asyncio.sleep(0.8)
async def collect(ui_url: str = ""):
launch_kw = browser_launch_kwargs()
incognito_ui = use_incognito_ui()
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=False,
args=BROWSER_ARGS,
**launch_kw,
)
signal_browser_ready()
context = await browser.new_context(viewport={"width": 1280, "height": 900})
await context.add_init_script(WS_HOOK_INIT)
ws_urls: list[str] = []
ui_page = None
print("已以无痕模式打开浏览器(关闭后不会保留登录记录)")
if ui_url and incognito_ui:
ui_page = await context.new_page()
await ui_page.goto(f"{ui_url}?run={STATE.run_id}", wait_until="domcontentloaded")
try:
await ui_page.evaluate("document.title = '① 凭证采集'")
except Exception:
pass
append_log("已打开「凭证采集」标签页")
print(f"凭证页:{ui_url}")
page = await context.new_page()
try:
await page.evaluate("document.title = '② 抖音登录'")
except Exception:
pass
def on_websocket(ws):
url = ws.url
if is_im_ws_url(url):
merge_ws_urls(ws_urls, [url])
print("已捕获 Frontier WS")
def bind_page(p):
p.on("websocket", on_websocket)
bind_page(page)
context.on("page", bind_page)
async def harvest_ws(p) -> None:
try:
found = await p.evaluate(HARVEST_WS_JS)
if found:
merge_ws_urls(ws_urls, found)
except Exception:
pass
async def wait_for_ws_on_page(p, label: str, wait_ms: int = 60000, reload_at_ms: int = 20000) -> None:
print(f"正在 {label} 等待 WebSocket 连接…")
STATE.set_collecting(f"正在 {label} 等待 WebSocket…")
elapsed = 0
reloaded = False
step = 500
ideal_rank = 46 # aid=2906 + sdk_cert + 真实 token
min_rank = 28 # aid=2906 + 真实 token(无 sdk_cert 也可先收下)
while elapsed < wait_ms:
await harvest_ws(p)
try:
await p.evaluate(CHAT_SCROLL_JS)
except Exception:
pass
best = pick_best_ws_url(ws_urls)
if best:
r = ws_rank(best)
if r >= ideal_rank:
print(f" 已捕获创作者私信 WSaid=2906,含 sdk_cert")
return
if r >= min_rank and elapsed >= 8000:
print(f" 已捕获创作者私信 WSaid=2906,继续等待 sdk_cert… score={r}")
if not reloaded and elapsed >= reload_at_ms and (not best or ws_rank(best) < min_rank):
reloaded = True
print(" 尚未抓到 WS,刷新私信页重试…")
try:
await p.reload(wait_until="domcontentloaded", timeout=30000)
await p.wait_for_timeout(3000)
except Exception:
pass
await p.wait_for_timeout(step)
elapsed += step
best = pick_best_ws_url(ws_urls)
if best and ws_rank(best) >= min_rank:
print(f" 使用已抓到的 WSscore={ws_rank(best)}")
elif best:
print(f" 警告:WS 质量偏低(score={ws_rank(best)}),建议私信页刷新后重采")
await page.goto(CHAT_URL, wait_until="domcontentloaded")
try:
await page.bring_to_front()
except Exception:
pass
append_log("请在本窗口「② 抖音登录」标签页扫码")
await wait_for_login(context, page)
# 用户按 Enter 后:确保在私信页,再抓 WS
try:
cur = page.url or ""
if CHAT_URL not in cur:
print("正在跳转到创作者私信页…")
await page.goto(CHAT_URL, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(3000)
except PlaywrightTimeoutError:
pass
await wait_for_ws_on_page(page, "创作者私信页")
try:
if CHAT_URL not in (page.url or ""):
await page.goto(CHAT_URL, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(2000)
except Exception:
pass
await harvest_ws(page)
print("正在读取 localStorage / Cookie…")
STATE.set_collecting("正在读取签名凭证…")
async def read_local_storage_inline():
try:
return await page.evaluate("""
() => {
const out = {};
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
out[k] = localStorage.getItem(k);
}
return out;
}
""")
except Exception:
return {}
async def read_local_storage(target_url):
try:
await page.goto(target_url, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(1500)
return await read_local_storage_inline()
except Exception:
return {}
creator_ls = await read_local_storage_inline()
if not creator_ls:
creator_ls = await read_local_storage(CHAT_URL)
www_ls = await read_local_storage("https://www.douyin.com/")
ls = {**creator_ls, **www_ls}
cookies = await context.cookies(["https://www.douyin.com", "https://creator.douyin.com"])
cookies = [normalize_cookie(c) for c in cookies if ".douyin.com" in c.get("domain", "") or "douyin.com" in c.get("domain", "")]
complete_session_cookies(cookies)
web_protect_raw = pick_local_storage(ls, "security-sdk/s_sdk_sign_data_key/web_protect", ["web_protect"])
if not web_protect_raw:
token_raw = find_security_value(ls, ["ticket", "ts_sign", "client_cert"], ["sign_data_key", "token"])
web_protect_raw = build_web_protect_from_token(token_raw) or token_raw
crypt_raw = pick_local_storage(ls, "security-sdk/s_sdk_crypt_sdk", ["crypt_sdk"])
if not crypt_raw:
crypt_raw = find_security_value(ls, ["ec_privateKey", "ec_publicKey"], ["crypt", "sdk"])
tea_raw = normalize_tea_key(www_ls) or normalize_tea_key(creator_ls)
my_uid, web_id = resolve_canonical_uid(www_ls, creator_ls, ws_urls)
device_id = my_uid or web_id
local_storage_entries = []
if crypt_raw:
local_storage_entries.append({"name": "security-sdk/s_sdk_crypt_sdk", "value": str(crypt_raw)})
if web_protect_raw:
local_storage_entries.append({"name": "security-sdk/s_sdk_sign_data_key/web_protect", "value": str(web_protect_raw)})
if tea_raw:
local_storage_entries.append({"name": "tea_cache_tokens", "value": str(tea_raw)})
elif my_uid:
local_storage_entries.append({
"name": "tea_cache_tokens",
"value": json.dumps({"user_unique_id": my_uid, "web_id": web_id or my_uid}, ensure_ascii=False),
})
if device_id and str(device_id).isdigit():
local_storage_entries.append({"name": "web_runtime_security_uid", "value": str(device_id)})
user_agent = await page.evaluate("navigator.userAgent")
origins = [{"origin": "https://www.douyin.com", "localStorage": local_storage_entries}]
if creator_ls:
creator_only = []
for key, value in creator_ls.items():
if key in ls and ls[key] == value and not any(e["name"] == key for e in local_storage_entries):
creator_only.append({"name": key, "value": str(value)})
if creator_only:
origins.append({"origin": "https://creator.douyin.com", "localStorage": creator_only})
result = {
"cookies": cookies,
"origins": origins,
"user_agent": user_agent,
}
if my_uid:
result["my_uid"] = int(my_uid)
elif web_id and web_id.isdigit():
result["my_uid"] = int(web_id)
attach_frontier_ws(result, ws_urls, my_uid, web_id)
my_uid = str(result.get("my_uid") or my_uid or "")
wp = parse_web_protect(web_protect_raw)
names = {c["name"] for c in cookies}
ws_url = result.get("frontier_ws_url") or ""
ws_captured = bool(ws_url and is_real_captured_ws(ws_url))
ws_has_cert = bool(result.get("sdk_cert"))
print("\n采集检查:")
print(" sessionid:", "OK" if ("sessionid" in names or "sessionid_ss" in names) else "缺失")
print(" web_protect:", "OK" if web_protect_raw else "缺失")
print(" crypt_sdk:", "OK" if crypt_raw else "缺失")
print(" web_protect 完整:", "OK" if (wp.get("ticket") and wp.get("ts_sign") and (wp.get("client_cert") or wp.get("sdk_cert"))) else "缺失")
if ws_captured and ws_has_cert:
aid_hint = "2906 创作者私信" if "aid=2906" in ws_url else "其他"
print(f" frontier_ws: OK(真实抓包,含 sdk_cert{aid_hint}")
elif ws_captured:
print(" frontier_ws:", "OK(真实抓包,无 sdk_cert")
elif ws_url:
print(" frontier_ws:", "警告:WS 疑似无效,请重采")
else:
print(" frontier_ws:", "缺失(请在私信页等消息列表加载后重采)")
print(" my_uid:", my_uid or web_id or "缺失")
if my_uid and web_id and my_uid != web_id:
print(" web_id:", web_id, "(与 my_uid 不同,属正常)")
OUT_FILE.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
publish_result(result)
print(f"\n已生成:{OUT_FILE}")
if ui_page:
try:
await ui_page.bring_to_front()
await ui_page.evaluate("document.title = '① 凭证采集 · 已完成'")
except Exception:
pass
append_log("采集完成,请在本窗口「① 凭证采集」标签页复制")
print("请在本窗口「凭证采集」标签页复制 JSON。")
else:
print("已在浏览器打开采集结果页,可一键复制凭证 JSON。")
if incognito_ui and ui_page:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, wait_for_shutdown)
await context.close()
await browser.close()
def main() -> None:
gui = is_gui_mode()
if gui:
_install_ui_logging()
try:
clear_browser_ready()
_, ui_url = start_server()
if not use_incognito_ui():
if os.environ.get("SPLASH_LAUNCHED") != "1":
launch_splash()
open_browser(f"{ui_url}?run={STATE.run_id}")
append_log("请在弹出的 Chrome 扫码登录")
else:
append_log("正在打开无痕浏览器…")
asyncio.run(collect(ui_url))
except KeyboardInterrupt:
print("\n已取消")
publish_error("用户取消采集")
if not gui:
input("\n按回车退出…")
return
except Exception as exc:
msg = str(exc)
if "Executable doesn't exist" in msg or "playwright install" in msg.lower():
from app_paths import is_frozen
if is_frozen():
fix_hint = "请运行同目录「修复浏览器.bat」,或 DouyinIMCollector.exe --install-browser"
else:
fix_hint = f"请运行:{app_root() / 'fix_browser.bat'}"
msg = f"Playwright 找不到浏览器。\n\n{fix_hint}"
print(f"\n采集失败:{msg}")
publish_error(msg)
if gui:
wait_for_shutdown()
else:
input("\n按回车退出…")
return
if gui and not use_incognito_ui():
append_log("采集流程结束,可复制凭证后点「退出」")
wait_for_shutdown()
elif gui and use_incognito_ui():
pass
else:
phase = STATE.snapshot().get("phase")
if phase == "done":
print("\n✓ 采集完成!请在浏览器页面复制凭证后关闭本窗口。")
input("\n按回车关闭…")
if __name__ == "__main__":
main()