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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user