diff --git a/core/huya/anon_device.py b/core/huya/anon_device.py new file mode 100644 index 0000000..ba19c60 --- /dev/null +++ b/core/huya/anon_device.py @@ -0,0 +1,177 @@ +"""虎牙网页匿名设备态协议生成器(纯 HTTP,middle + anonymousLogin)。 + +生成 4 个网页设备态字段(来源见 docs/HUYA_COOKIE合并审计与来源矩阵.md): + +- ``udb_deviceid`` <- GET /web/middle/2.4/{rand8}/https/{hex32} 的 Set-Cookie +- ``udb_guiddata`` <- middle URL 第三段 hex32(客户端自选,服务端不校验) +- ``udb_anobiztoken``<- POST /web/anonymousLogin 响应 data.biztoken(344 位) +- ``udb_anouid`` <- anonymousLogin 响应 data.uid + +用法:: + + python -m core.huya.anon_device # 打印 4 字段 JSON + python -m core.huya.anon_device --json # 同上,紧凑 JSON(供脚本消费) + +注意: 200 / returnCode=0 只证明接口受理,不证明风控认可这些值的真实性。 +``guid`` / ``_qimei_uuid42`` / ``__yamid_new`` / ``game_did`` 仍无服务端 +签发接口,本模块不生成、不伪造它们。 +""" +from __future__ import annotations + +import argparse +import json +import random +import re +import secrets +import sys +from collections.abc import Mapping + +import requests +from loguru import logger + +MIDDLE_URL = "https://udblgn.huya.com/web/middle/2.4/{rand8}/https/{guiddata}" +ANON_LOGIN_URL = "https://udblgn.huya.com/web/anonymousLogin" + +DESKTOP_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" +) + +UDBN_DEVICEID_RE = re.compile(r"udb_deviceid=([^;]+)") + + +class AnonymousDeviceError(RuntimeError): + """匿名设备态获取失败。""" + + +def _new_session(*, proxies: Mapping[str, str] | None = None) -> requests.Session: + session = requests.Session() + session.trust_env = False + if proxies: + session.proxies.update(dict(proxies)) + return session + + +def _parse_set_cookie_deviceid(set_cookie: str) -> str: + match = UDBN_DEVICEID_RE.search(set_cookie or "") + if not match or not re.fullmatch(r"w_\d{19}", match.group(1)): + raise AnonymousDeviceError("middle 响应未下发合法 udb_deviceid") + return match.group(1) + + +def fetch_anonymous_device_state( + *, + proxies: Mapping[str, str] | None = None, + timeout: float = 15.0, + session: requests.Session | None = None, +) -> dict[str, str]: + """换取匿名设备态四字段:udb_deviceid / udb_guiddata / udb_anobiztoken / udb_anouid。 + + 与网页 SDK 相同的两步:middle 引导页签发 deviceid,anonymousLogin 换取 + biztoken/uid。所有参数客户端随机生成,无需任何既有 Cookie。 + """ + own_session = session is None + s = session or _new_session(proxies=proxies) + try: + guiddata = secrets.token_hex(16) + rand8 = str(random.randint(10_000_000, 99_999_999)) + middle_url = MIDDLE_URL.format(rand8=rand8, guiddata=guiddata) + + # 1) middle:签发 udb_deviceid + resp = s.get( + middle_url, + headers={"User-Agent": DESKTOP_UA, "Referer": "https://www.huya.com/"}, + timeout=timeout, + allow_redirects=False, + ) + if resp.status_code != 200: + raise AnonymousDeviceError(f"middle 返回 {resp.status_code}") + deviceid = _parse_set_cookie_deviceid(resp.headers.get("Set-Cookie", "")) + + # 2) anonymousLogin:发放匿名 uid + biztoken(必须带 middle Referer) + context = ( + "WB-" + + secrets.token_hex(16) + + "-" + secrets.token_hex(16) + + "-" + secrets.token_hex(16) + ) + payload = { + "uri": "10013", + "version": "2.4", + "context": context, + "appId": 5002, + "sdid": "csid_" + secrets.token_hex(16), + "lcid": 2052, + "byPass": 3, + "requestId": random.randint(10_000_000, 99_999_999), + "authId": "", + "data": {"domainList": ""}, + } + anon = s.post( + ANON_LOGIN_URL, + headers={ + "User-Agent": DESKTOP_UA, + "Referer": middle_url, + "Content-Type": "application/json;charset=UTF-8", + }, + data=json.dumps(payload), + timeout=timeout, + allow_redirects=False, + ) + if anon.status_code != 200: + raise AnonymousDeviceError(f"anonymousLogin 返回 {anon.status_code}") + try: + body = anon.json() + except ValueError as exc: + raise AnonymousDeviceError( + f"anonymousLogin 响应非 JSON: {anon.text[:80]!r}" + ) from exc + if body.get("returnCode") != 0: + raise AnonymousDeviceError( + f"anonymousLogin returnCode={body.get('returnCode')} " + f"message={body.get('message')!r}" + ) + data = body.get("data") or {} + anouid = str(data.get("uid") or "") + biztoken = str(data.get("biztoken") or "") + if not anouid or not biztoken: + raise AnonymousDeviceError("anonymousLogin 响应缺少 uid/biztoken") + if not 300 <= len(biztoken) <= 400: + logger.warning("[huya-anon] biztoken 长度异常: {}", len(biztoken)) + + logger.info( + "[huya-anon] 匿名设备态 OK: deviceid={}... guiddata={}... " + "anouid={} biztoken={}...", + deviceid[:14], guiddata[:8], anouid, biztoken[:10], + ) + return { + "udb_deviceid": deviceid, + "udb_guiddata": guiddata, + "udb_anobiztoken": biztoken, + "udb_anouid": anouid, + } + finally: + if own_session: + s.close() + + +def main() -> int: + ap = argparse.ArgumentParser(description="虎牙匿名设备态四字段生成(纯协议)") + ap.add_argument("--json", action="store_true", help="输出紧凑 JSON") + args = ap.parse_args() + try: + state = fetch_anonymous_device_state() + except AnonymousDeviceError as exc: + print(f"❌ {exc}", file=sys.stderr) + return 2 + if args.json: + print(json.dumps(state, ensure_ascii=False)) + else: + for key, value in state.items(): + print(f"{key} = {value}") + print("\n(4/7 字段;guid/_qimei_uuid42/__yamid_new/game_did 无服务端签发,不生成)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/core/huya/browser_harvest.py b/core/huya/browser_harvest.py new file mode 100755 index 0000000..bd3ab72 --- /dev/null +++ b/core/huya/browser_harvest.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +"""真实 Chrome(CDP) 抓包:虎牙网页 Cookie 字段的来源分析(无需登录)。 + +两轮运行模式(同一个 user-data-dir): + round 1: 全新 profile 访问 www.huya.com 门户 + 直播间,记录每个请求携带的 + Cookie 头、每个响应的 Set-Cookie,定位每个字段的"首次出现位置"。 + round 2: 同一 profile 重启浏览器再访问,验证哪些字段是浏览器重启后 + 首请求就直接携带的(浏览器持久态,而非登录/页面产生)。 + +用法: + .venv/bin/python tools/huya_browser_harvest.py round1 --profile /tmp/huya-browser-p1 + .venv/bin/python tools/huya_browser_harvest.py round2 --profile /tmp/huya-browser-p1 + .venv/bin/python tools/huya_browser_harvest.py report --out evidence/browser_cookie_harvest + +无第三方依赖:标准库 + websockets(直接走 CDP 协议)。 +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import subprocess +import sys +import time +import urllib.parse +import urllib.request +from pathlib import Path + +import websockets + +CHROME = ( + Path.home() + / "Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/" + "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" +) + +PAGES = [ + ("portal", "https://www.huya.com/"), + ("live", "https://www.huya.com/30596253"), +] + +# 与 core/huya/app_login.py::WEB_DEVICE_COOKIE_KEYS 一致 +WEB_DEVICE_KEYS = ( + "guid", "udb_guiddata", "udb_deviceid", "game_did", + "_qimei_uuid42", "udb_anobiztoken", "__yamid_new", +) + +OUT_DIR = Path("evidence/browser_cookie_harvest") + + +class Cdp: + def __init__(self, ws): + self.ws = ws + self._id = 0 + self._pending: dict[int, asyncio.Future] = {} + self.listeners = [] + self.events = [] + + async def __aenter__(self): + self._task = asyncio.create_task(self._reader()) + return self + + async def __aexit__(self, *exc): + self._task.cancel() + + async def _reader(self): + try: + async for raw in self.ws: + msg = json.loads(raw) + if "id" in msg and msg["id"] in self._pending: + self._pending.pop(msg["id"]).set_result(msg) + elif "method" in msg: + self.events.append(msg) + for fn in list(self.listeners): + try: + fn(msg) + except Exception: # noqa: BLE001, S110 - 监听器容错 + pass + except Exception: # noqa: BLE001, S110 - socket closing on shutdown + pass + + async def call(self, method, params=None): + self._id += 1 + fut = asyncio.get_running_loop().create_future() + self._pending[self._id] = fut + await self.ws.send(json.dumps({"id": self._id, "method": method, "params": params or {}})) + resp = await fut + if "error" in resp: + raise RuntimeError(f"{method}: {resp['error']}") + return resp.get("result", {}) + + async def eval_js(self, expr): + r = await self.call("Runtime.evaluate", { + "expression": expr, "returnByValue": True, "awaitPromise": True, + }) + return r.get("result", {}).get("value") + + +def launch(profile: Path, port: int, headless: bool = False) -> tuple[subprocess.Popen, str]: + args = [ + str(CHROME), + "--no-first-run", "--no-default-browser-check", + "--disable-background-networking", "--disable-component-update", + "--disable-sync", "--metrics-recording-only", "--disable-extensions", + "--disable-features=OptimizationHints,MediaRouter", + f"--user-data-dir={profile}", + f"--remote-debugging-port={port}", + "--window-size=1400,900", + "about:blank", + ] + if headless: + args.insert(2, "--headless=new") + proc = subprocess.Popen( + args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True + ) + ws_url = None + deadline = time.time() + 20 + while time.time() < deadline: + # 1) 从 DevTools 输出的 ws url(browser 级) + line = proc.stderr.readline() + if "DevTools listening on" in line: + browser_ws = line.strip().split(" ")[-1] + else: + browser_ws = None + # 2) page target 的 ws url(Network 域需要挂在 page target 上) + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/json/list", timeout=2 + ) as resp: + targets = json.load(resp) + pages = {t for t in targets if t.get("type") == "page" and t.get("webSocketDebuggerUrl")} + if pages: + ws_url = pages[0]["webSocketDebuggerUrl"] + break + except Exception: # noqa: BLE001, S110 - 端口未就绪时轮询重试 + pass + # 3) 兜底:browser 级 endpoint + if browser_ws: + ws_url = browser_ws + break + if not ws_url: + proc.kill() + raise RuntimeError("Chrome 调试端口未就绪") + return proc, ws_url + + +async def navigate(cdp: Cdp, url: str, settle: float = 10.0): + """导航并等待 load 事件 + 脚本静置时间。""" + loaded = asyncio.get_running_loop().create_future() + + def on_event(msg): + if msg.get("method") == "Page.loadEventFired" and not loaded.done(): + loaded.set_result(True) + + cdp.listeners.append(on_event) + await cdp.call("Page.navigate", {"url": url}) + try: + await asyncio.wait_for(loaded, timeout=30) + except TimeoutError: + pass + finally: + cdp.listeners.remove(on_event) + await asyncio.sleep(settle) + + +def cookie_names_from_header(headers: dict) -> list[str]: + raw = headers.get("Cookie") or headers.get("cookie") or "" + if not raw: + return [] + return [part.strip().split("=", 1)[0] for part in raw.split(";") if part.strip()] + + +async def capture_round(profile: Path, tag: str, port: int): + """抓取一轮:导航序列 + 全量 Cookie 快照。""" + proc, ws_url = launch(profile, port) + try: + async with websockets.connect(ws_url, max_size=64 * 1024 * 1024) as raw_ws: + cdp = Cdp(raw_ws) + async with cdp: + await cdp.call("Network.enable") + await cdp.call("Page.enable") + await cdp.call("Runtime.enable") + + requests: dict[str, dict] = {} # requestId -> 请求信息 + req_seq = 0 + + def on_req(msg): + nonlocal req_seq + if msg.get("method") != "Network.requestWillBeSent": + return + params = msg["params"] + req = params["request"] + url = req.get("url", "") + rid = params.get("requestId", "") + entry = requests.setdefault(rid, {"url": url, "seq": req_seq}) + ack_order = entry.get("seq") + if ack_order is None: + entry["seq"] = req_seq + else: + entry["seq"] = min(ack_order, req_seq) + req_seq += 1 + entry.update({ + "url": url, + "host": urllib.parse.urlparse(url).netloc, + "type": params.get("type", ""), + }) + # 部分请求的 Cookie 头在 requestWillBeSent 就直接可见 + seen = set(entry.get("cookie_names", [])) + for name in cookie_names_from_header(req.get("headers", {})): + if name not in seen: + entry.setdefault("cookie_names", []).append(name) + seen.add(name) + + cdp.listeners.append(on_req) + + def on_req_extra(msg): + if msg.get("method") != "Network.requestWillBeSentExtraInfo": + return + params = msg["params"] + rid = params.get("requestId", "") + entry = requests.setdefault(rid, {"url": "?"}) + # associatedCookies: 该请求实际附带(含 httpOnly/持久)的 Cookie + seen = set(entry.get("cookie_names", [])) + for ck in params.get("associatedCookies", []): + name = ck.get("cookie", {}).get("name", "") + if name and name not in seen: + entry.setdefault("cookie_names", []).append(name) + seen.add(name) + # headers 里的 Cookie 头(同一信息的冗余来源) + for name in cookie_names_from_header(params.get("headers", {})): + if name not in seen: + entry.setdefault("cookie_names", []).append(name) + seen.add(name) + + cdp.listeners.append(on_req_extra) + + resp_extra: list[dict] = [] + + def on_resp_extra(msg): + if msg.get("method") != "Network.responseReceivedExtraInfo": + return + params = msg["params"] + rid = params.get("requestId", "") + entry = requests.setdefault(rid, {"url": "?"}) + url = entry.get("url", "?") + headers = params.get("headers", {}) + set_cookie = headers.get("Set-Cookie") or headers.get("set-cookie") or [] + if isinstance(set_cookie, str): + set_cookie = [set_cookie] + for sc in set_cookie: + name = sc.split("=", 1)[0].strip() + resp_extra.append({"cookie": name, "url": url, "meta": sc[:160]}) + + cdp.listeners.append(on_resp_extra) + + doc_cookies: dict[str, dict] = {} + snapshots: list[dict] = [] + for name, url in PAGES: + ts = time.strftime("%H:%M:%S") + doc_cookies[name] = { + "before": await cdp.eval_js("document.cookie"), + "ts": ts, + } + await navigate(cdp, url) + doc_cookies[name]["after"] = await cdp.eval_js("document.cookie") + snapshots.append({"page": name, "url": url, "time": ts, + "cookies": await cdp.call("Network.getAllCookies")}) + print(f"[harvest] {tag}/{name} done: {url} doc_cookie_len=" + f"{len(doc_cookies[name]['after'] or '')}", flush=True) + + # 按事件到达次序输出请求列表 + req_list = sorted(requests.values(), key=lambda r: r.get("seq", 10**9)) + + out = OUT_DIR / tag + out.mkdir(parents=True, exist_ok=True) + (out / "requests.json").write_text( + json.dumps(req_list, ensure_ascii=False, indent=1) + ) + (out / "set_cookies.json").write_text( + json.dumps(resp_extra, ensure_ascii=False, indent=1) + ) + (out / "doc_cookies.json").write_text( + json.dumps(doc_cookies, ensure_ascii=False, indent=1) + ) + (out / "snapshots.json").write_text( + json.dumps(snapshots, ensure_ascii=False, indent=1) + ) + print(f"[harvest] {tag} 已写出到 {out}/") + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +async def probe_anon(profile: Path, port: int): + """复用已有 profile:复放 udblgn 匿名流程,解密请求/响应体。 + + 抓取会话内 anonymousLogin 与 web/middle 的 postData 与响应体, + 然后带全套 Cookie 用 requests 独立重放 anonymousLogin,检查 + 服务端是否在匿名流程中下发 guid/udb_guiddata/匿名 token 等字段。 + """ + proc, ws_url = launch(profile, port) + try: + async with websockets.connect(ws_url, max_size=64 * 1024 * 1024) as raw_ws: + cdp = Cdp(raw_ws) + async with cdp: + await cdp.call("Network.enable") + await cdp.call("Page.enable") + await cdp.call("Runtime.enable") + + anon: list[dict] = [] # anonymousLogin / middle 请求详情 + rid_map: dict[str, dict] = {} + + def on_anon_req(msg): + if msg.get("method") != "Network.requestWillBeSent": + return + params = msg["params"] + req = params["request"] + url = req.get("url", "") + if "anonymousLogin" not in url and "/web/middle/" not in url: + return + entry = { + "url": url, + "method": req.get("method", ""), + "postData": req.get("postData", ""), + "headers": {k: v for k, v in req.get("headers", {}).items() + if k.lower() in ("cookie", "content-type", "referer", "origin")}, + "body": None, + "status": None, + } + rid_map[params.get("requestId", "")] = entry + anon.append(entry) + print(f"[probe] 抓到匿名请求: {req.get('method')} {url[:120]}", flush=True) + + cdp.listeners.append(on_anon_req) + + def on_anon_resp(msg): + if msg.get("method") != "Network.responseReceived": + return + rid = msg["params"].get("requestId", "") + entry = rid_map.get(rid) + if entry is None: + return + entry["status"] = msg["params"].get("response", {}).get("status") + + cdp.listeners.append(on_anon_resp) + + await navigate(cdp, PAGES[0][1], settle=14.0) + + # 抓响应体 + for rid, entry in rid_map.items(): + try: + body = await cdp.call("Network.getResponseBody", {"requestId": rid}) + entry["body"] = body.get("body", "") + print(f"[probe] 响应体 {len(entry['body'])}B status={entry['status']}", + flush=True) + except Exception as exc: # noqa: BLE001 + print(f"[probe] 响应体获取失败: {exc}", flush=True) + + # 全量 Cookie -> jar(含 httpOnly;Domain 匹配规则简化:host 以 domain 结尾即适用) + cookies = (await cdp.call("Network.getAllCookies")).get("cookies", []) + jar = {} + for c in cookies: + dom = str(c.get("domain", "")).lstrip(".") + if "huya.com" not in dom: + continue + jar.setdefault(dom, {})[c["name"]] = c["value"] + print(f"[probe] cookie jar 域名: {sorted(jar)}") + + out = OUT_DIR / "probe" + out.mkdir(parents=True, exist_ok=True) + + def cookie_header(host: str) -> str: + parts = [] + for dom, kv in jar.items(): + if host.endswith(dom) or host == dom: + parts.extend(f"{k}={v}" for k, v in kv.items()) + return "; ".join(parts) + + import requests as pyrequests + + replay = [] + for entry in anon: + host = urllib.parse.urlparse(entry["url"]).netloc + headers = {"User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/149.0.0.0 Safari/537.36" + )} + ck = cookie_header(host) + if ck: + headers["Cookie"] = ck + if entry["postData"]: + headers.setdefault("Content-Type", "application/x-www-form-urlencoded") + try: + resp = await asyncio.to_thread( + pyrequests.request, + entry["method"] or "GET", + entry["url"], + headers=headers, + data=entry["postData"] or None, + timeout=15, + allow_redirects=False, + ) + replay.append({ + "url": entry["url"][:160], + "method": entry["method"], + "status": resp.status_code, + "set_cookie": resp.headers.get("Set-Cookie", ""), + "location": resp.headers.get("Location", ""), + "body_head": resp.text[:600], + "body_len": len(resp.content), + }) + print(f"[probe] 重放 {entry['method']} {entry['url'][:90]} " + f"-> {resp.status_code} setcookie={bool(resp.headers.get('Set-Cookie'))}", + flush=True) + except Exception as exc: # noqa: BLE001 + replay.append({"url": entry["url"][:160], "error": str(exc)}) + print(f"[probe] 重放失败: {exc}", flush=True) + + (out / "anon_requests.json").write_text( + json.dumps(anon, ensure_ascii=False, indent=1) + ) + (out / "replay.json").write_text( + json.dumps(replay, ensure_ascii=False, indent=1) + ) + print(f"[probe] 已写出到 {out}/") + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +def build_report(): + """汇总 round1/round2,生成 Cookie 字段来源矩阵报告。""" + r1 = OUT_DIR / "round1" + r2 = OUT_DIR / "round2" + if not (r1 / "requests.json").exists(): + print("先运行 round1", file=sys.stderr) + sys.exit(2) + + req1 = json.loads((r1 / "requests.json").read_text()) + req2 = json.loads((r2 / "requests.json").read_text()) if (r2 / "requests.json").exists() else [] + setc1 = json.loads((r1 / "set_cookies.json").read_text()) + snap2 = json.loads((r2 / "snapshots.json").read_text()) if (r2 / "snapshots.json").exists() else [] + doc1 = json.loads((r1 / "doc_cookies.json").read_text()) + + # 每个 cookie 名在 round1 的首次出现位置(按请求顺序) + first_seen: dict[str, dict] = {} + for i, req in enumerate(req1): + for name in req.get("cookie_names", []): + if name not in first_seen: + first_seen[name] = {"idx": i, "host": req["host"], "url": req["url"], "type": req["type"]} + + # Set-Cookie 写入的 cookie + set_from: dict[str, str] = {} + for sc in setc1: + set_from.setdefault(sc["cookie"], sc["url"]) + + # round2 首请求携带(持久化证据) + persistent: set[str] = set() + if req2: + for req in req2: + for name in req.get("cookie_names", []): + persistent.add(name) + + # 最终状态(round2 快照) + final_cookies: dict[str, dict] = {} + if snap2: + for snap in snap2: + for c in snap.get("cookies", {}).get("cookies", []): + final_cookies.setdefault(c["name"], c) + + def fmt_first(info: dict | None): + if not info: + return "未见来源" + return f'#{info["idx"]} {info["host"]} ({info["type"]})' + + lines = [ + "# 浏览器 Cookie 字段来源分析(真实 Chrome 抓包,未登录)", + "", + f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}", + f"页面序列: {', '.join(f'{n}={u}' for n, u in PAGES)}", + "", + "## 9.1 网页设备态必需字段(WEB_DEVICE_COOKIE_KEYS 对齐)", + "", + "| 字段 | 首次出现(round1) | 是否 Set-Cookie 下发 | round2 重启后首请求携带 | 最终 httpOnly |", + "| --- | --- | --- | --- | --- |", + ] + for name in WEB_DEVICE_KEYS: + info = first_seen.get(name) + set_url = set_from.get(name) + persisted = "✅" if name in persistent else "—" + http_only = "✅" if final_cookies.get(name, {}).get("httpOnly") else "—" + lines.append( + f"| `{name}` | {fmt_first(info)} | {set_url or '—'} | {persisted} | {http_only} |" + ) + + lines += [ + "", + "## round1 全部请求(前 60,含 Cookie 头字段数)", + "", + "| # | host | type | Cookie 字段 |", + "| --- | --- | --- | --- |", + ] + for i, req in enumerate(req1[:60]): + names = ", ".join(req.get("cookie_names", [])[:12]) + names_all = req.get("cookie_names", []) + extra = f" +{len(names_all)-12}" if len(names_all) > 12 else "" + lines.append(f"| {i} | {req['host']} | {req['type']} | {names}{extra} |") + + lines += [ + "", + "## Set-Cookie 写入清单(round1)", + "", + ] + for sc in setc1: + lines.append(f"- `{sc['cookie']}` <- {sc['url'][:100]} `{sc['meta'][:100]}`") + + lines += [ + "", + "## round1 之后 document.cookie(页面脚本可得,非 httpOnly)", + "", + ] + seen = set() + for page, v in doc1.items(): + after = v.get("after") or "" + names = [p.split("=", 1)[0].strip() for p in after.split(";") if p.strip()] + diff = [n for n in names if n not in seen] + seen.update(names) + lines.append(f"- {page}: before={bool(v.get('before'))} after={len(names)} 字段" + f" 新增={', '.join(diff) if diff else '无'}") + lines += ["", "## round2 重启后首请求即携带的字段(浏览器持久态)", ""] + if req2: + pk = [] + for req in req2[:8]: + names = req.get("cookie_names", []) + pk.append(f"- {req['host']} ({req['type']}): {', '.join(names[:15])}") + lines += pk or ["(无持久 Cookie)"] + else: + lines += ["(未运行 round2)"] + lines += ["", "## 其他出现过的字段(round1 全字段集合)", ""] + all_names = sorted( + {n for r in req1 for n in r.get("cookie_names", [])} | set(set_from) | set(seen) + ) + lines.append("`" + "`, `".join(all_names) + "`") + + OUT_DIR.mkdir(parents=True, exist_ok=True) + (OUT_DIR / "report.md").write_text("\n".join(lines) + "\n") + print(f"[harvest] 报告已写出: {OUT_DIR / 'report.md'}") + print("\n".join(lines[:40])) + + +def main(): + global OUT_DIR + ap = argparse.ArgumentParser() + ap.add_argument("mode", choices=["round1", "round2", "probe", "report"]) + ap.add_argument("--profile", default="/tmp/huya-browser-p1") + ap.add_argument("--port", type=int, default=9333) + ap.add_argument("--out", default=str(OUT_DIR)) + args = ap.parse_args() + OUT_DIR = Path(args.out) + profile = Path(args.profile) + if args.mode in ("round1", "round2"): + if args.mode == "round1" and (profile / "Default").exists(): + print("round1 要求全新 profile;请删除", profile) + sys.exit(2) + if args.mode == "round2" and not (profile / "Default").exists(): + print("round2 需要先运行 round1(同一 profile)") + sys.exit(2) + asyncio.run(capture_round(profile, args.mode, args.port)) + elif args.mode == "probe": + # 匿名流程只在"无 udb_deviceid 持久态"的全新 profile 触发; + # probe 模式内部使用临时全新 profile,跑完自动清理。 + import shutil + import tempfile + + tmp = tempfile.mkdtemp(prefix="huya-probe-") + try: + asyncio.run(probe_anon(Path(tmp), args.port)) + finally: + shutil.rmtree(tmp, ignore_errors=True) + else: + build_report() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_huya_anon_device.py b/tests/test_huya_anon_device.py new file mode 100644 index 0000000..fc5ff0b --- /dev/null +++ b/tests/test_huya_anon_device.py @@ -0,0 +1,132 @@ +"""core/huya/anon_device.py 的协议装配与解析测试(mock 网络,不打真实请求)。""" + +from unittest.mock import Mock + +import pytest +import requests + +from core.huya.anon_device import ( + ANON_LOGIN_URL, + AnonymousDeviceError, + fetch_anonymous_device_state, +) + + +def _fake_response(status=200, headers=None, text="", json_body=None): + resp = Mock(spec=requests.Response) + resp.status_code = status + resp.headers = headers or {} + resp.text = text + resp.json = Mock(return_value=json_body) if json_body is not None else Mock( + side_effect=ValueError("bad json") + ) + return resp + + +def _fake_anon_body(uid="1471255709907", biztoken="A" * 344): + return { + "uri": 10014, + "returnCode": 0, + "message": "success", + "data": {"uid": uid, "biztoken": biztoken}, + } + + +def test_fetches_four_fields_from_middle_and_anon(monkeypatch): + session = Mock(spec=requests.Session) + deviceid = "w_1147246591246680065" + session.get.return_value = _fake_response( + headers={"Set-Cookie": f"udb_deviceid={deviceid}; Max-Age=315360000; Domain=huya.com"} + ) + session.post.return_value = _fake_response(json_body=_fake_anon_body()) + monkeypatch.setattr("core.huya.anon_device._new_session", lambda **kw: session) + + state = fetch_anonymous_device_state(session=session) + + assert state["udb_deviceid"] == deviceid + assert state["udb_anouid"] == "1471255709907" + assert state["udb_anobiztoken"] == "A" * 344 + assert len(state["udb_guiddata"]) == 32 + + # middle 请求装配:URL 含 8 位随机数与 32hex guiddata + get_url = session.get.call_args.args[0] + parts = get_url.split("/") + assert len(parts[-1]) == 32 + assert len(parts[-3]) == 8 + assert parts[-3].isdigit() + assert state["udb_guiddata"] == parts[-1] + + # anonymousLogin 装配:必须带 middle Referer;body 含随机 context/csid + post_kwargs = session.post.call_args.kwargs + assert session.post.call_args.args[0] == ANON_LOGIN_URL + assert post_kwargs["headers"]["Referer"] == get_url + import json as _json + + payload = _json.loads(post_kwargs["data"]) + assert payload["context"].startswith("WB-") + assert payload["sdid"].startswith("csid_") + + +def test_values_are_random_across_calls(monkeypatch): + session = Mock(spec=requests.Session) + session.get.return_value = _fake_response( + headers={"Set-Cookie": "udb_deviceid=w_1147246591246680065; Max-Age=1"} + ) + session.post.return_value = _fake_response(json_body=_fake_anon_body(uid="1")) + monkeypatch.setattr("core.huya.anon_device._new_session", lambda **kw: session) + + import json as _json + + a = fetch_anonymous_device_state(session=session) + b = fetch_anonymous_device_state(session=session) + + # 客户端随机部分:guiddata(middle URL) 与 anonymousLogin 的 context/csid + assert a["udb_guiddata"] != b["udb_guiddata"] + post_a = _json.loads(session.post.call_args_list[0].kwargs["data"]) + post_b = _json.loads(session.post.call_args_list[1].kwargs["data"]) + assert post_a["context"] != post_b["context"] + assert post_a["sdid"] != post_b["sdid"] + + +def test_bad_middle_status_raises(monkeypatch): + session = Mock(spec=requests.Session) + session.get.return_value = _fake_response(status=403) + monkeypatch.setattr("core.huya.anon_device._new_session", lambda **kw: session) + + with pytest.raises(AnonymousDeviceError): + fetch_anonymous_device_state(session=session) + + +def test_missing_deviceid_in_set_cookie_raises(monkeypatch): + session = Mock(spec=requests.Session) + session.get.return_value = _fake_response(headers={"Set-Cookie": "PHPSESSID=x"}) + monkeypatch.setattr("core.huya.anon_device._new_session", lambda **kw: session) + + with pytest.raises(AnonymousDeviceError, match="udb_deviceid"): + fetch_anonymous_device_state(session=session) + + +def test_anon_error_return_code_raises(monkeypatch): + session = Mock(spec=requests.Session) + session.get.return_value = _fake_response( + headers={"Set-Cookie": "udb_deviceid=w_1147246591246680065; Max-Age=1"} + ) + session.post.return_value = _fake_response( + json_body={"uri": 10014, "returnCode": 108, "message": "blocked"} + ) + monkeypatch.setattr("core.huya.anon_device._new_session", lambda **kw: session) + + with pytest.raises(AnonymousDeviceError, match="returnCode=108"): + fetch_anonymous_device_state(session=session) + + +def test_non_json_anon_body_raises(monkeypatch): + session = Mock(spec=requests.Session) + session.get.return_value = _fake_response( + headers={"Set-Cookie": "udb_deviceid=w_1147246591246680065; Max-Age=1"} + ) + session.post.return_value = _fake_response(text="error!") + monkeypatch.setattr("core.huya.anon_device._new_session", lambda **kw: session) + + with pytest.raises(AnonymousDeviceError, match="非 JSON"): + fetch_anonymous_device_state(session=session) \ No newline at end of file