915 lines
32 KiB
Python
915 lines
32 KiB
Python
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:
|
||
"""创作者中心私信页 UID(aid=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]:
|
||
"""创作者私信 WS(aid=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:
|
||
"""创作者私信页 WS(aid=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" 已选用 WS(aid={'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" 已捕获创作者私信 WS(aid=2906,含 sdk_cert)")
|
||
return
|
||
if r >= min_rank and elapsed >= 8000:
|
||
print(f" 已捕获创作者私信 WS(aid=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" 使用已抓到的 WS(score={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()
|