返回 douyin-downloader
cookie_fetcher.py
根目录 / tools / cookie_fetcher.py
1 import argparse
2 import asyncio
3 import json
4 import re
5 import sys
6 from pathlib import Path
7 from typing import Any, Dict, List, Optional, Sequence
8 from urllib.parse import parse_qs, unquote, urlparse
9
10 import yaml
11
12 from utils.cookie_utils import parse_cookie_header, sanitize_cookies
13
14 DEFAULT_URL = "https://www.douyin.com/"
15 DEFAULT_OUTPUT = Path("config/cookies.json")
16 REQUIRED_KEYS = {"msToken", "ttwid", "odin_tt", "passport_csrf_token"}
17 SUGGESTED_KEYS = REQUIRED_KEYS | {"sid_guard", "sessionid", "sid_tt"}
18 DEFAULT_AUXILIARY_KEYS = {
19 "_waftokenid",
20 "s_v_web_id",
21 "__ac_nonce",
22 "__ac_signature",
23 "UIFID",
24 "UIFID_TEMP",
25 "d_ticket",
26 "x-web-secsdk-uid",
27 "__security_server_data_status",
28 }
29 DEFAULT_AUXILIARY_PREFIXES = (
30 "__security_mc_",
31 "bd_ticket_guard_",
32 "_bd_ticket_crypt_",
33 )
34 PRIMARY_WAIT_UNTIL = "networkidle"
35 FALLBACK_WAIT_UNTIL = "domcontentloaded"
36 PRIMARY_TIMEOUT_MS = 300_000
37 FALLBACK_TIMEOUT_MS = 300_000
38
39
40 def parse_args(argv: Sequence[str]) -> argparse.Namespace:
41 parser = argparse.ArgumentParser(
42 description="Launch a browser, guide manual login, then dump Douyin cookies.",
43 )
44 parser.add_argument(
45 "--url",
46 default=DEFAULT_URL,
47 help=f"Login page to open (default: {DEFAULT_URL})",
48 )
49 parser.add_argument(
50 "--browser",
51 choices=["chromium", "firefox", "webkit"],
52 default="chromium",
53 help="Playwright browser engine (default: chromium)",
54 )
55 parser.add_argument(
56 "--headless",
57 action="store_true",
58 help="Run browser headless (not recommended for manual login)",
59 )
60 parser.add_argument(
61 "--output",
62 type=Path,
63 default=DEFAULT_OUTPUT,
64 help="JSON file to write collected cookies",
65 )
66 parser.add_argument(
67 "--config",
68 type=Path,
69 help="Optional config.yml to update with captured cookies",
70 )
71 parser.add_argument(
72 "--include-all",
73 action="store_true",
74 help="Store every cookie from douyin.com instead of the recommended subset",
75 )
76 return parser.parse_args(argv)
77
78
79 async def capture_cookies(args: argparse.Namespace) -> int:
80 try:
81 from playwright.async_api import async_playwright # type: ignore
82 except ImportError: # pragma: no cover - defensive path
83 print(
84 "[ERROR] Playwright is not installed. Run `pip install playwright` first.",
85 file=sys.stderr,
86 )
87 return 1
88
89 async with async_playwright() as p:
90 browser_factory = getattr(p, args.browser)
91 browser = await browser_factory.launch(headless=args.headless)
92 context = await browser.new_context()
93 page = await context.new_page()
94 observed_cookie_headers: List[str] = []
95 observed_mstokens: List[str] = []
96
97 def _on_request(request: Any) -> None:
98 try:
99 headers = request.headers or {}
100 cookie_header = headers.get("cookie")
101 if cookie_header:
102 observed_cookie_headers.append(cookie_header)
103 url = request.url or ""
104 query = parse_qs(urlparse(url).query)
105 if "msToken" in query and query["msToken"]:
106 observed_mstokens.append((query["msToken"][0] or "").strip())
107 token = extract_ms_token_from_text(url)
108 if token:
109 observed_mstokens.append(token)
110 except Exception:
111 # 观察请求失败不应影响主流程
112 return
113
114 page.on("request", _on_request)
115
116 print("[INFO] Browser launched. Please complete Douyin login in the opened window.")
117 print("[INFO] Press Enter in this terminal once the homepage shows you are logged in.")
118
119 await wait_for_login_confirmation(page, args.url)
120
121 storage = await context.storage_state()
122 cookies = {
123 cookie["name"]: cookie["value"]
124 for cookie in storage["cookies"]
125 if cookie["domain"].endswith("douyin.com")
126 }
127 cookies = sanitize_cookies(cookies)
128
129 ms_token = await try_extract_ms_token(
130 page, cookies, observed_cookie_headers, observed_mstokens
131 )
132 if ms_token and not cookies.get("msToken"):
133 cookies["msToken"] = ms_token
134 print("[INFO] Extracted msToken from alternate sources.")
135
136 await context.close()
137 await browser.close()
138
139 picked = cookies if args.include_all else filter_cookies(cookies)
140 picked = sanitize_cookies(picked)
141
142 args.output.parent.mkdir(parents=True, exist_ok=True)
143 args.output.write_text(json.dumps(picked, ensure_ascii=False, indent=2), encoding="utf-8")
144 print(f"[INFO] Saved {len(picked)} cookie(s) to {args.output.resolve()}")
145
146 missing = REQUIRED_KEYS - picked.keys()
147 if missing:
148 print(f"[WARN] Missing required cookie keys: {', '.join(sorted(missing))}")
149
150 if args.config:
151 update_config(args.config, picked)
152
153 return 0
154
155
156 def is_timeout_error(exc: Exception) -> bool:
157 return exc.__class__.__name__ == "TimeoutError" or "Timeout" in str(exc)
158
159
160 def is_target_closed_error(exc: Exception) -> bool:
161 return (
162 exc.__class__.__name__ == "TargetClosedError"
163 or "Target page, context or browser has been closed" in str(exc)
164 )
165
166
167 async def goto_with_fallback(page: Any, url: str) -> str:
168 # 部分站点会持续发请求,networkidle 可能一直达不到,超时后降级等待策略。
169 try:
170 await page.goto(url, wait_until=PRIMARY_WAIT_UNTIL, timeout=PRIMARY_TIMEOUT_MS)
171 return PRIMARY_WAIT_UNTIL
172 except Exception as exc:
173 if is_target_closed_error(exc):
174 print(
175 "[WARN] Browser/page was closed during initial navigation, "
176 "continuing with current browser state."
177 )
178 return "target_closed"
179 if not is_timeout_error(exc):
180 raise
181 print(
182 f"[WARN] goto(wait_until={PRIMARY_WAIT_UNTIL}) timed out after {PRIMARY_TIMEOUT_MS}ms, "
183 f"falling back to {FALLBACK_WAIT_UNTIL}."
184 )
185 try:
186 await page.goto(url, wait_until=FALLBACK_WAIT_UNTIL, timeout=FALLBACK_TIMEOUT_MS)
187 return FALLBACK_WAIT_UNTIL
188 except Exception as exc:
189 if is_target_closed_error(exc):
190 print(
191 "[WARN] Browser/page was closed during fallback navigation, "
192 "continuing with current browser state."
193 )
194 return "target_closed"
195 if is_timeout_error(exc):
196 print(
197 f"[WARN] goto(wait_until={FALLBACK_WAIT_UNTIL}) also timed out after {FALLBACK_TIMEOUT_MS}ms, "
198 "continuing anyway."
199 )
200 return "timeout"
201 raise
202
203
204 async def wait_for_login_confirmation(page: Any, url: str, input_func: Any = input) -> None:
205 # 页面导航放到后台执行,避免在导航等待期间终端无法响应 Enter。
206 nav_task = asyncio.create_task(goto_with_fallback(page, url))
207 # 让 nav_task 至少进入第一个 await 点。否则在某些调度时序下,
208 # 若 input_func 立即返回(例如自动化测试或用户立刻按 Enter),
209 # 可能导致 goto 尚未被调度便被 cancel,从而漏掉页面加载。
210 await asyncio.sleep(0)
211 await asyncio.to_thread(input_func)
212
213 if not nav_task.done():
214 nav_task.cancel()
215 try:
216 await nav_task
217 except asyncio.CancelledError:
218 pass
219 except Exception as exc:
220 print(f"[WARN] Navigation task ended with error after cancel: {exc}")
221 return
222
223 try:
224 await nav_task
225 except Exception as exc:
226 print(f"[WARN] Navigation task ended with error: {exc}")
227
228
229 async def try_extract_ms_token(
230 page: Any,
231 cookies: Dict[str, str],
232 observed_cookie_headers: List[str],
233 observed_mstokens: List[str],
234 ) -> Optional[str]:
235 existing = cookies.get("msToken")
236 if existing:
237 return existing
238
239 for token in reversed(observed_mstokens):
240 token = (token or "").strip()
241 if token:
242 return token
243
244 for header in reversed(observed_cookie_headers):
245 parsed = parse_cookie_header(header)
246 token = (parsed.get("msToken") or "").strip()
247 if token:
248 return token
249 extra = extract_ms_token_from_text(header)
250 if extra:
251 return extra
252
253 try:
254 doc_cookie = await page.evaluate("() => document.cookie || ''")
255 parsed = parse_cookie_header(doc_cookie)
256 token = (parsed.get("msToken") or "").strip()
257 if token:
258 return token
259 extra = extract_ms_token_from_text(doc_cookie)
260 if extra:
261 return extra
262 except Exception:
263 pass
264
265 js = """
266 () => {
267 const values = [];
268 const pushIf = (v) => {
269 if (typeof v === 'string' && v.trim()) values.push(v.trim());
270 };
271 try {
272 for (const key of Object.keys(localStorage || {})) {
273 if (key.toLowerCase().includes('mstoken')) {
274 pushIf(localStorage.getItem(key));
275 }
276 }
277 } catch (e) {}
278 try {
279 for (const key of Object.keys(sessionStorage || {})) {
280 if (key.toLowerCase().includes('mstoken')) {
281 pushIf(sessionStorage.getItem(key));
282 }
283 }
284 } catch (e) {}
285 return values;
286 }
287 """
288 try:
289 candidates = await page.evaluate(js)
290 for candidate in candidates or []:
291 if not isinstance(candidate, str):
292 continue
293 text = candidate.strip()
294 if not text:
295 continue
296 parsed = parse_cookie_header(text)
297 if parsed.get("msToken"):
298 return parsed["msToken"]
299 extra = extract_ms_token_from_text(text)
300 if extra:
301 return extra
302 if len(text) <= 2048 and all(ch not in text for ch in [";", " ", "\n", "\r", "\t"]):
303 return text
304 except Exception:
305 pass
306
307 return None
308
309
310 def extract_ms_token_from_text(text: str) -> Optional[str]:
311 if not text:
312 return None
313
314 patterns = [
315 r"(?:^|[;,&\s\"'])msToken=([^;,&\s\"']+)",
316 r'"msToken"\s*:\s*"([^"]+)"',
317 r"'msToken'\s*:\s*'([^']+)'",
318 ]
319 for pattern in patterns:
320 match = re.search(pattern, text)
321 if not match:
322 continue
323 token = (match.group(1) or "").strip()
324 if token:
325 return unquote(token)
326 return None
327
328
329 def filter_cookies(cookies: Dict[str, str]) -> Dict[str, str]:
330 cookies = sanitize_cookies(cookies)
331 picked = {}
332 for key, value in cookies.items():
333 if key in SUGGESTED_KEYS or key in DEFAULT_AUXILIARY_KEYS:
334 picked[key] = value
335 continue
336 if any(key.startswith(prefix) for prefix in DEFAULT_AUXILIARY_PREFIXES):
337 picked[key] = value
338
339 if not picked:
340 return cookies
341 return picked
342
343
344 def update_config(config_path: Path, cookies: Dict[str, str]) -> None:
345 existing: Dict[str, object] = {}
346 if config_path.exists():
347 existing = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
348
349 existing["cookies"] = cookies
350
351 config_path.parent.mkdir(parents=True, exist_ok=True)
352 config_path.write_text(
353 yaml.safe_dump(existing, allow_unicode=True, sort_keys=False),
354 encoding="utf-8",
355 )
356 print(f"[INFO] Updated config file: {config_path.resolve()}")
357
358
359 def main(argv: Optional[Sequence[str]] = None) -> int:
360 args = parse_args(argv or sys.argv[1:])
361 return asyncio.run(capture_cookies(args))
362
363
364 if __name__ == "__main__":
365 raise SystemExit(main())
366
366 lines PYTHON