"""米哈游活动页适配器。 事实基线(2026-09-20 对 6 个页面的实爬与逆向,卷宗见 .scratch/mhy-recon/REPORT.md): * **骨架数据一律内联在入口 bundle 里**——6 个页面里 `.atlas` / `.skel` 的网络请求数是 0。 只有 hsr 的 kv45 例外:它的 skeleton json 走网络(页面根目录 `.json`),atlas 仍是内联。 * 内联有三种家族: - **A**(nico-tea / back-moon / zhidong-wonder): ``Object.values(Object.assign({"…/spine/.json":{…}}))[0]``,值是 **JS 对象字面量**(键不带引号)。 - **B**(get-memory / start-ndkl):atlas 与骨架都是匿名 webpack 模块,靠配对表 ``"":{atlas:(),json:()}`` 关联。 - **C**(kv45):``spineSetting`` 表里 ``{atlas:"<文本>", json:<模块变量>}``。 * **贴图页走网络**,URL 由 bundle 里的资源表给出,形如 ``images/<逻辑名>...png``(ys)与 ``assets/images/<逻辑名>...png``(hsr)。 逻辑名就是 atlas 第一行的页名——**去 hash 就是把它换回逻辑名**。 * 同一逻辑名可能有多个候选(引擎有桌面/移动两套预载表)。判据:**数组式描述表是基准集, 字典式表是移动端覆盖**(引擎里是 `desktop() || base.forEach(e => override[e.id] && …)`), 桌面取基准集。 """ from __future__ import annotations import json import re import urllib.error import urllib.request from dataclasses import dataclass, field from pathlib import Path from typing import Any from .. import atlas as atlas_mod from .. import jslit from .. import scene as scene_mod __all__ = ["Site", "PageData", "SpineAsset", "match", "fetch_page", "HttpError"] _DATA_URL = re.compile(r"^data:(?P[^;,]+)(?P;base64)?,(?P.*)$", re.DOTALL) _MIME_EXT = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"} def decode_data_url(url: str) -> bytes: """解内联 data: URL(webpack 把小于阈值的图直接塞进 bundle)。""" import base64 m = _DATA_URL.match(url) if m is None: raise HttpError(f"不是合法的 data: URL:{url[:40]}…") body = m.group("body") if m.group("b64"): return base64.b64decode(body) from urllib.parse import unquote_to_bytes return unquote_to_bytes(body) def image_ext(rel: str) -> str: """资源路径 → 落盘扩展名(data: URL 走 MIME 判定)。""" if rel.startswith("data:"): mime = rel[5:].split(";", 1)[0].split(",", 1)[0] return _MIME_EXT.get(mime, ".png") ext = Path(rel.split("?")[0]).suffix.lower() return ext if ext in (".png", ".webp", ".jpg", ".jpeg", ".gif") else ".png" _UA = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/146.0.0.0 Safari/537.36" ) _TIMEOUT = 30 class HttpError(RuntimeError): """抓取失败(离线缺缓存、HTTP 非 200、网络异常)。""" @dataclass(frozen=True) class Site: """站点标识。""" id: str game: str host: str path_prefix: str @dataclass class SpineAsset: """一具骨架的抓取素材。""" name: str atlas_text: str pages: list[str] version: str | None = None animations: list[str] = field(default_factory=list) skins: list[str] = field(default_factory=list) json_data: dict[str, Any] | None = None # 内联家族 json_rel: str | None = None # 走网络时相对页面目录的路径 json_text: str | None = None # 已下载/待下载的原文 def as_meta(self, source: str) -> dict[str, Any]: return { "name": self.name, "spine": self.version, "animations": self.animations, "skins": self.skins, "pages": self.pages, "source": source, } @dataclass class PageData: """一个页面的全部抓取素材。""" id: str game: str url: str site: Site entry_url: str | None = None bundles: dict[str, str] = field(default_factory=dict) spines: dict[str, SpineAsset] = field(default_factory=dict) scenes: list[scene_mod.Scene] = field(default_factory=list) images: dict[str, str] = field(default_factory=dict) # 逻辑名 → 相对页面目录的路径 geometries: dict[str, Any] = field(default_factory=dict) # glTF 网格哈希 → 网格数据 timeline: dict[str, dict[str, dict[str, list[float]]]] = field(default_factory=dict) # 场景 → 轨道末帧 warnings: list[str] = field(default_factory=list) @property def base_url(self) -> str: return self.url.rsplit("/", 1)[0] + "/" def asset_url(self, rel: str) -> str: """相对路径 → 绝对 URL;内联 data: URL 原样返回。""" if rel.startswith("data:"): return rel return self.base_url + rel.lstrip("/") # -------------------------------------------------------------------------------------- # HTTP + 缓存 # -------------------------------------------------------------------------------------- def _cache_path(cache_dir: Path, page_id: str, rel: str) -> Path: safe = rel.replace("/", "__").split("?")[0] return cache_dir / page_id / safe def http_get(url: str, cache_dir: Path | None, *, page_id: str = "_", rel: str | None = None, offline: bool = False, binary: bool = False) -> bytes: """取一个 URL,带磁盘缓存。``offline=True`` 时只用缓存,缺了就报错。""" if url.startswith("data:"): return decode_data_url(url) path = _cache_path(cache_dir, page_id, rel or url) if cache_dir else None if path is not None and path.exists(): return path.read_bytes() if offline: raise HttpError(f"离线模式但缓存缺失:{url}") req = urllib.request.Request(url, headers={"User-Agent": _UA, "Referer": url}) try: with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: if resp.status != 200: raise HttpError(f"HTTP {resp.status}:{url}") data = resp.read() except urllib.error.HTTPError as exc: raise HttpError(f"HTTP {exc.code}:{url}") from exc except urllib.error.URLError as exc: raise HttpError(f"网络错误:{url}({exc.reason})") from exc if path is not None: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) return data def http_get_text(url: str, cache_dir: Path | None, **kw: Any) -> str: return http_get(url, cache_dir, **kw).decode("utf-8", errors="replace") # -------------------------------------------------------------------------------------- # 通用解析辅助 # -------------------------------------------------------------------------------------- _STRING = re.compile(r'"(?:[^"\\]|\\.)*"') _PUBLIC_PATH = r"[A-Za-z_$][\w$]*\.p" _ASSET_LITERAL = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*' + _PUBLIC_PATH + r'\s*\+\s*"([^"]+)"') _MODULE_LITERAL = re.compile( r'(\d+):function\([^)]*\)\s*\{[^}]*?e\.exports\s*=\s*' + _PUBLIC_PATH + r'\s*\+\s*"([^"]+)"' ) # 模块直接导出**字符串**:小图被 webpack 内联成 `e.exports="data:image/png;base64,\u2026"`,也有 # `e.exports="images/x.png"` 这种不带公共路径前缀的写法。描述表里写 `src:a(38458)` 时只能靠 # 这张表还原——不认它就会把"内联的图"当成"根本没引用"(实测 get-memory 的 loading_moutain_a 与 # a01_lizi、start-ndkl 的 loading_start_1 都栽在这里,它们是真资源,不是运行时残留)。 _MODULE_STRING = re.compile(r'(\d+)\s*:\s*function\s*\([^)]*\)\s*\{[^}]*?e\.exports\s*=\s*"((?:[^"\\]|\\.)*)"') # `src:a(38458)`:直接调 require 函数、参数是模块号。 _REQUIRE_CALL = re.compile(r"\s*[A-Za-z_$][\w$]*\s*\(\s*(\d+)\s*\)\s*") # `$w="data:image/png;base64,\u2026"`:变量直接存字符串字面量。描述表里写 `{src:$w,…}` 时靠它还原 # (kv45 的 loading_dt1 就是这样,不认它就会被当成"缺资源"直接判红)。 _STRING_ASSIGN = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*"((?:[^"\\]|\\.)*)"') _REQUIRE_ID = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*n\((\d+)\)') _REQUIRE_URL_ID = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*new URL\(n\((\d+)\)') _REQUIRE_NS = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*n\.n\(([A-Za-z_$][\w$]*)\)') # 描述表有两种 src 形状,分开匹配——一条 `.+?` 通吃会让它跨过整段 bundle 去够后面那条 # `,id:"…",type:"image"`:kv45 的 `{src:e.activeIcon,alt:""}` 就是这么把 # `{src:$w,id:"loading_dt1",type:"image"}` 整个吞掉的(真表项永远收集不到)。 # inline:`Object.values(Object.assign({"<路径>":"data:…"}))[0]`(webpack 内联的小图) # simple:`a(38458)` / `$w` / `a.p+"images/x.png"` 这类不含 `,` `{` `}` 的表达式 _ASSIGN_EXPR = r'Object\.values\(Object\.assign\(\{[^{}]*\}\)\)\[0\]' _DESCRIPTOR_INLINE = re.compile(r'\{src:(' + _ASSIGN_EXPR + r'),id:"([^"]+)",type:"image"\}') _DESCRIPTOR_SIMPLE = re.compile(r'\{src:([^,{}]+),id:"([^"]+)",type:"image"\}') # webpack 会把小图内联:`Object.values(Object.assign({"<源路径>":"data:image/png;base64,…"}))[0]` _DATA_IN_ASSIGN = re.compile(r'Object\.values\(Object\.assign\(\{"[^"]*":("(?:[^"\\]|\\.)*")\}\)\)') _OVERRIDE_TABLE = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*\{\s*"([^"]+)"\s*:\s*([A-Za-z_$][\w$]*)\(\)') def _unescape_js_string(raw: str) -> str: """把 ``"…"``(含两端引号)还原成文本。""" return json.loads(raw) def _literal_of(expr: str) -> str | None: """从 ``Object.values(Object.assign({"<源路径>":}))[0]`` 这类表达式里取出变量名。""" m = re.search(r'Object\.values\(Object\.assign\(\{[^:]*:([A-Za-z_$][\w$]*)\}\)\)', expr) if m: return m.group(1) m = re.fullmatch(r"([A-Za-z_$][\w$]*)\(\)", expr.strip()) if m: return m.group(1) m = re.fullmatch(r"([A-Za-z_$][\w$]*)", expr.strip()) if m: return m.group(1) return None class _AssetResolver: """把 bundle 里的变量/模块引用解析成真正的资源路径。 压缩后的变量名在**模块作用域**内唯一,所以回退查找要限定在同一个 webpack 模块里; 这里用"从引用处往前找最近的模块头"作为边界,避免跨模块误配。 """ def __init__(self, text: str) -> None: self.text = text self.var_to_literal = {m.group(1): m.group(2) for m in _ASSET_LITERAL.finditer(text)} self.module_to_literal = {m.group(1): m.group(2) for m in _MODULE_LITERAL.finditer(text)} self.module_to_string: dict[str, str] = {} for m in _MODULE_STRING.finditer(text): try: # 捕获组里没有引号,而 _unescape_js_string 要的是含引号的原文。 value = _unescape_js_string('"' + m.group(2) + '"') except json.JSONDecodeError: continue # 只认"看起来是资源"的字符串导出:atlas 文本与内联 JSON 也是字符串导出, # 把它们当路径只会造出一条永远下载失败的假引用。 if value.startswith("data:") or value.lower().endswith((".png", ".webp", ".jpg", ".jpeg", ".gif")): self.module_to_string.setdefault(m.group(1), value) self.var_to_string: dict[str, str] = {} for m in _STRING_ASSIGN.finditer(text): raw = m.group(2) if not (raw.startswith("data:") or raw.lower().endswith((".png", ".webp", ".jpg", ".jpeg", ".gif"))): continue try: self.var_to_string.setdefault(m.group(1), _unescape_js_string('"' + raw + '"')) except json.JSONDecodeError: continue self.var_to_module = {m.group(1): m.group(2) for m in _REQUIRE_ID.finditer(text)} self.var_to_module.update({m.group(1): m.group(2) for m in _REQUIRE_URL_ID.finditer(text)}) self.ns_to_var = {m.group(1): m.group(2) for m in _REQUIRE_NS.finditer(text)} def resolve_var(self, var: str) -> str | None: if var in self.var_to_literal: return self.var_to_literal[var] if var in self.var_to_string: return self.var_to_string[var] target = self.ns_to_var.get(var, var) if target in self.var_to_literal: return self.var_to_literal[target] module_id = self.var_to_module.get(target) if module_id is not None: return self.module_to_literal.get(module_id) return None def resolve_expr(self, expr: str) -> str | None: inline = _DATA_IN_ASSIGN.search(expr) if inline is not None: try: return _unescape_js_string(inline.group(1)) except json.JSONDecodeError: return None # `src:a(38458)` 这种直接调 require 的写法:模块导出可能是 `p+"\u2026"`,也可能是内联 data:。 call = _REQUIRE_CALL.fullmatch(expr) if call is not None: module_id = call.group(1) return self.module_to_literal.get(module_id) or self.module_to_string.get(module_id) var = _literal_of(expr) if var is None: return None return self.resolve_var(var) def _collect_geometries(text: str) -> dict[str, Any]: """收集内联的 glTF 网格表(`geometries:{<哈希>:{type,attributes:{position:{array}}}}`)。 场景节点里 `geometry:{type:1,id:"<哈希>"}` 的尺寸不写在场景树上,只能来这里算顶点包围盒。 键有时带引号(JSON.parse 的模块)有时不带(JS 字面量),所以两种都要认。 """ out: dict[str, Any] = {} for m in re.finditer(r"geometries", text): brace = text.find("{", m.end()) if brace < 0 or brace - m.end() > 4: continue end = jslit.match_literal(text, brace) if end < 0: continue try: data = jslit.loads(text[brace : end + 1]) except jslit.JsLitError: continue if isinstance(data, dict): for key, value in data.items(): if isinstance(value, dict) and key not in out: out[key] = value return out _TRACK = re.compile(r'name:"([\w.]+)\.(position|scale)"') _CLIP_START = "{type:" # 场景自己在 modifier 里点名播哪条轨道:playTimeline{sceneName, trackName1..6, frame1..6} _PLAY_TIMELINE = re.compile(r'playTimeline",data:\{sceneName:"([\w-]+)",([^}]*)\}') _TRACK_ENTRY = re.compile(r'trackName(\d+):"([\w-]*)"') _FRAME_ENTRY = re.compile(r"frame(\d+):(\d+)") def _block_tracks( text: str, block: str, frame: int | None = None ) -> dict[str, dict[str, list[float]]]: """取某个**块**(如 `pv`)里 position/scale 轨道在**指定帧**的值。 `frame=None` 时取末帧(块的结束状态);给了帧就取该帧(场景声明的 `frame1`)。 为什么按块取:同名节点(layout/bg/img)在 `loading` 与 `pv` 里各有自己的关键帧, 全局扫名字会把**入场块**的值套到主场景上(实测差得很远:layout.z 539 vs 439,x/y 也不同)。 源码依据:`parsePath` 把 `a.b` 解析成 `getObjectByName("a")` 再写 `.b`——轨道名第一段是节点名,值是**覆盖**。 """ positions: dict[str, list[float]] = {} scales: dict[str, list[float]] = {} anchor = text.find(f"{{{block}:{{type:1,") if anchor < 0: return {"positions": positions, "scales": scales} end = jslit.match_literal(text, anchor) if end < 0: return {"positions": positions, "scales": scales} for m in _TRACK.finditer(text, anchor, end): name, prop = m.group(1), m.group(2) back = text.rfind(_CLIP_START, anchor, m.start()) if back < 0 or m.start() - back > 4000: continue clip_end = jslit.match_literal(text, back) if clip_end < 0 or clip_end > end: continue try: clip = jslit.loads(text[back : clip_end + 1]) except jslit.JsLitError: continue if not isinstance(clip, dict) or clip.get("name") != f"{name}.{prop}": continue data = clip.get("data") or [] if not isinstance(data, list) or not data: continue frames = (data[-1] or {}).get("frames") or [] if not isinstance(frames, list) or not frames: continue # 帧号语义:块的 clip 从 start 起算,`frames` 是整个区间的关键帧序列。 # 场景声明的帧是「从这条轨道第几帧开始」,直接按该下标取(越界则回退到首/末)。 value = frames[frame] if frame is not None and 0 <= frame < len(frames) else frames[-1] if not isinstance(value, list): continue target = positions if prop == "position" else scales target.setdefault(name, [float(v) for v in value if isinstance(v, (int, float))]) return {"positions": positions, "scales": scales} def _collect_timeline(text: str) -> dict[str, dict[str, dict[str, list[float]]]]: """按场景归并:`{场景id: {positions: {...}, scales: {...}}}`。 场景播哪条轨道由它自己的 `playTimeline` modifier 声明;只取那条轨道的末帧(= 稳定态)。 """ per_scene: dict[str, dict[str, dict[str, list[float]]]] = {} cache: dict[str, dict[str, dict[str, list[float]]]] = {} for m in _PLAY_TIMELINE.finditer(text): scene, body = m.group(1), m.group(2) bucket = per_scene.setdefault(scene, {"positions": {}, "scales": {}}) frames_by_slot = {entry.group(1): int(entry.group(2)) for entry in _FRAME_ENTRY.finditer(body)} for entry in _TRACK_ENTRY.finditer(body): track = entry.group(2) if not track: continue frame = frames_by_slot.get(entry.group(1)) key = f"{track}@{frame}" if key not in cache: cache[key] = _block_tracks(text, track, frame) for kind in ("positions", "scales"): for node, vec in cache[key][kind].items(): bucket[kind].setdefault(node, vec) return per_scene def _collect_images(text: str) -> dict[str, str]: """从 bundle 的预载描述表里取「逻辑名 → 资源路径」。 数组式描述表(``[{src:…,id:"x",type:"image"}]``)是基准集,先收;字典式 (``{"x":fn()}``)是引擎在非桌面端才套用的覆盖集,只在没有基准候选时兜底。 """ resolver = _AssetResolver(text) images: dict[str, str] = {} for pattern in (_DESCRIPTOR_INLINE, _DESCRIPTOR_SIMPLE): for m in pattern.finditer(text): expr, name = m.group(1), m.group(2) rel = resolver.resolve_expr(expr) if rel and name not in images: images[name] = rel for m in _OVERRIDE_TABLE.finditer(text): var, name = m.group(3), m.group(2) rel = resolver.resolve_var(var) if rel and name not in images: images[name] = rel # 兜底:描述表没覆盖到的名字,直接用 `n.p+"images/<逻辑名>...png"` 字面量。 # ys 页 99 个字面量与 99 个逻辑名一一对应(无重名),这条兜底因此是确定的; # hsr 有桌面/移动两套候选,兜底只取先出现的那个。 for m in _ASSET_LITERAL.finditer(text): rel = m.group(2) if not re.match(r"^(assets/)?images/", rel): continue stem = rel.rsplit("/", 1)[-1].split(".")[0] images.setdefault(stem, rel) return images # -------------------------------------------------------------------------------------- # 骨架提取:三个内联家族 # -------------------------------------------------------------------------------------- _A_JSON = re.compile(r'Object\.values\(Object\.assign\(\{"((?:\.\./\.\.|/)[^"]*?/spine/[\w.-]+\.json)":') _A_ATLAS = re.compile(r'Object\.values\(Object\.assign\(\{"((?:\.\./\.\.|/)[^"]*?/spine/[\w.-]+\.atlas)":') _B_ATLAS_MODULE = re.compile(r'(\d+):function\(([\w,\s]*)\)\{"?(?:use strict"?;)?e\.exports=("(?:[^"\\]|\\.)*")\}') _B_JSON_MODULE = re.compile(r'(\d+):function\(([\w,\s]*)\)\{"?(?:use strict"?;)?e\.exports=JSON\.parse\(') _B_PAIR = re.compile(r'"?([\w\u4e00-\u9fa5@-]+)"?:\{atlas:([A-Za-z_$][\w$]*)\((\d+)\),json:\2\((\d+)\)\}') _C_SPINE = re.compile(r'"?([\w@-]+)"?:\{atlas:"((?:[^"\\]|\\.)*)",json:([A-Za-z_$][\w$]*)\}') def _summary(data: dict[str, Any]) -> tuple[str | None, list[str], list[str]]: skeleton = data.get("skeleton") or {} version = skeleton.get("spine") if isinstance(skeleton, dict) else None animations = sorted((data.get("animations") or {}).keys()) if isinstance(data.get("animations"), dict) else [] raw_skins = data.get("skins") if isinstance(raw_skins, list): skins = [s.get("name") if isinstance(s, dict) else s for s in raw_skins] elif isinstance(raw_skins, dict): skins = list(raw_skins.keys()) else: skins = [] return version, animations, [s for s in skins if isinstance(s, str)] def _extract_family_a(text: str) -> dict[str, SpineAsset]: assets: dict[str, SpineAsset] = {} for m in _A_JSON.finditer(text): name = m.group(1).rsplit("/", 1)[-1][: -len(".json")] brace = text.find("{", m.end()) if brace < 0: continue end = jslit.match_literal(text, brace) if end < 0: continue data = jslit.loads(text[brace : end + 1]) if not isinstance(data, dict): continue version, animations, skins = _summary(data) assets[name] = SpineAsset( name=name, atlas_text="", pages=[], version=version, animations=animations, skins=skins, json_data=data, ) for m in _A_ATLAS.finditer(text): name = m.group(1).rsplit("/", 1)[-1][: -len(".atlas")] quote = m.end() # 正则正好停在值字符串的左引号上 if quote >= len(text) or text[quote] != '"': continue end = quote + 1 while end < len(text): if text[end] == "\\": end += 2 elif text[end] == '"': break else: end += 1 atlas_text = _unescape_js_string(text[quote : end + 1]) info = atlas_mod.parse(atlas_text) asset = assets.get(name) if asset is None: continue asset.atlas_text = atlas_text asset.pages = info.pages return {k: v for k, v in assets.items() if v.atlas_text} def _extract_family_b(text: str) -> dict[str, SpineAsset]: atlas_modules: dict[str, str] = {} for m in _B_ATLAS_MODULE.finditer(text): try: value = _unescape_js_string(m.group(3)) except json.JSONDecodeError: continue first = (value.splitlines() or [""])[0] if re.search(r"\.(png|webp|jpg|jpeg)\s*$", first, re.IGNORECASE) and re.search( r"^\s*(size|filter|format)\s*:", value, re.IGNORECASE | re.MULTILINE ): atlas_modules[m.group(1)] = value json_modules: dict[str, dict[str, Any]] = {} for m in _B_JSON_MODULE.finditer(text): paren = m.end() - 1 end = jslit.match_literal(text, paren) if end < 0: continue raw = text[paren + 1 : end].strip() if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'": raw = jslit.unescape(raw[1:-1]) try: data = json.loads(raw) except json.JSONDecodeError: continue if isinstance(data, dict) and (data.get("skeleton") or {}).get("spine"): json_modules[m.group(1)] = data assets: dict[str, SpineAsset] = {} for m in _B_PAIR.finditer(text): name, atlas_id, json_id = m.group(1), m.group(3), m.group(4) if name in assets: continue data = json_modules.get(json_id) atlas_text = atlas_modules.get(atlas_id, "") if data is None or not atlas_text: continue version, animations, skins = _summary(data) assets[name] = SpineAsset( name=name, atlas_text=atlas_text, pages=atlas_mod.parse(atlas_text).pages, version=version, animations=animations, skins=skins, json_data=data, ) return assets def _extract_family_c(text: str) -> dict[str, SpineAsset]: """kv45:``{atlas:"<文本>", json:<模块变量>}``,json 走网络。""" resolver = _AssetResolver(text) assets: dict[str, SpineAsset] = {} for m in _C_SPINE.finditer(text): name, raw_atlas, var = m.group(1), m.group(2), m.group(3) if name in assets: continue try: atlas_text = _unescape_js_string(f'"{raw_atlas}"') except json.JSONDecodeError: continue rel = resolver.resolve_var(var) if not rel: continue assets[name] = SpineAsset( name=name, atlas_text=atlas_text, pages=atlas_mod.parse(atlas_text).pages, json_rel=rel, ) return assets # -------------------------------------------------------------------------------------- # 页面抓取 # -------------------------------------------------------------------------------------- _SCRIPT_SRC = re.compile(r']+src="([^"]+)"', re.IGNORECASE) _YS_PATH = re.compile(r"/(ys|bh3|hkrpg)/event/") _HSR_PATH = re.compile(r"/puzzle/hkrpg/") def match(url: str) -> Site | None: """认页面:ys 活动页与 hsr 的 puzzle 页各一个站点标识。""" if _HSR_PATH.search(url): return Site(id="mihoyo-hsr-puzzle", game="hsr", host="act.mihoyo.com", path_prefix="/puzzle/hkrpg/") if _YS_PATH.search(url): return Site(id="mihoyo-ys-event", game="ys", host="act.mihoyo.com", path_prefix="/ys/event/") if "act.mihoyo.com" in url: return Site(id="mihoyo-act", game="mihoyo", host="act.mihoyo.com", path_prefix="/") return None def _absolute(base: str, rel: str) -> str: if rel.startswith("http://") or rel.startswith("https://"): return rel if rel.startswith("//"): return "https:" + rel if rel.startswith("/"): return "https://act.mihoyo.com" + rel return base + rel.lstrip("./") def _discover_scripts(html: str, base: str) -> list[str]: return [_absolute(base, src) for src in _SCRIPT_SRC.findall(html)] _CHUNK_FN = re.compile(r"\.u\s*=\s*function\s*\([^)]*\)\s*\{") _CHUNK_PAIR = re.compile(r'(\d+)\s*:\s*"([^"]*)"') def discover_chunk_urls(text: str, base: str) -> list[str]: """从 webpack 运行时里解出异步 chunk 的 URL。 形如 ``s.u=function(e){return({416:"lib.pc",833:"lib.m"}[e]||e)+"."+{258:"ccb0954b",…}[e]+".js"}``: 取函数体里所有 ``id:"值"``,按值的样子分成**名字表**(`lib.pc` 这类含点的)与**哈希表**(纯十六进制), 再拼成 ``<名字|id>.<哈希>.js``。 """ urls: list[str] = [] for m in _CHUNK_FN.finditer(text): end = jslit.match_literal(text, m.end() - 1) if end < 0: continue body = text[m.end() : end] names: dict[str, str] = {} hashes: dict[str, str] = {} for pair in _CHUNK_PAIR.finditer(body): chunk_id, value = pair.group(1), pair.group(2) if re.fullmatch(r"[0-9a-f]{6,}", value): hashes[chunk_id] = value elif value: names[chunk_id] = value for chunk_id, digest in hashes.items(): urls.append(_absolute(base, f"{names.get(chunk_id, chunk_id)}.{digest}.js")) return urls def fetch_page(page_id: str, game: str, url: str, *, cache_dir: Path | None = None, offline: bool = False, log: Any = print) -> PageData: """抓一个页面并解析出骨架、场景、图片表。""" site = match(url) if site is None: raise HttpError(f"没有匹配的站点适配器:{url}") data = PageData(id=page_id, game=game, url=url, site=site) base = url.rsplit("/", 1)[0] + "/" html = http_get_text(url, cache_dir, page_id=page_id, rel="index.html", offline=offline) scripts = _discover_scripts(html, base) if not scripts: raise HttpError(f"页面里没有找到任何