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

17 KiB
Raw Blame History

tools/downloader/ — 米哈游活动页 Spine 抓取器

从 wallpapers/sources.yml 列出的活动页里把 Spine 骨架 + 贴图 + 场景装配信息 抓下来, 落到 staging(_out/)。每个有内容的场景各产出一个「可直接搬走的壁纸目录」——用户挑中的那份 整个移到 wallpapers/<游戏id>/ 就能用(也可以让 promote 代劳)。

它不是构建输入:wallpapers/ 只放最终要发布的资源,抓取脚本与临时下载都留在 tools/。

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 是空的:

{ "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 / 贴图页 / 场景图 / 侧车 / 预设) ✅ 可离线预览
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:

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)。