真实浏览器抓包与匿名设备态协议生成器
This commit is contained in:
@@ -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())
|
||||
Executable
+592
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user