Files
SpineWallpaper/tools/downloader/sites/mihoyo.py
T
Shuery 3f11426964 feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
2026-10-02 01:27:02 +08:00

731 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""米哈游活动页适配器。
事实基线(2026-09-20 对 6 个页面的实爬与逆向,卷宗见 .scratch/mhy-recon/REPORT.md):
* **骨架数据一律内联在入口 bundle 里**——6 个页面里 `.atlas` / `.skel` 的网络请求数是 0。
只有 hsr 的 kv45 例外:它的 skeleton json 走网络(页面根目录 `<hash>.json`),atlas 仍是内联。
* 内联有三种家族:
- **A**(nico-tea / back-moon / zhidong-wonder):
``Object.values(Object.assign({"…/spine/<N>.json":{…}}))[0]``,值是 **JS 对象字面量**(键不带引号)。
- **B**(get-memory / start-ndkl):atlas 与骨架都是匿名 webpack 模块,靠配对表
``"<N>":{atlas:<fn>(<id>),json:<fn>(<id>)}`` 关联。
- **C**(kv45):``spineSetting`` 表里 ``{atlas:"<文本>", json:<模块变量>}``。
* **贴图页走网络**,URL 由 bundle 里的资源表给出,形如
``images/<逻辑名>.<hash>..png``(ys)与 ``assets/images/<逻辑名>.<hash>.<hash>.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<mime>[^;,]+)(?P<b64>;base64)?,(?P<body>.*)$", 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({"<源路径>":<VAR>}))[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/<逻辑名>.<hash>..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'<script[^>]+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"页面里没有找到任何 <script src>:{url}")
# hsr 的骨架表在 258.*.js 这类 chunk 里,ys 在入口 index_*.js 里;两者都扫一遍更稳。
ordered = sorted(scripts, key=lambda s: (0 if re.search(r"/(index|258)\.", s) else 1, s))
# 队列而不是 for:hsr 的骨架表在 webpack 异步 chunk 里,入口 HTML 根本没列它,
# 只能边扫边把 `.u=` 映射出来的 chunk 追加进队列。
queue = list(ordered)
seen: set[str] = set()
while queue:
script_url = queue.pop(0)
if script_url in seen:
continue
seen.add(script_url)
name = script_url.rsplit("/", 1)[-1]
try:
text = http_get_text(script_url, cache_dir, page_id=page_id, rel=name, offline=offline)
except HttpError as exc:
data.warnings.append(f"跳过 {name}:{exc}")
continue
data.bundles[name] = text
# 网格表要先收:场景树里的 type-1 平面靠它算尺寸与中心。
for key, mesh in _collect_geometries(text).items():
data.geometries.setdefault(key, mesh)
for scene_id, tracks in _collect_timeline(text).items():
bucket = data.timeline.setdefault(scene_id, {"positions": {}, "scales": {}})
for kind in ("positions", "scales"):
for node, vec in tracks[kind].items():
bucket[kind].setdefault(node, vec)
if not data.scenes:
data.scenes = [
scene_mod.parse_scene(s, data.geometries, data.timeline) for s in scene_mod.find_scene_list(text)
]
for key, rel in _collect_images(text).items():
data.images.setdefault(key, rel)
if not data.spines:
spines = _extract_family_a(text) or _extract_family_b(text) or _extract_family_c(text)
if spines:
data.spines = spines
data.entry_url = script_url
if data.spines and data.scenes and data.images:
break
for chunk_url in discover_chunk_urls(text, base):
if chunk_url not in seen:
queue.append(chunk_url)
if not data.spines:
data.warnings.append("没有从任何 bundle 里提取到骨架(页面结构可能变了)")
if not data.scenes:
data.warnings.append("没有找到 sceneList(场景树)")
return data
def load_spine_json(data: PageData, asset: SpineAsset, *, cache_dir: Path | None = None,
offline: bool = False) -> dict[str, Any]:
"""取骨架数据:内联家族直接用内存里的对象,kv45 去下载 ``<hash>.json``。"""
if asset.json_data is not None:
return asset.json_data
if asset.json_rel is None:
raise HttpError(f"骨架 {asset.name} 既没有内联数据也没有 json 路径")
if asset.json_text is None:
asset.json_text = http_get_text(
data.asset_url(asset.json_rel), cache_dir, page_id=data.id, rel=asset.json_rel, offline=offline
)
parsed = json.loads(asset.json_text)
version, animations, skins = _summary(parsed)
asset.version = version
asset.animations = animations
asset.skins = skins
return parsed