Files
SpineWallpaper/tools/downloader/README.md
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

249 lines
17 KiB
Markdown
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.
# `tools/downloader/` — 米哈游活动页 Spine 抓取器
从 `wallpapers/sources.yml` 列出的活动页里把 **Spine 骨架 + 贴图 + 场景装配信息** 抓下来,
落到 staging(`_out/`)。**每个有内容的场景各产出一个「可直接搬走的壁纸目录」**——用户挑中的那份
整个移到 `wallpapers/<游戏id>/` 就能用(也可以让 `promote` 代劳)。
它不是构建输入:`wallpapers/` 只放**最终要发布**的资源,抓取脚本与临时下载都留在 `tools/`。
```bash
python -m tools.downloader fetch --page kv45 # 抓一页(结尾自动 verify,有硬伤就非 0 退出)
python -m tools.downloader fetch # 抓 sources.yml 里的全部页面
python -m tools.downloader fetch --interactive # 逐页问:这一页推荐哪个场景(不影响落盘范围)
python -m tools.downloader select --page nico-tea # 只做选择,写 selection.yml
python -m tools.downloader verify # 只自检产物
python -m tools.downloader fetch --offline # 只用 _cache/ + 已落盘的产物,不发任何请求(重跑验证)
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name 挥掷千星的筹码 --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
node tools/checks/check-downloader.mts # 门:编译 + 夹具绿 + 四处破坏必红
```
依赖:Python 3.10+ 与 **PyYAML**(`requirements.txt`)。不需要浏览器——这些页面免登录即可拿到
全量入口 bundle,骨架数据就在里面。
---
## 一、产物形状
```
tools/downloader/
├── _cache/<页面>/<脚本名> # 抓下来的 bundle 原文(文本调试缓存,可重跑零请求)
├── _out/<游戏>/<页面>/
│ ├── page.json # 页面级溯源:来源 URL / 入口脚本 / 场景清单 / 每个场景目录的摘要
│ └── <场景id>/ # ← 一个场景 = 一个可直接搬走的壁纸目录
│ ├── meta.json # id / name / title / description / game / page / scene / source / audio
│ ├── preset.template.json # 运行时配置(背景图 + sceneConfig.parts)
│ ├── scene.json # 场景侧车:part 列表 + 每个 part 的世界变换
│ ├── spines/<骨架名>/
│ │ ├── <骨架名>.json # 骨架(skeleton.images 已归一化为空串)
│ │ ├── <骨架名>.atlas # 第一行 = 贴图页名,与落盘文件名逐字一致
│ │ ├── <贴图页…> # 与 atlas **同居**(布局 A,见 ADR 0008)
│ │ └── meta.json # spine 版本 / 动画 / 皮肤 / 来源 URL
│ ├── scene/<场景图> # 几何平面(geometry + material.diffuse)贴的图
│ └── audios/ # 音源(当前恒为空:页面的 BGM 还没抓)
└── selection.yml # 机器所有的「哪一页推荐哪个场景」(**入库**)
```
场景目录名 = 场景 id 转义成合法壁纸 id(只允许 `[a-z0-9_-]`,首字符必须是字母数字):
`scene_main` / `P1` / `loading` 原样,中文场景 id(back-moon 的 `动画预览`)折成 `scene`。
**真实 id 不丢**——它在同目录的 `meta.json` 与 `scene.json` 里。
`_cache/` 与 `_out/` 在 `.gitignore` 里;`selection.yml` 入库。
### 场景内去重、跨场景不去重
* **场景内按 id 去重**:同一个场景里同一具骨架被引用多次只存一份(`ctc_rewards` 在
back-moon 的 `scene_content` 里出现 4 次、`gc_win` 在 `scene_gacha` 里 6 次,都只落一份)。
`scene.json` 里那些 part 仍然一条不少——只有**文件**去重。
* **跨场景不去重**:`scene_main/` 与 `scene_ava/` 会各存一份用到的骨架。`_out/` 是可随时
重生成的 staging,体积换"每个目录都能单独搬走"。总体积记在 `page.json` 的 `totalBytes` 里。
### `usable`:不是每个场景目录都能搬走
各页的 `scene_ui` 是一层**场景渲染目标**:它的平面用 `drawScene` modifier 把别的场景渲染成纹理
(diffuse 名就是场景 id),本身没有任何资源文件。这种场景照样落盘("页面上有几个场景"这件事在
产物里是完整的),但它的预设 `parts` 是空的:
```json
{ "usable": false, "reason": "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源" }
```
`promote` 拒绝搬这种目录;`page.json` 的 `sceneDirs[]` 里逐条标了 `usable`。
## 二、场景选择:只决定"推荐哪个"
`fetch` **落盘全部有内容的场景**(至少有一具骨架或一块贴图平面)。纯色平面组成不了壁纸
(`sceneConfig.parts` 会是空的),完全没有 part 的场景也没有内容——这两类不落盘,原因记在
`page.json` 的 `skippedScenes` 里。
推荐场景由 `selection.yml` / `sources.yml` / 默认值算出(优先级写死,避免两份文件打架):
1. `wallpapers/sources.yml` 里这一页写了 `scene:` / `spines:` → 用它(**人的意志最高**)
2. `tools/downloader/selection.yml` 里有这一页的记录 → 用它(上次交互的结果)
3. 都没有 → **骨架最多的那个场景**(`--interactive` 时才问人)
默认规则与旧项目的判断一致(get-memory 会推荐 `P1`、back-moon 会推荐 `scene_main`)。
推荐值写进 `page.json` 的 `chosenScene` / `chosenDir`,是 `promote` 不给 `--scene` 时的默认值。
> **这份选择不再裁剪产物。** 以前它决定"只落哪几个骨架",现在产物是"一个场景一个自包含的
> 壁纸目录"——砍掉几具骨架会做出一个缺件的坏场景。要精简就在场景目录里的
> `preset.template.json` 上改(那是纯数据,构建期才读)。
> `sources.yml` 的 `spines:` 字段同理,只剩记录语义。
## 三、`scene.json` 里有什么
页面用自研引擎(three.js 系)描述场景:`sceneList` 是场景数组,每个场景是一棵树,节点有三类负载:
| kind | 含义 | 需要资源? |
| --- | --- | --- |
| `spine` | `spine:{id:"main_nike"}`,一具骨架 = 一个视觉元件 | 是(骨架 + atlas + 贴图页) |
| `image` | `geometry` + `material.uniforms.diffuse`,贴图平面 | 是(`scene/<名>.<ext>`) |
| `solid` | `defines.USE_TEXTURE == 0` 的**纯色平面**(diffuse 常写 `DEFAULT`) | 否 |
每个 part 带 `position` / `scale`(**世界变换**)与 `localPosition` / `localScale`,合成语义照抄引擎:
```
world_position = parent_position + parent_scale ⊙ local_position
world_scale = parent_scale ⊙ local_scale
```
旋转暂不参与合成(旧项目同样如此),只在 `stats.rotatedNodes` 里报出数量——不假装算了。
绘制层级 = 树序遍历序(`order`)+ 节点自身的 `renderOrder`。
带 `"runtime": true` 的 `image` part 是**没有独立文件**的平面:`drawScene` 的场景渲染目标、
`cacheContainer` 的贴图缓冲,或 diffuse 指向同场景骨架缓存。它们不进预设,也不算缺资源。
**用户口中的 "scene / geometric" 就是这里的两类节点**:`sceneList` 的场景容器与 `geometry` 平面。
Spine 官方没有这两个数据概念(官方 7 种附件类型里没有 geometric;`scene` 在 spine-webgl 里指的是
`SceneRenderer` 这个渲染器)。所以"支持场景动画"= 运行期把多具骨架 + 若干贴图平面按这套摆放合成。
## 四、`preset.template.json` 怎么生成的
* `sceneConfig.parts`:`scene.json` 里每个 `spine` / `image` part 一条;纯色平面与运行时纹理不写
(运行时画不了它们,写进去只是死配置)。路径一律写成 `./spines/<名>/…`、`./scene/<名>.<ext>`——
**正斜杠**,因为预设会被整体搬走,`str(Path)` 的反斜杠在别的机器上是错的。
* `backgroundImage`:构建期它是**必填且必须真实存在**的。自动挑法 = 场景里**面积最大**的贴图平面
(它决定 `document.body` 的底图与取景用的宽高比,挑到一块小按钮会让整幅画的比例全错);
一块贴图平面都没有时退到第一具骨架的 atlas 第一行声明的贴图页。
* 写法与校验在同一个模块 `layout.py` 里(`build_preset` / `missing_preset_paths`):
产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。写完之后**当场**把每条路径在磁盘上
核一遍,不留到构建期才炸。
## 五、页面侧的坑(都踩过,别再踩)
- **描述表的 `src` 表达式不能用一条 `.+?` 通吃**:kv45 的 bundle 里有个编译后的模板片段写着
`{src:e.activeIcon,alt:""}})`,`\{src:(.+?),id:"…",type:"image"\}` 会从那里一路吃 **8.8 万字符**
去够后面的 `,id:"…",type:"image"}`,把夹在中间的真表项(`{src:$w,id:"loading_dt1",…}`)整个吞掉。
现在分成两种形状各匹配各的:`_DESCRIPTOR_INLINE`(`Object.values(Object.assign({…}))[0]`)与
`_DESCRIPTOR_SIMPLE`(不含 `,{}` 的表达式)。
- **资源引用有四种写法**,缺一种就会把真资源当"不存在":
1. `X = a.p + "images/x.png"`(`_ASSET_LITERAL`)
2. `{src:a(38458),id:"x"}`:模块直接导出字符串——小图被 webpack 内联成
`e.exports="data:image/png;base64,…"`(`_MODULE_STRING`)。get-memory 的 `loading_moutain_a`、
`a01_lizi`、start-ndkl 的 `loading_start_1` 都栽在这里(少了它们,平面会报"资源表里没有 URL")。
3. `{src:$w,id:"x"}`:`$w="data:image/png;base64,…"`(`_STRING_ASSIGN`,kv45 的 `loading_dt1`)。
4. `Object.values(Object.assign({"<源路径>":"data:…"}))[0]`(`_DATA_IN_ASSIGN`)。
- **骨架数据一律内联在 bundle 里**,6 个页面里 `.atlas` / `.skel` 的网络请求数是 **0**。
只有 hsr 的骨架 json 走网络(页面根目录 `<hash>.json`),而且它在 **webpack 异步 chunk**
里(`258.ccb0954b.js`)——入口 HTML 根本没列它,得读 `.u=` 的 chunk 名映射再排队抓。
这也是 hsr 的 `scene_ava` **首次离线抓不到**的原因:那两个 json 从没进过 `_cache/`,
得先联网抓一次(见第七节的离线边界)。
- **三种内联家族**都要认:A `Object.values(Object.assign({"…/spine/<N>.json":{…}}))[0]`(JS 对象字面量,
键不带引号,用 `jslit` 规范化);B 匿名模块 + 配对表 `{atlas:fn(id),json:fn(id)}`,
骨架是 `JSON.parse('…')`(**单引号** JS 字符串,要先按 JS 语义还原);C hsr 的 `spineSetting`。
- **公共路径变量名逐页不同**:`n.p` / `a.p` / `t.p`。写死 `n.p` 会让半个页面的资源表全空。
- **同一逻辑名可能有两个候选**(引擎的桌面/移动两套预载表)。判据:数组式描述表是基准集,
字典式表是移动端覆盖(引擎里是 `desktop() || base.forEach(e => override[e.id] && …)`),桌面取基准集。
- **atlas 有两种书写风格**:`size:498,330` 与 `size: 256, 256`,解析器两种都要吃。
- **`.atlas` 的页名要读 atlas 自己声明的**(`atlas.py` 解析出来的第一行/页行),不要按 `<stem>_N` 猜:
多页是 `_2.png`,也有完全不同的名字。落盘用逻辑名,atlas 第一行不用改。
- **`skeleton.images` 要归一化**:作者目录(`../images/`)搬进分发后一定指错,写空串即可
(页名相对 atlas 所在目录解析),原值记在 `meta.json` 的 `originalImages` 里。
- **跨平台路径**:别拿 `str(Path)` 当映射键(Windows 反斜杠 vs 预设里的正斜杠),一律
`Path.as_posix()`。
## 六、多版本 Spine
`meta.json` 逐具骨架记录 `skeleton.spine` 原文。事实基线(详见 `.scratch/spine-versions/REPORT.md`):
- 版本串是**编辑器版本**;`4.0-from-4.1-from-4.2-from-4.3.23` 这类 `-from-` 串表示
**数据是 4.0 格式**(降级导出),运行库只认开头那个 `major.minor`。
- 运行库**不校验**这个串;spine-ts 4.2 能读 4.0/4.1/4.2 的数据。**4.3 数据会被静默丢掉全部约束**
(4.3 把约束并进 `root.constraints`),所以 `verify` 对 `≥4.3` 的骨架直接报红。
- 目前 7 页共 170+ 具骨架全是 ≤4.2 格式,**一套 4.2 运行库就够**;真出现 4.3 数据再谈多套 UMD 共存
(官方没有 `spine-version` 属性,只能各包一层别名函数避免 `window.spine` 互相覆盖)。
- 重分发运行库要带 Spine Runtimes License Agreement(Exhibit A)+ 版权声明,不得删各文件头。
## 七、`verify` 检查什么
逐场景目录核一遍:`meta.json` / `preset.template.json` / `scene.json` 都在;`meta.json` 的
`id` 等于目录名、字符集合法、`name`/`title`/`description` 非空、`audio.choices` 指向的文件存在;
预设里每条 `./…` 路径在磁盘上找得到;侧车引用的骨架有目录与文件、atlas 声明的贴图页逐字落盘、
几何平面的图存在;纯色与运行时纹理不计入缺失;骨架版本在运行库可读范围内。
页面级还会抓"旧形状的残留"(页面根的 `scene.json` / `spine/` / `scene/`)与"不是场景目录的目录"。
**退出码非 0 = 有硬伤**。
它不评判画面对不对——那是 `tools/checks/verify-scene-player.mts` 的事(多骨架 + 贴图平面真的合成出来)。
### 离线能跑到哪一步
`fetch --offline` 只读 `_cache/`(bundle 文本)与**已经落盘的 `_out/`**。所以:
* **重跑**永远是安全的:产物存在就跳过,不发任何请求。
* **首次**抓一个"从没抓过的场景"需要联网——它的骨架 json 与贴图页都还没有本地副本。
hsr 的 `scene_ava`(`zhigengniao_juheye` / `shajin`)就是这样:先 `fetch --page kv45`(联网)
一次,之后再 `--offline` 就完全绿。缺什么会**逐条报出来**(不会甩一条 traceback 就走)。
## 八、离线预览(`_out/` 能看,`_cache/` 不能)
| 目录 | 内容 | 能否预览 |
| --- | --- | --- |
| `_cache/<页面>/` | 原始 bundle **文本**(引用仍是线上绝对地址、贴图不在里面) | ❌ 按设计就是文本调试缓存 |
| `_out/<游戏>/<页面>/<场景>/` | 真实文件树(骨架 / atlas / 贴图页 / 场景图 / 侧车 / 预设) | ✅ **可离线预览** |
```bash
node tools/preview.mts # 列出 _out 里可预览的场景
node tools/preview.mts ys/nico-tea # 页面 = 取它的推荐场景
node tools/preview.mts ys/nico-tea/scene_main # 点名场景
node tools/preview.mts hsr/kv45/scene_ava --port 8199
node tools/preview.mts ys/nico-tea --no-serve # 只组装到 tools/.cache/preview/
```
`tools/preview.mts` 把「构建出的运行时 + staging 的资产 + 由 `scene.json` 生成的预设」组装成
一个独立目录再起静态服务;**页面只读本地文件**。页面里的调试面是 `window.__sceneDebug`
(`parts` / `loaded` / `errors` / `framing` / `rotatedSkipped`),排查"少加载了一件"直接看它。
两点说明:
- **要预览"原页面"而不是我们的产物**,得走整站镜像(`.scratch/page-mirror/spec.md`,尚未实现);
`_cache/` 不能满足这个需求——它只有文本,没有资产树,也没有改写引用。
- `tools/checks/verify-scene-player.mts` 里另有一份"从 scene.json 生成预设"的代码,
**故意不与 preview 共用**:门必须独立于被验证对象,共用一份就变成自己验自己。
## 九、`promote`:搬进 `wallpapers/`
`fetch` 已经产出终态,所以 promote 退化成 **拷贝 + 校验 + 写 meta**:
```bash
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name '挥掷千星的筹码' --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
```
* `--page` 认三种写法:页面 id(`kv45`)、`页面/场景`(`kv45/scene_ava`)、场景目录的路径。
给页面 id 时用 `--scene` 点名,或让它取 `page.json` 的 `chosenScene`。
* `--name` / `--title` / `--description` 可选:不给就沿用场景目录 `meta.json` 里的
(那里已经是"页面名(场景id)"的形状,够用但通常要改成正式文案)。
* `--cover` 给逻辑名时覆盖 `backgroundImage`。
* **目标目录里的 `meta.json` 的 `id` 会被改写成 `--id`**——所以壁纸 id 可以跟场景目录名不同,
但目录名与 `meta.json.id` 必须一致(构建期会校验)。
* 搬之前会再核一遍预设里的每条路径在**目标目录**上存在;搬完不用手工改任何路径。
也可以完全不用 promote:**整个场景目录拷到 `wallpapers/<游戏id>/<壁纸id>/` 就完事**
(前提是目录名 = `meta.json.id`,且那个目录 `usable`)。两种做法等价,promote 只是顺手改名与校验。
## 十、还没做的
- **音源**:页面 BGM 是另一条链,`audios/` 现在是空的,`meta.json` 的 `audio.choices` 也是空的。
- **预览图**:没有抓,也没有生成。
- **`scene_ui` 这类纯渲染目标场景**:落盘但不可搬走(运行时不会 `drawScene`)。