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 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
"""站点适配层。
一个站点适配器只做三件事:**发现入口 bundle**、**从 bundle 里把资源清单抠出来**、**把逻辑名解析成
可下载的 URL**。通用管线(选择场景、落盘、自检)不认站点,只认这里吐出的数据结构——将来接别的
网站,新增一个同形状的模块即可。
"""
from __future__ import annotations
from . import mihoyo
__all__ = ["mihoyo", "for_url", "SITE_MODULES"]
SITE_MODULES = [mihoyo]
def for_url(url: str) -> mihoyo.Site | None:
"""按 URL 选站点适配器;没有匹配的返回 None。"""
for module in SITE_MODULES:
site = module.match(url)
if site is not None:
return site
return None
+730
View File
@@ -0,0 +1,730 @@
"""米哈游活动页适配器。
事实基线(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