# Technical Documentation: czy0729/Bangumi

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/czy0729/Bangumi)

### Overview (`README.MD`)

<p align="center">
  <img alt="release" src="https://img.shields.io/github/v/release/czy0729/Bangumi" />
  <img alt="license" src="https://img.shields.io/github/license/czy0729/Bangumi" />
  <img alt="stars" src="https://img.shields.io/github/stars/czy0729/Bangumi" />
  <img alt="forks" src="https://img.shields.io/github/forks/czy0729/Bangumi" />
  <img alt="issues" src="https://img.shields.io/github/issues/czy0729/Bangumi" />
  <img alt="downloads" src="https://img.shields.io/github/downloads/czy0729/Bangumi/total" />
</p>

# Bangumi

一个基于 `react-native` 的 [Bangumi](https://bgm.tv) 第三方客户端

Bangumi 是一个用于管理追番进度（不限于动漫、音乐、日剧、游戏、AVG），发现和讨论番组的网站

iOS 和安卓都做了兼容，并最大限度保持一致

目前已开发不少于 100 个页面，基于移动端重新设计布局，几乎涵盖网站所有页面

本人并没有原生开发经验，用于练手 react-native，希望通过这个平台去学习更多

更多涉及到本项目的说明，可前往 [语雀](https://www.yuque.com/chenzhenyu-k0epm/znygb4) 查看

[ENGLISH](https://github.com/czy0729/Bangumi/blob/master/README.en.MD)

# 下载

[安卓 APK 下载](https://github.com/czy0729/Bangumi/releases) | [AltStore 侧载](https://www.yuque.com/chenzhenyu-k0epm/znygb4/qw4xr3) | [AltStore 直接添加源](./web/ALTSTORE-USAGE.md) | [iOS 上当前能使用的方法](https://github.com/czy0729/Bangumi/blob/master/web/IOS-TEST.MD) | [Expo 主页](https://expo.dev/@bgmtv/bangumi-pro)

- Google Play 被举报下架，暂搁置

- iOS 付费开发者账号已过期，期间没能过审，因涉及到社区、版权敏感、备案等问题，不考虑再送审了

# 功能

## 基本功能（网站原有功能）

带链接的支持网页预览（只能看不能操作）

- 番组进度管理
- [条目](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-subject--subject&viewMode=story&subjectId=62229)、[角色详情](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-mono--mono&viewMode=story&monoId=character%2F111328)
- [时间线](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-timeline--timeline&viewMode=story)
- [超展开](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-rakuen--rakuen&viewMode=story)、[帖子](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-topic--topic&viewMode=story&topicId=group%2F350677)
- [时光机](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-user--user&viewMode=story&userId=sai)、[用户空间](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-zone--zone&viewMode=story&userId=sai&hd=1)
- 不限于 [每日放送](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-calendar--calendar&viewMode=story)、[搜索](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-search--search&viewMode=story)、[排行榜](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-rank--rank&viewMode=story)、[新番](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-staff--staff&viewMode=story)、[目录](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-catalog--catalog&viewMode=story)、[年鉴](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-yearbook--yearbook&viewMode=story)、标签、日志、[小组](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-group--group&viewMode=story&groupId=biweekly)

## 扩展功能

- 其他站点（bilibili、豆瓣）有限同步到 bgm.tv 功能
- 封面图片和大部分脱敏静态数据覆盖到自费 CDN 中间层，能更稳定快速地展示数据（尽管网页版遇到很慢或者挂掉客户端也能正常获取数据）
- [照片墙](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-milestone--milestone&viewMode=story&subjectType=music&type=collect&bg=true&numColumns=4&autoHeight=true&cnFirst=true&numberOfLines=2&subTitle=%E6%97%B6%E9%97%B4&extraTitle=%E6%97%A0&starsFull=true&starsColor=true&lastTime=true&limit=100&userId=sukaretto)、[猜你喜欢、AI 推荐](https://www.yuque.com/chenzhenyu-k0epm/znygb4/hyrzz32whgurgg6t?singleDoc)、关联系列、优质目录、[评分月刊](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-vib--vib&viewMode=story)、[分类排行](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-typerank--typerank&viewMode=story&type=anime&tag=%E7%BB%AD%E4%BD%9C&subjectId=443428)
- 词云：[条目](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-wordcloud--word-cloud&viewMode=story&subjectId=389156)、[人物](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-wordcloud--word-cloud&viewMode=story&monoId=person%2F40794)、[帖子](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-wordcloud--word-cloud&viewMode=story&topicId=group%2F350677)
- ~~当季番剧精准到单集网络播放源~~、源头自定义管理，[找番剧、书籍、游戏](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-anime--anime&viewMode=story)
- [番剧截屏](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-preview--preview&viewMode=story&subjectId=424883&cn=%E4%B8%8D%E6%97%B6%E7%94%A8%E4%BF%84%E8%AF%AD%E5%B0%8F%E5%A3%B0%E8%AF%B4%E7%9C%9F%E5%BF%83%E8%AF%9D%E7%9A%84%E9%82%BB%E6%A1%8C%E8%89%BE%E8%8E%89%E5%90%8C%E5%AD%A6)、游戏截图预览
- [多规则超展开屏蔽](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-rakuensetting--rakuen-setting&viewMode=story)、用户拉黑功能
- [本地文件夹条目管理](https://www.yuque.com/chenzhenyu-k0epm/znygb4/cq5vqctm7iohgldl?singleDoc)（支持文件夹罗马音刮削）
- [小圣杯](https://bgm.tv/dev/app/1143)（角色交易空气游戏，客户端独立设计 UI 和交互）

# 预览

以下为 2025 年 6 月份，一些 UI 相对不重复的页面截屏。图片无法正常加载也可以前往 [语雀](https://www.yuque.com/chenzhenyu-k0epm/znygb4/xcvi52hi66me2kwn?singleDoc) 查看。














# 版本

[当前版本图文说明](https://www.yuque.com/chenzhenyu-k0epm/znygb4/uchnd5v0cmxamohl?singleDoc)

[所有更新 CHANGELOG](https://github.com/czy0729/Bangumi/blob/master/web/CHANGELOG.MD)

# 在线

基于 [@storybook/react-native 6.5](https://github.com/storybookjs/react-native) 生成的 iframe.html，尽量少入侵原项目的基础上，补全了一个完整的单页面流程（当然这样是不考虑性能问题的，不可能与正常开发的，例如使用 Next.js 的相提并论）。

关键代码实现：[navigate](https://github.com/czy0729/Bangumi/blob/5904f862203b8f805e24fdae5e1592431deae934/src/components/storybook/utils.ts#L23) | [window popstate](https://github.com/czy0729/Bangumi/blob/5904f862203b8f805e24fdae5e1592431deae934/.storybook/preview.js#L22)

因现在 expo 在网页上面已经很成熟，若以后有空再考虑使用同一套方案补全网页端。

[SPA 应用预览](https://czy0729.github.io/Bangumi-Storybook/storybook-static/iframe.html?id=screens-discovery--discovery&viewMode=story)（网页收藏管理条目相关功能不定期开发中，已支持基本的信息展示，移动端最佳）

[Storybook 组件面板预览](https://czy0729.github.io/Bangumi-Storybook/storybook-static)

# 开发心得

从 react-native@0.37 就开始持续开发，当年并没有今天如此多简单而且优秀的方案和第三方库，比如 react hooks、react-native-reanimated、expo libraries，甚至连 Pressable、color scheme 这些概念都没有。至于 TypeScript 是最近两三年才开始慢慢补充的，所以很多最初的设计并没考虑过类型问题。最近还涌现了很多一站式解决方案，比如 expo-router，ones 都能在客户端和网页端上直出高性能构建。现在代码上会存在大量老旧的又或者与你现在了解到的 react-native 很多雷同的实现，都已经很难通过使用新的方案迭代过来。

使用 react-native 开发客户端，想做到流畅是需要下很大的功夫的，写法没问题的话一样流畅够用。其实跟开发网页应用没多大区别，无非就是多了一些概念而且还需要转变思路。几年开发下来，关键影响到流畅度，无非就几个点：

- 尽可能不要渲染过大的图片（是所有里面最影响流畅性的）
- 尽量减少同时请求数、慢请求（包括图片的请求）
- 尽量缓存计算过的结果
- 对不可见区域延迟渲染（不像浏览器有现成的）
- 多写函数式编程、异步逻辑，少用 this、循环
- 如果可以，减少嵌套层数（网页影响更大）

# 本地开发

## 关于环境

<details>
<summary><b>详细</b></summary>

本人一直在 mac 环境下开发，iOS 使用 expo 客户端调试，安卓跑真机 usb 调试。

由于 4 年的迭代，不管是对于本项目，还是最初使用的 expo 来说，都已经大相径庭。iOS 目前只能跑在 expo 上，而安卓只能跑在 metro 上。

在 iOS 上想充分使用 expo 客户端简化调试流程，所以不会引入 expo 不支持的第三方库。而安卓上面想要最高的性能，所以后期已经从 expo 上面 eject 出来了，用上了很多第三方的性能优化库替代了原来的组件。

若你想在本地调试，不要在根目录安装包，而请先到 ./packages/[目标环境]/ 下使用 yarn 安装包，然后使用命令 yarn env [xxx] 来切换到目标开发环境，最后请参考这个时候根目录的 package.json 中的命令开发。通常预置的本地调试命令是 yarn dev。多环境切换与补丁管理的详细说明见 [packages/README.md](packages/README.md)。

遇到跑不起来，不要太过折腾，推荐在运行本项目之前，就应该使用官方提供的工作流预先把所有环境需要的库都安装好了。window 和 mac 的环境差异还是比较大的，可以查阅 [Expo](https://github.com/expo/expo) 官方文档。

</details>

## 关于 NPM

<details>
<summary><b>详细</b></summary>

不要使用 taobao 源头，可能会在跑环境时提示不允许代理。

```bash
npm config set registry https://registry.npmjs.org
```

国内环境，首次下载包有可能会异常地慢。因用了很多旧的依赖包，里面的包版本依赖混乱，为了不出错请使用 yarn 安装。

```bash
git clone https://github.com/czy0729/Bangumi
cd ./Bangumi
yarn
```

</details>

## 安卓真机 usb 调试遇到的问题

<details>
<summary><b>详细</b></summary>

不能安装请尝试在命令前添加 sudo。

安卓系统请打开开发者调试模式，需要至少进行一次打开 Android Studio 的同时连接 usb 允许添加调试。

调试请保证给应用安装权限，允许悬浮窗权限。

遇到 spawnSync adb ENOENT，参考 [stackoverflow](https://stackoverflow.com/questions/38835931/react-native-adb-reverse-enoent)。

第一次就遇到打包失败，看看是不是遇到 read timeout 等问题，表示需要科学上网。明明前一天还能打包成功，后一天就失败，也可能是依赖包无法获取的问题，因为 gradlew 依赖包只有 24 小时的缓存时间，尝试使用 --offline 命令。

</details>

## 常见错误，包括 mac M1

<details>
<summary><b>详细</b></summary>

已在 mac M1 上调试成功，最近购入了 M1，尝试实用新环境，iOS expo 运行一路顺风，但是打包 apk 遇到很多问题，总结如下：

- 一定要使用正确的 JAVA jdk，不是 jre！

- 打包前一定要安装 watchman，不然会遇到监听文件过多错误

- 打包遇到失败，请进入 android 目录下执行 `./gradlew clean` 后再重新打包

- 打包失败，看见错误提示上面有 node_modules 包的名字，请尝试把这个包下载补全

- 若出现各种无法预料的问题，请一定要尝试重启机器

</details>

# 免责

- 本项目所有数据信息均来自各种网站，不提供任何形式的媒体下载、直接播放和修改功能

- 本项目承诺不保存任何第三方用户信息

- 本项目代码仅供学习交流，不得用于商业用途，若侵权请联系

# 致谢

[](https://www.jsdelivr.com/package/gh/ekibot/bangumi-onair) [[Bangumi-OnAir]](https://github.com/ekibot/bangumi-onair) [ekibun](https://github.com/ekibun) 的单集播放数据源

[[bangumi-mosaic-tile]](https://github.com/weizhenye/bangumi-mosaic-tile) [weizhenye](https://github.com/weizhenye) 的用户统计瓷砖库

[[bangumi-data]](https://github.com/bangumi-data/bangumi-data) 番组数据索引

[[bangumi-api]](https://github.com/bangumi/api) 官方接口

# 投食

开发迭代不易，觉得客户端好用的，有能力的可以投喂一下，也可以给个星星

<img src="./src/assets/images/qr/alipay.jpg" width="188" style="vertical-align: top" /> <img src="./src/assets/images/qr/wx.jpg" width="188" style="vertical-align: top" />

<a href="https://star-history.com/#czy0729/Bangumi&Date">
 <picture>
   <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=czy0729/Bangumi&type=Date&theme=dark" />
   <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=czy0729/Bangumi&type=Date" />
   <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=czy0729/Bangumi&type=Date" />
 </picture>
</a>

---

### Overview (`android/README.MD`)

# 开发安卓或打包 apk 前请先阅读

0. 安卓是 expo 下的 bare workflow，已经脱离了 expo 的环境很多年了，有条件请先尝试 iOS 环境开发，因为后面诸多问题，比如特定的 jdk 版本、子库的一次微小的版本发布，都会导致就是启动不起来。

1. 本 repo 默认提交 iOS 环境的 `package.json`，若你想开发安卓请先用 `./packages/android/package.json` 替换掉 `./package.json`，`/patches` 复制到根目录，再安装包。

2. 手机 USB 连接后允许调试授权，运行 `yarn dev` 会自动编译一个版本到手机，若后面报错 metro 无法正常启动，可以运行 `yarn metro` 不打包直接打开调试进程。

3. 若需要打包 apk 前请先使用自己的环境生成 `my-release-key.keystore` 放到 `./android/app/my-release-key.keystore`。

4. `./android/gradle_yours.properties` 重命名为 `gradle.properties`，并根据上面生成的 keystore 自行修改 `***` 的内容。可能还需要你手动创建 `local.properties` 编写 `sdk.dir=/Yours/Android/sdk`

```
MYAPP_RELEASE_STORE_FILE=my-release-key.keystore
MYAPP_RELEASE_KEY_ALIAS=***
MYAPP_RELEASE_STORE_PASSWORD=***
MYAPP_RELEASE_KEY_PASSWORD=***
```

5. 打包命令为 `yarn build`。

6. 打包完成后可以使用 `yarn open` or `yarn open:mac` 直达 apk 输出目录。

---

### Overview (`packages/README.md`)

# packages 多环境管理

本项目通过 `yarn env [ios | android | web]` 在多个开发环境之间切换，脚本见 `packages/env.js`。提交代码前请切回 ios 环境。

## 目录结构

```
packages/
├── env.js          # 环境切换脚本
├── patches/        # ★ 统一补丁管理目录（唯一实体补丁文件）
├── ios/            # iOS 环境（提交基准）
├── android/        # Android 环境
├── web/            # Web 环境
└── README.md       # 本文件
```

每个环境目录独立维护以下内容：

| 内容            | 说明                                                            |
| --------------- | --------------------------------------------------------------- |
| `package.json`  | 该环境专属依赖版本                                              |
| `node_modules/` | 该环境安装的完整依赖                                            |
| `patches/`      | 指向 `packages/patches/` 的补丁**符号链接**（该环境的补丁子集） |

## 补丁统一管理

所有补丁的**实体文件**统一存放在 `packages/patches/`，各环境目录与根目录 `patches/` 里只有指向它的符号链接，按各环境需要的补丁子集决定包含哪些符号链接：

| 目录                        | 补丁子集                                                    |
| --------------------------- | ----------------------------------------------------------- |
| 根 `patches/`               | 当前环境的补丁集合                                          |
| `packages/android/patches/` | 共享 3 + `pager-view`/`smb`/`cameraroll`/`android-widget`   |
| `packages/ios/patches/`     | 共享 3（`realtimeblurview`/`render-html`/`tab-view`）       |
| `packages/web/patches/`     | `realtimeblurview`/`render-html` + `@storybook/preview-web` |

> 补丁只维护一份，修改 `packages/patches/` 下文件即对所有环境生效，无需逐份复制。

## 切换机制

`yarn env <目标环境>` 执行三件事：

1. **备份当前环境**：将根目录的 `package.json`、`node_modules` 备份到 `packages/{当前环境}/`
2. **恢复目标环境**：将 `packages/{目标环境}/` 的 `package.json`、`node_modules` 恢复到根目录
3. **更新平台配置**：同步 `babel.config.js` 与 `app.json`

`patches/` 不做物理拷贝：切换时 `env.js` 的 `syncPatches` 会清空目标目录，并**按源目录的补丁子集重建指向 `packages/patches/` 的符号链接**，因此各环境的补丁子集得以保留、且统一指向同一份实体文件。

## 补丁管理约定

- 补丁文件命名遵循 patch-package 规范：`{包名}+{版本}.patch`
- **新增/修改补丁**：先在 `packages/patches/` 创建/修改实体文件，再在需要该补丁的环境 `patches/`（及根 `patches/`）下创建符号链接
- 各环境只保留该环境下实际安装且版本匹配的补丁，避免跨环境版本不匹配导致应用失败
- `npx patch-package <包名>` 会写入**当前环境根目录 `patches/` 的符号链接目标**（即 `packages/patches/`），自动归档到统一目录

## 注意事项

- 根目录 `patches/` 是指向 `packages/patches/` 的符号链接集合，不要复制成实体文件
- 新增某包补丁时，需先在 `packages/patches/` 建好实体文件再软链（`patch-package` 无法写入不存在的符号链接目标）
- 非 mac 环境切换前请关闭编辑器，避免 `node_modules` 被占用
- 提交 git 前务必切回 ios 环境

---

### Overview (`workers/README.MD`)

# Workers

> 🤖 本文档由 AI 生成，实际情况请自行判断。

Bangumi 的反向代理 Worker，推荐部署到 **Deno Deploy**（国内可访问）。

> ❗ Cloudflare Workers 自带的 `*.workers.dev` 域名**国内无法访问**，如果你有备案的自定义域名可以用 CF，否则请用 Deno。

---

## 文件说明

| 文件            | 用途                                             |
| --------------- | ------------------------------------------------ |
| `deno/index.ts` | **通用代理** — 转发 API、网页等请求，解决跨域    |
| `deno/lain.ts`  | **图片代理** — 缓存 `lain.bgm.tv` 图片，节省流量 |
| `cf/index.js`   | Cloudflare 版通用代理（需自定义域名）            |
| `cf/lain.js`    | Cloudflare 版图片代理（需自定义域名）            |

---

## 部署到 Deno Deploy（推荐）

全程在网页操作，**不需要安装任何东西**。

### 部署通用代理

1. 打开 [Deno Deploy](https://console.deno.com)，用 GitHub 登录
2. 点击 **New Project** → **Playground**
3. 删除默认代码，复制粘贴 [`deno/index.ts`](deno/index.ts) 的全部内容
4. 顶部 Project Name 填一个名字（如 `bgm-proxy`），点击 **Deploy**
5. 部署完成后得到一个 `https://bgm-proxy.deno.dev` 的地址

### 部署图片代理（可选）

重复以上步骤，粘贴 [`deno/lain.ts`](deno/lain.ts) 的内容，取另一个名字（如 `bgm-lain`）。

---

## 部署到 Cloudflare Workers（需自定义域名）

如果你有备案域名，也可以用 Cloudflare：

1. 打开 [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages**
2. 点击 **Create Worker**
3. 删除默认代码，复制粘贴 [`cf/index.js`](cf/index.js)（通用代理）或 [`cf/lain.js`](cf/lain.js)（图片代理）
4. 点击 **Deploy**
5. 在 Worker 详情页 → **Triggers** → **Custom Domains** 绑定你的域名

---

## 在 Bangumi 中配置

设置 → 网络 → 代理，连接模式选**「镜像 / 反代」**。

### 填写地址

App 中有 **3 个输入框**，对应不同的用途：

| 输入框标题 | 默认上游      | 填写你的 Worker 地址         |
| ---------- | ------------- | ---------------------------- |
| 主站域名   | `bgm.tv`      | `https://bgm-proxy.deno.dev` |
| API 域名   | `api.bgm.tv`  | 不填则和主站共用同一个       |
| 图片域名   | `lain.bgm.tv` | `https://bgm-lain.deno.dev`  |

- **主站域名**必填，登录、浏览等绝大部分功能都依赖它
- **API 域名**通常可以不填，会跟随主站域名
- **图片域名**不填也不影响使用，只是图片不经过代理

> 填写后立即生效，如遇到问题请重启 App 或重新登录。

### 直接转发

如果用的是本项目提供的 Worker 脚本，**保持「使用了自建 Worker？」关闭即可**。这个开关是给 Nginx 等其他方案用的。

---

## 加个密码（可选）

部署后任何人都能使用你的 Worker。如果需要加密码，在 Deno Dashboard / Cloudflare Dashboard 的 **Environment Variables** 中添加：

| 变量名   | workerSecret             | workerLainSecret       |
| -------- | ------------------------ | ---------------------- |
| 对应文件 | `index.ts` / `index.js`  | `lain.ts` / `lain.js`  |
| 添加位置 | 环境变量 `WORKER_SECRET` | 环境变量 `LAIN_SECRET` |
| App 填写 | 设置 → 密钥              | 设置 → 图片密钥        |

不设置则所有人都能用，按需决定。

### 调试

App 中设置页面底部有**日志控制台**，可以实时查看请求走了哪个代理、是否成功，方便排错。

---

### Overview (`android/rust/README.md`)

# ECH Proxy - Android Native Module

本地 HTTPS CONNECT 代理，通过 ECH (Encrypted Client Hello) + DoH 绕过 SNI 封锁。

## 架构

```
JS (enableEchProxy)
  → Java EchProxyModule (NativeModule)
    → JNI EchProxyNative
      → Rust libechproxy.so
        ├── 本地 HTTP CONNECT 代理
        ├── ECH TLS 后端 (OpenSSL 4.0.1 + ech.h)
        ├── MITM: 自签 CA → per-host 证书 → rustls
        └── DoH DNS 解析
```

## 依赖

| 依赖        | 版本          | 说明                            |
| ----------- | ------------- | ------------------------------- |
| Rust        | stable        | 交叉编译 aarch64-linux-android  |
| cargo-ndk   | latest        | Android NDK 集成                |
| OpenSSL     | 4.0.1         | 需要 ECH 支持 (`openssl/ech.h`) |
| Android NDK | 27.1.12297006 | 在 `build-android.sh` 中配置    |

## 构建

### 1. 编译

```bash
npm run build:rust
# 或
bash android/rust/build-android.sh
```

脚本会自动检查 OpenSSL 源码是否存在：
- **已存在** → 直接编译
- **不存在** → 询问是否自动下载，或手动下载到 `android/rust/openssl/build/openssl-4.0.1/`

手动下载地址：
```
https://github.com/openssl/openssl/releases/download/openssl-4.0.1/openssl-4.0.1.tar.gz
```

目录结构：
```
android/rust/openssl/
  build/
    openssl-4.0.1/    ← 源码 (自动下载或手动放置)
  install/            ← 编译产物 (自动生成)
```

### 构建流程

1. 检查/安装 Rust + `aarch64-linux-android` target + cargo-ndk
2. 设置 Android NDK 环境变量
3. 交叉编译 OpenSSL (带 ECH 支持，静态链接，已编译则跳过)
4. `cargo ndk` 编译 `libechproxy.so`
5. 复制到 `android/app/src/main/jniLibs/arm64-v8a/`

### 构建产物

| 产物             | 路径                                         |
| ---------------- | -------------------------------------------- |
| `libechproxy.so` | `android/app/src/main/jniLibs/arm64-v8a/`    |
| OpenSSL 静态库   | `android/rust/openssl/install/` (gitignored) |
| Cargo target     | `android/rust/target/` (gitignored)          |

## 源码

| 文件           | 作用                                      |
| -------------- | ----------------------------------------- |
| `src/lib.rs`   | 代理主逻辑、JNI 入口、ECH 后端、MITM、DNS |
| `ech_helper.c` | OpenSSL ECH GREASE retry-config 获取      |
| `build.rs`     | 编译期探测 `openssl/ech.h`                |
| `Cargo.toml`   | Rust 依赖配置                             |

## 注意事项

- 首次构建时脚本会询问是否自动下载 OpenSSL 源码，也可手动下载
- 仅支持 Android arm64-v8a
- OpenSSL 必须有 ECH 支持 (`ech.h`)，否则编译为 `no_ech` 版本，ECH 功能不可用
- `libechproxy.so` 和 `openssl/` 目录均被 `.gitignore` 忽略，需要本地编译
- 构建需要 macOS (当前 `build-android.sh` 使用 `darwin-x86_64` 工具链)

---

### .Claude Docs Api (`.claude/docs/api.md`)

# API 层

## 两种请求方式

- **`fetchAPI()`** — JSON API 调用，自动附加 Authorization header，支持重试
- **`fetchHTML()`** — HTML 页面抓取，携带 cookies，支持代理，cheerio 解析

## HTML 解析引擎

- 生产代码一律经 `src/utils/thirdParty/html`（`cheerio` / `cParse` / `cFind` / `cMap` / `cText` 等）使用，禁止直接 import 引擎包
- 底层引擎固定为 `cheerio/slim`（1.0，纯 htmlparser2），`engines/slim.ts` 构造单例适配层，由 `resolveEngine()` 懒加载
- 旧引擎 legacy（cheerio-without-node-native 0.20）仅测试对照用，生产代码不引用（不进 bundle）；双引擎基线稳定后删除
- 根 package.json dependencies 里的 `htmlparser2@^3.9.0` 是给 legacy 引擎占提升位的：占住根 node_modules 的 htmlparser2 槽位（3.x），cheerio 1.x 需要的 htmlparser2 v9 会被嵌套安装到自身目录下，两引擎互不干扰；不要"清理"掉它，也不要恢复顶层 resolutions 的 htmlparser2（会降级 cheerio 1.x 的依赖导致解析失败）
- formhash 提取统一用 `getFormhash(html)`（正则实现，引擎无关），不要再写 cheerio 选择器

## API 常量位置

- REST API: `src/constants/api/index.ts`（`API_SUBJECT()`, `API_USER_INFO()` 等）
- HTML 页面: `src/constants/html/`
- CDN: `src/constants/cdn/`
- 第三方: MAL、AniDB、Pixiv、Anitabi

---

### .Claude Docs Architecture (`.claude/docs/architecture.md`)

# 项目架构

## 技术栈

- **框架**: React Native + Expo SDK 54
- **状态管理**: MobX 6 + mobx-react 9
- **导航**: React Navigation 6（native-stack + bottom-tabs）
- **样式**: StyleSheet + 自定义 memoStyles() 缓存
- **HTTP**: 自封装 fetchAPI() + fetchHTML()（cheerio 解析 HTML）
- **构建**: EAS (Expo Application Services) + Fastlane + GitHub Actions

## 目录结构

```
├── src/                    # 主源码
│   ├── App.tsx             # 统一入口（平台差异用 ANDROID 常量分流, 不再拆分 App.android.tsx）
│   ├── config.ts           # 开发标志、代理、初始路由
│   ├── components/         # ~80+ 可复用 UI 组件
│   ├── screens/            # 所有页面模块（~100+）
│   ├── stores/             # MobX domain stores（18个）
│   ├── navigations/        # React Navigation 配置
│   ├── constants/          # 常量: index.ts 为 barrel; 子模块 api/, app/, assets/, cdn/, data/, device/, env/, events/, host/, html/, i18n/, init/, model/, site/, text/, tinygrail/ (运行时管线已迁至 utils/cdn)
│   ├── styles/             # 主题、颜色、布局工具 (index.ts 汇总; colors.ts 单文件; layout.ts 布局/尺寸; device.ts 设备检测; tools/ 工具类: container/margin/border/effect/legacy)
│   ├── types/              # TypeScript 类型定义
│   ├── utils/              # ~35+ 工具模块
│   └── assets/             # 图片、字体、JSON 数据
├── android/                # Android 原生工程
├── ios/                    # iOS 原生工程
├── packages/               # 多环境构建目录（android/ios/ipa/web，含各环境独立 package.json、node_modules、patches）
├── web/                    # IPA 构建、更新日志
├── patches/                # patch-package 补丁（当前环境的运行时镜像，由 packages/env.js 切换时自动同步）
├── [deprecated]/           # 废弃代码
└── test/                   # 测试数据
```

## 多环境切换（yarn env）

- 通过 `yarn env [ios | android | ipa | web]` 切换开发环境，脚本见 `packages/env.js`
- 各环境独立维护 `packages/{env}/package.json`、`node_modules`、`patches/`
- 切换时：备份当前环境配置到 `packages/{当前env}/`，恢复目标环境配置到根目录
- **patches 同步**：备份时根目录 `patches/` 同步到 `packages/{当前env}/patches/`，恢复时 `packages/{目标env}/patches/` 同步到根目录 `patches/`。因此根目录 `patches/` 始终是当前环境的补丁镜像，补丁只需在各环境目录维护一处

## 路径别名

| 别名 | 路径 |
|------|------|
| `@components` | `src/components/` |
| `@screens` | `src/screens/` |
| `@stores` | `src/stores/` |
| `@utils` | `src/utils/` |
| `@constants` | `src/constants/` |
| `@styles` | `src/styles/` |
| `@types` | `src/types/` |
| `@assets` | `src/assets/` |
| `@src` | `src/` |

## 导航结构

- **Root Stack**: `createNativeStackNavigator()` — 所有页面作为 Stack.Screen
- **Bottom Tab**: 6 个主 tab — Discovery / Timeline / Home / Rakuen / User / Tinygrail
- **懒加载**: 非 tab 页面使用 `React.lazy()` 代码分割
- **配置**: `src/screens/index.ts` 导出 ~100+ 页面组件

## 构建/部署

- **EAS**: `eas.json` — development / preview / production profiles
- **Fastlane**: Android 商店元数据
- **CI**: `.github/workflows/build-upstream-ipa.yml` — IPA 构建
- **发布**: `npm run pub`（eas update → production channel）

---

### .Claude Docs Code Style (`.claude/docs/code-style.md`)

# 代码规范

- 一个文件没必要不超过 400 行，超了就拆
- 嵌套没必要不要超过 4 层

# 提交描述规范

- 格式为 `- [功能块] xxx`（功能块如 文档 / 组件 / 重构 / 依赖 / 词云 等，取自最近提交惯例）
- 尽量简洁，用自然语言概括改动本身，不关心代码层面细节

# 注释规范

- **文件头注释框：元信息之后空一行写文件职责描述**，描述紧贴 `*/` 收尾，可多行（续行与首行对齐）。新建文件一律带上；存量文件未带的不必回填，编辑该文件时顺手补即可。先例见 `src/utils/thirdParty/protobuf/index.ts`：
  ```typescript
  /*
   * @Author: czy0729
   * @Date: 2023-12-07 21:42:04
   * @Last Modified by: czy0729
   * @Last Modified time: 2026-08-30 07:30:00
   *
   * native 端入口: 资源加载 (assets.ts) + 缓存去重 (cache.ts) + 解码 (decoder.ts)
   */
  ```
- `/** */` 块注释上面如果有内容（非空白行），必须空一行隔开
- 类型定义中，每个属性的 `/** */` 注释前也需要空一行，仅首个属性（上方为 `{`）可紧贴；**联合类型逐成员注释同理，成员的 `/** */` 注释上方必须空一行**（成员较多或注释较长时，优先用类型头部 `/** */` 破折号列表描述各成员）
- **hooks 返回对象时，每个 key 都必须加 `/** */` 注释**（返回 `as const` 或条件提前返回的对象同样适用）
- **函数参数说明写在函数头 JSDoc 的 `@param name 说明` 中（不带类型标注，类型由 TS 提供）**，不在签名内联给参数写 `/** */`。先例见 `constants/api/tinygrail.ts`：
  ```typescript
  /**
   * 创建冰山委托
   *
   * @param monoId 条目 Id
   */
  export const iceberg = (monoId: MonoId) => {}
  ```

# 类型规范

- **不允许使用 `interface`**，统一使用 `type` 定义类型
- 优先查找并复用项目中已有的类型定义，避免重复定义
- 类型定义应放在文件顶部导入语句之后
- 内联类型仅用于简单、一次性使用的场景
- **必须使用 `import type`**：TS 类型直接用 `import type` 引入
- **样式与路由类型优先从 `@types` 引入**，不要直接从 `react-native` 引：
  - `ViewStyle` / `TextStyle` = `@types` 中的宽口径别名（`StyleProp<RNViewStyle>` 等），RN 原生窄型仅在 reanimated 的 `AnimatedStyle<T>` 等需要具体对象型的场景使用（可别名引入，如 `ViewStyle as RNViewStyle`）
  - 组件 `style?: ViewStyle` 用 `WithViewStyles<>` 组合；带导航参数的组件用 `WithNavigation<>`
  - children 一律用 React 的 `PropsWithChildren<>`
- **遗留类型 `Fn` 与 `AnyObject` 已从 `@types` 移除**（2026-09 清理）：禁止再使用，回调一律写具体签名（如 `() => void`、`(res?: unknown) => void`、复用 `TouchableHandlePress`），动态数据袋用 `Record<string, any>` 等内联结构
- **云快照（`@utils/kv` 读写的 OTA 数据）类型一律用 `ResultData<T>`**（`@utils/kv/type`，等价于 `T & { ts?: number }`），不手写 `T & { ts?: number }`：
  - 读取写 `get<ResultData<X>>(key)` / `gets<ResultData<X>>(keys)`
  - store 保存快照时，先在页面 `types.ts` 用 `ResultData<...>` 定义快照类型（如 `type OtaChannel = ResultData<Partial<Channel>>`），state 再引用
  - `ts` 由 `ResultData` 提供，业务类型里不再重复声明；上传用 `update(key, omit(data, [...瞬态字段]))` 剔除 `_loaded` 等本地字段

# 代码风格（全局）

- 所有组件统一用 `observer()` 包裹，**不使用** `useObserver` 或 `ob`
- 性能优化推荐依赖 MobX observer() 自动追踪，默认不手写 React.memo / useMemo
  - store 派生数据每次渲染直接计算即可，useMemo 缓存 store 派生值可能滞留旧数据（原因见 component.md「observer 组件内不要用 useMemo 缓存 store 派生数据」）
  - **例外**：滚动密集页面的区块内容组件（见 screen.md「页面区块双组件模式」）使用项目封装的 `memo(props, DEFAULT_PROPS, COMPONENT_MAIN)` + props 驱动；此类组件内部的 useMemo 若服务于稳定引用可保留
- useCallback 用于稳定引用
- **`use*` 函数禁止在条件提前返回之后调用**：`if (...) return null` 之后不要再调用任何 `use*` 函数（含纯函数），避免日后函数内引入真实 hook 时触发 Rules of Hooks 崩溃。提前返回应放在所有 `use*` 调用之后
- **`memoStyles()` 不是 `use*`，不受上一条约束**：`_.memoStyles()` 返回的是普通函数，调用它只是取一份 `StyleSheet`；组件内有提前返回时应放在提前返回之后，避免无渲染的分支也生成样式（见 [component.md](./component.md)「样式规范」）。不要为了「与 `use*` 对齐」把它提到提前返回之前，审查时也不要按 `use*` 报此问题
- 每个组件/页面通过 `ds.ts` 导出 `COMPONENT` 常量标识自身，子组件用 `rc(PARENT, 'Name')` 生成
- Store 访问：页面通过 hooks 解构 `{ id, $ }`，子组件通过 `useStore<Ctx>(COMPONENT)` 获取
- `StoreContext.Provider` 在页面 index.tsx 中提供，子组件通过 context 消费
- **JSX 文件不要写 `import React from 'react'`**：项目使用 automatic JSX runtime（expo `jsx: react-jsx`），多余的 React 导入会触发 `noUnusedLocals` 的 TS6133

# React Native 规范

- **触摸事件必须在同步代码中提取数据**：RN 触摸事件（`onTouchMove` 等）的合成事件对象会被重用回收，在 `requestAnimationFrame` / `setTimeout` / `Promise` 等异步回调中访问 `e.nativeEvent.touches` 会得到 `null`。先同步提取，再进异步回调：
  ```typescript
  const handleTouchMove = useCallback((e: any) => {
    const touch = e.nativeEvent?.touches?.[0]
    if (!touch) return
    const { pageX, pageY } = touch  // 同步提取

    requestAnimationFrame(() => {
      // 使用已提取的 pageX, pageY（此处再读 e.nativeEvent 已被回收）
    })
  }, [])
  ```

- **页面底色是 `colorPlain`（浅色为纯白），不是 `colorBg`**：`Page` 在 iOS 用 `_.container.plain`，浅色页面即白底。因此浅色下卡内分隔靠「描边 + 白底」，不要在白底上挖 `colorBg` 大灰井或叠白卡片 + 阴影（两者都不可见）；暗色相反，靠「level1 卡片 + `colorBg` 深井」分层，阴影无效可省略
- **覆盖组件默认样式必须用同名具体键，不要用简写**：RN 样式数组按下标合并，`padding` 简写与组件内部的 `paddingHorizontal` / `paddingVertical` 是不同 key，且 Yoga 中具体边优先于简写——`padding: 0` 压不住自带的 `paddingHorizontal: _.wind`。要覆盖就写同名键（如 `paddingHorizontal: 0`）
- **样式对象内属性按「布局 → 尺寸 → 间距 → 文字 → 填充 → 修饰」从上到下书写**，即先写决定盒子如何被放置与占位的属性，再写内容（文字），然后是容器的视觉填充，最后写裁切阴影等修饰，一组内固定先后：

  1. 布局参与：`flex` / `alignItems` / `justifyContent`
  2. 定位：`position` → `zIndex` → `top` / `right` / `bottom` / `left`（偏移仅 absolute 时出现，紧跟定位）
  3. 尺寸：`width` → `height`
  4. 间距：`padding`（全 → 轴向 → 单边）→ `margin`（全 → 轴向 → 单边）
  5. 文字排版：`fontFamily` → `fontSize` → `lineHeight` → `color` → `textAlign`（内容在前，容器 paint 在后）
  6. 背景：`backgroundColor`
  7. 边框与圆角：`borderWidth` → `borderColor` → `borderStyle` → `borderRadius`
  8. 修饰收尾：`overflow` → `transform` → `..._.shadow`

  ```typescript
  export const memoStyles = _.memoStyles(() => ({
    dot: {
      position: 'absolute',   // 定位
      zIndex: 1,
      top: 6,
      right: 6,
      width: 6,               // 尺寸
      height: 6,
      backgroundColor: _.colorSuccess,  // 填充
      borderRadius: 6,        // 圆角
      overflow: 'hidden'      // 修饰收尾
    },
    form: {
      paddingVertical: 16,    // 间距: 先 padding 后 margin
      paddingRight: 8,
      paddingLeft: 16,
      marginBottom: _.sm,
      backgroundColor: _.colorBg,
      borderWidth: 1,
      borderColor: _.colorBorder,
      borderRadius: _.radiusSm,
      overflow: 'hidden'
    },
    input: {
      width: '100%',          // 尺寸
      paddingVertical: 8,     // 间距
      paddingHorizontal: 12,
      fontFamily: _.fontBoldFamily,  // 文字在背景前
      color: _.colorDesc,
      ..._.fontSize14,
      backgroundColor: _.select(_.colorPlain, _._colorDarkModeLevel2),  // 背景
      borderWidth: _.select(1, 0),
      borderColor: _.colorBorder,
      borderRadius: _.radiusXs,
      overflow: 'hidden'
    }
  }))
  ```

# 模块级缓存规范
- **Map / Set 缓存需要上限时，统一使用 `@utils/cache` 的 `ensureCacheLimit(cache, maxSize)`**，在每次 `set` / `add` 之后调用即可（FIFO 淘汰最早条目，单次只淘汰 1 条）；禁止自建「超过上限就删除」的私有工具函数或 LRU 类
- **数组缓存需要上限时，统一使用 `@utils/cache` 的 `ensureArrayLimit(list, maxLength)`**，原地 `splice` 从尾部裁剪并返回同一引用；MobX observable 数组需在 `runInAction` 内调用
- decode / 映射 / 去重等纯函数级缓存优先做成「模块级 Map + `ensureCacheLimit`」，缓存 key 要包含会影响结果的全部输入（如屏蔽词列表需加入内容指纹，修改后缓存自动失效）

# MobX 规范

- **MobX store 属性若作为 hooks 依赖，必须先解构**：`systemStore.setting.xxx` 需要进入 `useMemo` / `useEffect` / `useCallback` 的依赖数组时，先在组件顶层解构为局部变量再使用，不要在依赖数组中内联 `systemStore.setting.xxx`：
  ```typescript
  const { s2t: s2tEnabled } = systemStore.setting
  const labels = useMemo(() => ..., [action, s2tEnabled])
  ```
- 解构命名需避开已导入的符号：如 `s2t` 是导入的转换函数，设置项解构时应重命名为 `s2tEnabled`，避免遮蔽
- **禁止在 computed / derivation 中原地修改 observable 数组**：`.sort()`、`.reverse()` 等会 mutates in-place，必须先 `.slice()` 或 `[...arr]` 拷贝后再操作，例如 `arr.slice().sort(...)`
- 同理避免 `.splice()`、`.push()`、`.pop()` 等原地修改 observable 数组的操作
- 如果 `.filter()` / `.map()` 的源是 observable 数组，其返回值已经是新数组，可安全使用 `.sort()`；但如果源是其他 computed 返回值，需先 `.slice()`
- **`computedFn` 统一从 `@utils/computed-fn` 导入**，禁止直接从 `mobx-utils` 导入。项目自定义的 `computedFn` 内部通过 Proxy 自动补齐可选参数，避免原版 DeepMap 因 `arguments.length` 不一致而崩溃（`DeepMap should be used with functions with a consistent length`）

---

### .Claude Docs Component (`.claude/docs/component.md`)

# 可复用组件（components/）规范

> 页面内私有组件规范见 [screen.md](./screen.md)

## 目录结构

```
component-name/
├── index.tsx       # 主组件（函数组件 + observer()），只编排 hook 返回值 + 渲染
├── types.ts        # 所有类型定义（Props + hook 参数 + 事件类型 + 内部类型别名）
├── styles.ts       # StyleSheet + memoStyles()
├── hooks.ts        # 组件专属 hooks
├── utils.ts        # 工具函数（纯函数，供逻辑测试）
└── ds.ts           # 常量（调试常量 / 模块级常量）
```

## 关键模式

- 所有组件为**函数组件**，包裹 `observer()`
- Props 类型使用 `WithViewStyles<>`、`WithNavigation<>` 组合
- 样式用 `_.memoStyles()` 缓存（theme-aware）
- 样式合并用 `stl()` 工具函数
- 组件包裹 `<Component id='...'>` 调试壳

## observer 与 memo 的选择

组件级包裹方式取决于组件是否**直接读取可观察值**（store / 主题）：

- **读取可观察值**（如 `systemStore.setting.xxx`、`_` 主题值）→ 包裹 `observer()`。`observer` 内部是「`React.memo` 浅比较 + 可观察值追踪」，能响应 store 变化，是 `memo` 的超集。
- **纯转发 props、不读任何 store** → 包裹 `memo()`。不读取可观察值时 `observer` 退化为纯 `memo` 行为，`memo` 更轻（不引入 mobx 追踪依赖）。

判断标准：组件体（含调用的派生计算）是否出现 `systemStore.*`、`_`、`userStore.*` 等可观察值读取。例如：

- `cover/cover-image` 只把 props 转发给 `Image`/`Squircle`（各自是 `observer`），用 `memo`
- `cover/disc` 读取 `_.radiusSm`，用 `observer`

要点：

- `memo` 浅比较对字符串/数字按**值**比较（稳定生效），对对象按**引用**比较——传内联对象会让任何 memo 失效，样式应传 `styles.xxx` 稳定引用
- 若组件未来可能直接读 store，直接先用 `observer` 亦无性能代价

## 失焦门控（Tab 保活下的常驻副作用）

底栏 tab 与页面内二级 Tab 都会保活，组件不能只靠卸载来停止副作用：

- 需要「页面聚焦 && 应用在前台 && 所在 Pager 场景聚焦」时用 `useActive()`（`@utils/hooks`）：blur 时同步置 false，能在 `freezeOnBlur` 冻结前完成最后一次渲染；不要改用 `useIsFocused`（blur 延迟到切页动画后，冻结的树内 setState 不生效）
- 无限循环动画：`useEffect` 内以 `active` 为条件启停（停时 `cancelAnimation` 再归位），不能只依赖卸载清理
- 轮询 / 定时器：以 `active` 作为启动条件；多实例共享的全局定时器沿用引用计数清理
- 页面内 TabView 传 `keepDistance={1}`：`|i - index| <= 1` 的场景保活，更远的场景卸载内容（外层页容器必须保留，Pager 子节点数量恒定）；跨级跳转期间区间自动扩展到目标页
- Android 的 `nested-scroll` 页面给 `NestedScrollParallaxHeader` 传 `lazyDistance={1}`，未激活页用占位 View 保号（`lazy-load-view` 的 `placeholder`）

## observer 组件内不要用 useMemo 缓存 store 派生数据

两个机制的冲突：

1. `observer()` 追踪渲染期（含 `useMemo` 回调执行期间）的所有可观察值读取，store 变化时通过自身订阅触发组件重渲染
2. `useMemo` 按 **deps 引用浅比较**决定重算——而 deps 只能写组件 props / 局部变量

结果：把「调用 store 方法的派生计算」包进 `useMemo` 后，store 更新（如点赞乐观更新）→ observer 正常触发重渲染 → 但 deps 未变 → **返回缓存里的旧数据，UI 停留在过期状态**。且被追踪的可观察源藏在调用链深处（如 `rakuenStore.likesList()` 内部读取），deps 无法枚举完整。

规则：

- ❌ 不写：
  ```tsx
  const likesList = useMemo(() => rakuenStore.likesList(topicId, id) || [], [topicId, id])
  ```
- ✅ 正确：store 派生数据**每次渲染直接计算**，失效交给 observer 细粒度追踪（这也是全局规范「不使用 useMemo」的同一原因）
  ```tsx
  const likesList = rakuenStore.likesList(topicId, id) || []
  ```
- 派生计算本身很廉价，真正的开销在子树渲染——全局依赖 `observer()` 自动优化即可；仅在 screen.md「页面区块双组件模式」等例外场景才引入 `memo`
- 纯事件回调（`useCallback`）不受此限制，正常使用

## 复合组件（子组件独立文件夹）

当组件拆分出多个子组件时（如 `hold-menu`、`scroll-view/mask`），每个子组件独立成文件夹，文件夹内 `index.tsx` 用 `export default` 导出组件，私有样式放该文件夹的 `styles.ts`，专属逻辑抽成 `useXxx.ts` 自定义 hooks：

```
hold-menu/
├── index.tsx           # 入口, 导出默认组件与外部类型
├── context.ts / ds.ts / types.ts / utils.ts   # 共享模块
├── provider/
│   ├── index.tsx           # default export
│   ├── useMenuController.ts
│   └── styles.ts
├── hold-item/
│   ├── index.tsx           # default export
│   ├── useItemAnimation.ts
│   ├── useItemGesture.ts
│   └── styles.ts
├── menu/
│   ├── index.tsx           # default export
│   ├── useMenuAnimation.ts
│   ├── styles.ts
│   └── menu-item/          # 更深层的私有子组件同理
│       └── index.tsx       # default export
└── backdrop/
    ├── index.tsx           # default export
    └── styles.ts
```

- 子组件一律 `export default`，hooks 用具名导出
- 组件直接以真实名称声明 `function X(...)`，末尾 `export default memo(X)` 包裹导出，**不要多包一层**（不做 `function XComponent` + `const X = memo(XComponent)` 的中间命名）；仅对外入口组件用 `observer()` 包裹（如 `HoldMenuProvider`）
- **例外**：子组件若**直接读取可观察值**（`_` 主题值、store 字段），按「observer 与 memo 的选择」规则应改用 `observer()`——如 `scroll-view/mask` 读取 `_.isPad`/`_.wind`/`_._wind` 故用 `observer(Mask)`
- 子组件内不写 `r(COMPONENT)` 调试调用；`ds.ts` 在无使用时不导出 `COMPONENT`
- 子组件 Props 类型提取到各自文件夹 `./types.ts`，每个 key 带 `/** ... */` 注释，回调字段放 type 末尾
- **模块级常量放该文件夹的 `ds.ts`**：非调试用途的常量（如 `GRADIENT_DIRECTION`）也放 `ds.ts`，避免散落在组件体内每次渲染新建对象
- 跨组件引用优先 `@components/*` 别名，避免深层相对路径（`../../../`）
- 共享常量/类型/工具仍放组件根目录（`ds.ts`/`types.ts`/`utils.ts`）

## 类型定义规范

- **使用 `type` 而不是 `interface`** 定义 Props
- 导出名称统一为 `Props`
- style / children 等样式与路由类型优先从 `@types` 引入（见 [code-style.md](../code-style.md) 类型规范）：`WithViewStyles<>` 组合 style、`WithNavigation<>` 组合导航、`ViewStyle`/`TextStyle` 用宽口径别名、children 用 React 的 `PropsWithChildren<>`
- **所有类型一律放 `./types.ts`**：Props、hook 参数类型（`useXxxOptions`）、事件类型、内部类型别名都收进 `types.ts`，不写在 hooks.ts / 组件体内
- **优先 `Pick` 上层类型**：下层组件 Props / hook 参数能从上层的 `Props` Pick 就不重复定义（`Pick<Props, 'a' | 'b'>`）；只有语义或必选性不同（如上层可选、本层必填）才保留本地定义
- **共享类型别名抽到最上层**：跨层复用的字面量类型抽具名别名（如 `MaskColors = readonly [string, string, string]`）放根 `types.ts`，逐层 `import type` 复用，不重复字面量
- 每个类型字段带 `/** ... */` 注释，回调字段放 type 末尾
- 示例：

  ```typescript
  // types.ts
  import type { PropsWithChildren } from 'react'
  import type { WithViewStyles } from '@types'

  export type Props = PropsWithChildren<WithViewStyles<{}>>

  /** 遮罩渐变色 [左, 中, 右] */
  export type MaskColors = readonly [string, string, string]

  /** 水平渐隐遮罩参数 */
  export type UseHorizontalMaskOptions = Pick<Props, 'horizontal' | 'showMask'>
  ```

## hooks 规范

- 逻辑优先抽到 `hooks.ts` 自定义 hooks，组件体只做「hook 返回值组合 + 渲染」
- 含 JSX 的 hook 文件用 `.tsx` 扩展名（如 `tabs-v2/hooks.tsx` 的 `useRenderTabBar`），纯逻辑 hooks 用 `.ts`
- 一类状态机 / 一组回调封装一个 hook（如 `useScrollLock`），参数与返回值类型放 `./types.ts`（`useXxxOptions`）
- 对外暴露的回调一律 `useCallback` 稳定引用，按真实依赖声明 deps
- **渲染期不直读 `ref.current`**：ref 在渲染后才赋值，渲染期直读会得到 `null`。需要传给下层组件使用的值应暴露成「调用时懒读取 ref」的稳定函数（如 `useScrollViewRef` 返回 `scrollTo` 而非裸 ref）

## 样式规范

- 无主题依赖的样式用 `_.create()` 创建，导出名称统一为 `styles`，消费端 `import { styles } from './styles'`
- 依赖主题（样式值使用 `_.select()`）才用 `_.memoStyles()` 创建，导出名称统一为 `memoStyles`
- 若 `memoStyles` 内没有任何 computed theme 值（如 `_.select`），会触发 `bangumi/require-computed-in-memo-styles` 警告，应改用 `_.create`
- `memoStyles()` 不是 Hook，不受 Rules of Hooks 约束（`use*` 必须保持在早退之前）；若有早退分支（如无条件直接 `return` 场景），**应放到早退之后**，避免无渲染时也生成样式。例外：早退分支自身要读样式（如 `return <Flex style={styles.loading} />`）时，取样式需留在早退之前
- 组件内部样式放在组件目录下的 `styles.ts` 中
- 共享样式放在上层目录的 `styles.ts` 中
- 样式数组必须用 `stl()` 包裹：

  ```tsx
  // ❌ 错误
  <Text style={[_.mt.sm, style]}>

  // ✅ 正确
  <Text style={stl(_.mt.sm, style)}>
  ```

## 禁止用 ts 抑制注释掩盖类型错误

- 不使用 `// @ts-ignore` / `@ts-expect-error` 掩盖**可修复**的类型错误，从根因解决
- 例：`LinearGradient.colors` 期望可变的 `string[]`，传入 `readonly` 元组报错时应 `[...colors]` 展开为可变数组，而不是加抑制注释

## 动画 / 静态 API 组件

原 ant-design 系组件（现位于 `src/components/<name>`）遵循的模式：

- **动画核心组件**独立成目录（如 `modal-view`），负责 Portal、遮罩、进出场动画（`withTiming`/`withSpring`），对外 UI 组件在之上组合（见 `modal/`）；动画逻辑抽 `hooks.ts`（如 `useModalAnimation`）：shared values + `scheduleOnRN` 桥接 `onAnimationEnd` 回调、稳定 `useCallback` 引用，JSX 拆 Mask/Content 子组件组合（镜像 `collapsible/hooks.ts`）
- 需要静态 API 的组件用 `api.tsx` 提供命令式入口，通过 `Portal.add` 挂载（见 `action-sheet/api.tsx` 的 `AntmActionSheet`）
- 纯逻辑抽到 `utils.ts`（如 `normalPercent`、`getUpdatedIndex`、`getPosition`），测试写 `__tests__/utils.test.ts`；hook 测试写 `__tests__/hooks.test.ts`，用手写 `renderHook` harness（RNTL 版本校验不可用，方案见 [testing.md](./testing.md) 的「hook 测试规范」）

---

### .Claude Docs Screen (`.claude/docs/screen.md`)

# 页面（Screen）规范

> 全局风格规则（observer 包裹、`import type`、样式数组 `stl()` 等）见 [code-style.md](./code-style.md)，本文不重复。

## 目录结构

```
screen-name/
├── index.tsx           # 页面组件
├── types.ts            # Ctx / Params 类型
├── hooks.ts            # useXxxPage() 初始化 hook
├── ds.ts               # COMPONENT 名称常量
├── store/
│   ├── index.ts        # ScreenXxx 类（extends Action），定义 init() / unmount()
│   ├── state.ts        # State extends Store<typeof STATE>，observable(STATE)
│   ├── computed.ts     # Computed extends State，@computed get
│   ├── fetch.ts        # Fetch extends Computed，异步请求
│   ├── action.ts       # Action extends Fetch，业务逻辑
│   └── ds.ts           # NAMESPACE / RESET_STATE / EXCLUDE_STATE / STATE
├── header/             # 页面头部组件（独立目录，不内联在 index.tsx）
│   ├── index.tsx
│   ├── ds.ts
│   └── styles.ts
├── component/          # 页面私有子组件（访问 Store）
│   └── xxx/
│       ├── index.tsx
│       ├── ds.ts
│       └── styles.ts
└── components/         # 页面内纯 UI 小组件（不访问 Store）
    └── yyy/
        ├── index.tsx
        ├── types.ts
        └── styles.ts   # 可选，无样式时省略
```

## types.ts

页面内跨文件复用的类型（数据模型、定位参数、编辑表单项等）统一放页面 `types.ts`，store 与组件从各自目录 `import type` 引用，不要内联在 `store/action.ts` 等使用处。

```ts
import { GetRouteParams, WithNavigation } from '@types'
import Store from './store'

export type Ctx = WithNavigation<{
  $: InstanceType<typeof Store>
}>
export type Params = GetRouteParams<RouteXxx>
```

## hooks.ts

```ts
import { useInitStore } from '@stores'
import { usePageLifecycle } from '@utils/hooks'
import Store from './store'
import { Ctx } from './types'

export function useXxxPage(props: NavigationProps) {
  const context = useInitStore<Ctx['$']>(props, store)
  const { id, $ } = context
  usePageLifecycle(
    {
      onEnterComplete() {
        $.init()
      },
      onLeaveComplete() {
        $.unmount()
      }
    },
    id
  )
  return context
}
```

## index.tsx

```ts
import { observer } from 'mobx-react'
import { Component, StoreContext } from '@stores'
import { useXxxPage } from './hooks'

/** 页面描述 */
function ScreenXxx(props: NavigationProps) {
  const { id, $ } = useXxxPage(props)
  return (
    <Component id='screen-xxx'>
      <StoreContext.Provider value={id}>
        <Page />
        <Header />
      </StoreContext.Provider>
    </Component>
  )
}

export default observer(ScreenXxx)
```

## store/ds.ts

```ts
import { COMPONENT } from '../ds'

export const NAMESPACE = `Screen${COMPONENT}`
export const RESET_STATE = { ... }
export const EXCLUDE_STATE = { ... }  // 瞬态，不持久化
export const STATE = { ...RESET_STATE, ...EXCLUDE_STATE, _loaded: false as Loaded }
```

## Store 继承链

```
State → Computed → Fetch → Action → ScreenXxx
```

每个文件只做一件事：state.ts 声明 observable 状态，computed.ts 做派生，fetch.ts 请求数据，action.ts 处理用户操作，index.ts 定义 init()/unmount()。

## 子组件访问 Store

```ts
import { observer } from 'mobx-react'
import { rc } from '@utils/dev'
import { useStore } from '@stores'
import { COMPONENT as PARENT } from '../ds'
import { Ctx } from '../types'

export const COMPONENT = rc(PARENT, 'Info')

/** 组件描述 */
function Info() {
  const { $ } = useStore<Ctx>(COMPONENT)
  return (
    // JSX
  )
}

export default observer(Info)
```

页面和子组件统一使用命名函数声明 + 底部 `export default observer(Xxx)` 导出。

## 子组件 Props 类型规范

子组件 `types.ts` 的 `Props` 按以下优先级编写：

### 1. 能从上层 Pick 则 Pick

结构与父级类型一致或为其子集时，用 `Pick` 派生，不重复声明：

```ts
// grid/list/types.ts
import type { Props as GridProps } from '../types'

export type Props = Pick<GridProps, 'title'>
```

交叉写入的自有 key 单独加注释：

```ts
// empty/types.ts
import type { Props as ListProps } from '../list/types'

export type Props = Pick<ListProps, 'title'> & {
  /** 当前列表条目数 */
  length: number
}
```

上级没有独立 Props 类型时，最常见的场景是**接收上级展平的数据模型条目**（List 里 `{...item}` 透传），此时从共享数据模型 `Omit` / `Pick` 派生，不手写一份字段相同的镜像类型；回调签名优先复用组件库已有的 prop 类型：

```ts
// item/types.ts —— 上级 List 直接展开 OriginItem 条目
import type { InputProps } from '@components'
import type { Keys, OriginItem } from '../../types'

export type Props = Omit<OriginItem, 'desc'> & {
  /** 源头类型 */
  type?: Keys

  /** 输入框聚焦时滚动到可视区 */
  onScrollIntoViewIfNeeded?: InputProps['onScrollIntoViewIfNeeded']
}
```

- 数据模型缺失实际数据携带的字段时（如 `iconSquare`），把字段补进数据模型，而不是在子组件 Props 里凭空手写
- 上级自身没有可 Pick 的类型（页面无 props、上级 Props 只有一个回调字段）时，才按第 2 条手写

### 2. 不能 Pick 则保留手写，key 必须有字段注释

父级为可选字段 / 子级要求必传、或子级接收展平标量等无法 Pick 时，保留手写类型；每个自写 key 都要有 `/** */` 字段注释。**不要**写「为何不 Pick」之类的类型级说明注释：

```ts
import type { SubjectId } from '@types'

export type Props = {
  /** 条目 Id */
  subjectId: SubjectId

  /** 已看集数 */
  epStatus: string | number
}
```

### 3. 格式

首个字段的注释可紧贴 `{`，后续字段的注释与上一个字段之间必须空一行（与全局声明注释规则一致）。

## 页面风格要求

- headerRight 等回调用 `useCallback` 包裹：

```tsx
const handleHeaderRight = useCallback(
  () => <IconHeader name='md-info-outline' onPress={handlePress} />,
  []
)

return <Header title='标题' hm={HM} headerRight={handleHeaderRight} />
```

- 埋点 `t()` 置后：先执行核心业务逻辑（如 alert、导航），后执行 `t()` 埋点调用

## 页面区块（component/xxx/）双组件模式

滚动密集页面（如条目页）的区块采用 Wrap + 内容组件两层结构，配合 React.lazy 分包，是 `observer` 通则的**合法例外**：

```
component/ep/
├── index.tsx     # EpWrap: function + useStore + observer；读 show* 守卫显隐、组装数据、注册 ref
├── ep.lazy.ts    # export default lazy(() => import('./ep')) —— 代码分割
├── ep.tsx        # 内容组件: memo(function Ep(...) {...}, DEFAULT_PROPS, COMPONENT_MAIN)
└── ds.ts         # COMPONENT_MAIN = rc(COMPONENT)、DEFAULT_PROPS(含 FROZEN_FN 兜底)
```

- Wrap 是 store 连接点：早退（模块隐藏）、派生选择逻辑都在这层
- 内容组件保持 **props 驱动 + memo 浅比较**，不接 store；由上层保证 props 引用稳定
- 区块锚点统一用 `component/block-anchor`（`<BlockAnchor title={TITLE_X} onBlockRef={onBlockRef} />`），不要手写 `useCallback` + `<View ref collapsable>` 样板
- 新增区块遵循此模式；不要把内容组件改成 observer 直连 store（会破坏浅比较优化）

## 敏感标签过滤（条目页第三方标签）

条目页底部四个第三方标签块（`component/tags/block/`，`path` = `Anime` / `Game` / `Manga` / `Wenku`）在 `userStore.isExtremeLimit` 为真时，统一走 `isSensitiveTag()` 过滤：

- 判定实现在 `src/utils/subject/sensitive-tag.ts`，三档匹配：**子串包含**（绝大多数词）、**整串相等**（`肉` / `变态` / `正太` / `触手` / `强制`，子串会误伤 `肉鸽` / `生肉` / `变态王子与不笑猫` 等）、**ASCII 单词边界**（`ntr` / `sm` / `3p`，会误伤 `LapinTrack` / `PLAYISM` / `d3p`）
- 四个块一视同仁，**不要**再按 `path` 区分；`path` 只用于跳转
- 「四块」指条目页标签区实际渲染的 4 个 `<Block>`（`component/tags/tags-list/index.tsx`），与 `src/utils/subject/` 下的 9 个频道目录无关：找ADV / 本子 / 三次元 / 音乐频道不渲染第三方标签块，它们用各自 ds 的 `TAGS` 做筛选 chip；但词表来源覆盖全部频道目录
- 词表增删后必须跑 `src/utils/subject/__tests__/sensitive-tag.test.ts`：它以 typerank 五类全量标签（4776 条）为语料，断言命中集合与人工确认清单**完全一致**（多一个即误伤，少一个即漏词）
- 题材义标签（`BL` / `百合` / `耽美` / `后宫` / `纯爱` / `一般向`）与资源义标签（`生肉`）一律不遮

## 页面内组件规范（components/）

纯 UI 小组件放 `components/`，不访问 Store（这是它与 `component/` 下子组件的唯一区别）。目录结构、类型定义（`PropsWithChildren<WithViewStyles<{}>>`）、样式（`stl()`）、命名函数 + `export default observer()` 写法均同 [component.md](./component.md)。

---

### .Claude Docs Screens (`.claude/docs/screens.md`)

# 页面索引

> 新增或修改页面时，同步更新此文件。

## Discovery

adv, anime, anitama, award, bi-weekly, blog, browser, calendar, catalog, catalog-detail, channel, character, dollars, game, hentai, like, manga, music, nsfw, pic, rank, recommend, search, series, staff, tags, users, vib, wenku, wiki, word-cloud, yearbook

## Home

catalogs, characters, episodes, info, link, mono, overview, persons, preview, rating, subject, tag, typerank, v2, voices, wiki, works

### v2（首页）

COMPONENT: `Home`，NAMESPACE: `ScreenHomeV2`

用户登录后的主页面，展示在看/在玩的收藏条目，支持 5 个分类 tab：全部/动画/书籍/三次元/游戏。

**Store 关键状态**：
- `top: SubjectId[]` — 置顶条目
- `item: Record<SubjectId, { expand, doing }>` — 每个条目的展开/进行中状态
- `page` — 当前 tab 页码
- `grid` — 网格布局选中的 subjectId
- `filter` — 各 tab 的筛选文本
- `visible`, `modal`, `progress` — 弹窗/进度等瞬态

**Store 主要方法**：
- `init()` — 加载缓存 → 延迟 4s 拉取最新数据
- `fetchSubject()` — 单条目拉取（6h 缓存）
- `fetchSubjectsQueue()` — 批量队列拉取
- `doWatchedNextEp()` — 标记下一集已看
- `doUpdateSubjectWatched()` — 批量更新看到第几集
- `doSaveCalenderEvent()` / `doExportCalenderEventICS()` — 日历提醒
- `itemToggleExpand()` / `itemToggleTop()` — 展开/置顶

**子组件**：
- `tab/` — Tab 切换（支持 2+ tab 用 Tab 组件，1 tab 直接渲染）
- `list/` — 列表布局（每条 item 可展开显示章节按钮）
- `grid/` — 网格布局（含 info 子组件：eps/cover/title/toolbar 等）
- `item/` — 条目项（cover/eps/count/title/toolbar/onair/progress 等拆分）
- `filter/` — 筛选栏
- `modal/` — 收藏管理弹窗
- `tips/` — 提示条
- `heatmaps/` — 热力图
- `time/` — 时间显示
- `is-top/` — 置顶标识
- `empty/` — 空状态

## Login

assist, token, v2

## Rakuen

blog, board, group, history, mine, notify, reviews, search, setting, topic, ugc-agree, v2

## Timeline

say, v2

## Tinygrail

advance, advance-ask, advance-auction, advance-auction2, advance-bid, advance-sacrifice, advance-state, bid, chara-assets, clipboard, deal, ico, ico-deal, items, logs, lottery-rank, new-bangumi, overview, relation, rich, sacrifice, search, star, temples, top-week, trade, transaction, tree, tree-rich, valhall, wiki

## User

actions, backup, blogs, catalogs, dev, friends, milestone, origin-setting, pm, qiafan, server-status, setting, smb, sponsor, timeline, user-setting, v2, zone

## WebView

bilibili-sync, douban-sync, information, log, playground, share, tips, versions, web-browser, webhook

---

### .Claude Docs Store (`.claude/docs/store.md`)

# MobX Store 规范

## Store 继承链

```
State → Computed → Fetch → Action
```

- **State**（extends `@utils/store`）— observable state、init()、save()、readStorage()
- **Computed**（extends State）— @computed 派生数据
- **Fetch**（extends Computed）— API 请求 → setState() + save()
- **Action**（extends Fetch）— 业务逻辑

## Store 文件拆分

每个 store 目录包含：`index.ts`、`state.ts`、`computed.ts`、`fetch.ts`、`action.ts`、`common.ts`（解析器）、`init.ts`（初始状态）、`types.ts`

## 大文件二次拆分（链式中间类）

单文件超过 400 行红线时，按领域切成子目录中的中间类文件，继承链接力，对外导出不变：

```
store/
├── computed.ts          # 聚合入口: export default class Computed extends Derived {}
├── computed/
│   ├── base.ts          # extends State
│   ├── meta.ts          # extends Base
│   └── ...
├── fetch.ts             # 核心请求, extends fetch/extend
└── fetch/
    ├── oss.ts           # extends Computed
    └── ...
```

规则：
- 中间类文件只做「方法搬家」，函数体逐字不变；被调用者所在类必须位于调用者祖先方向（链序 = 依赖序）
- 每个新文件自带裁剪后的 import 子集，基类导入指向上一环（`import Oss from './oss'`）
- 纯函数工具拆为目录 + `index.ts` 再导出（如 `store/utils/`），消费方 `from './utils'` 零改动

已应用：`home/subject/store`（computed/ fetch/ action/ utils/ 四组）、`home/v2/store`（computed/ action/ utils/ 三组）、`discovery/word-cloud/store`（computed/ 一组：base/meta/comment/collection）

## 18 个 Domain Store

| Store | 职责 |
|-------|------|
| `calendarStore` | 放送日历 |
| `collectionStore` | 用户收藏 |
| `discoveryStore` | 发现/搜索 |
| `monoStore` | 人物/制作人员 |
| `otaStore` | OTA 数据 |
| `rakuenStore` | 超展开（论坛） |
| `searchStore` | 搜索 |
| `smbStore` | SMB 网络文件 |
| `subjectStore` | 条目（动画/书籍/游戏） |
| `systemStore` | 系统设置 |
| `tagStore` | 标签 |
| `themeStore`（别名 `_`）| 主题/样式 |
| `timelineStore` | 时间线 |
| `tinygrailStore` | 小圣杯（股票游戏） |
| `uiStore` | UI 状态 |
| `userStore` | 用户认证 |
| `usersStore` | 其他用户信息 |

全局初始化：`src/stores/global.ts` → `GlobalStores.init()`

---

### .Claude Docs Testing (`.claude/docs/testing.md`)

# 测试规范

- `it()` / `test()` 描述使用**中文**，格式为简洁的行为描述
- `describe()` 为被测函数/模块名，`it()` 描述预期行为，如 `it('空字符串返回空数组')`
- 测试注释使用中文，保持格式一致
- **主动发掘隐藏问题**，用 `[问题]` 前缀标注，如 `it('[问题] 锁永远不释放时 Promise 永远挂起')`
- **发现问题时测试应该失败**，而不是通过——这样用户看到失败才会去修复代码。写期望的正确行为，而非当前的错误行为
- 常见需验证的问题：
  - 内存泄漏：`setInterval`/`setTimeout` 无清理机制、持续累积的订阅/监听
  - 竞态条件：并发调用共享状态、Promise 乱序完成
  - Promise 永远挂起：缺少 reject/resolve 路径、错误时等待者未通知
  - 异常处理：`async executor` 反模式、空 catch 吞掉错误
  - 边界情况：switch 无 default、空值/undefined 未处理
- **不要在测试文件里局部 mock `@utils/dev`**：jest.setup.js 已按 resolved 路径全局注册完整 mock（含全部导出与 logger 七个方法），相对路径导入同样生效；需要断言日志时直接 `import { logger } from '@utils/dev'` 后对 jest.fn 断言

# hook 测试规范

RNTL 的 `renderHook` 因 `ensure-peer-deps` 严格校验 `react-test-renderer` 与 `react` 版本完全一致而不可用（版本漂移即抛错，依赖安装由用户自理）。hook 测试直接用 `react-test-renderer` 手写最小 harness（参考 `src/components/touchable/__tests__/hooks.test.ts`）：

- `jest.requireActual('react-test-renderer')` 引入（该包无 TS 类型，`requireActual` 返回 any 规避）
- 顶部设 `globalThis.IS_REACT_ACT_ENVIRONMENT = true`，否则 act 内状态更新不生效
- 所有触发 setState 的调用（含 afterEach 里 `jest.runAllTimers()` 执行到的解锁回调）都要包在 act 内
- 依赖 store 的 hook：在 jest.setup.js 的 `@stores` mock 通过 `global.__mockStoreState__` 可变 cell 暴露字段（如 `uiStore.isScrolling`），测试里直接改 cell 控制分支
- 屏蔽 `react-test-renderer is deprecated` 官方告警用定向 `jest.spyOn(console, 'error')` 过滤，保留其余错误输出

# cheerio HTML 解析测试规范

## HTML 解析引擎对照 (engine-battery)

- 生产引擎固定为 `next`（cheerio 1.0 slim，静态 import）；`legacy`（0.20）仅测试对照用，生产代码不引用、不进 bundle
- 双引擎对照方式：命令行 `HTML_ENGINE=legacy npx jest`，battery 测试在模块顶层 `__setEngineForTest(legacyEngine())` 注入（结果收集在模块顶层执行，注入不能放 beforeAll），afterAll 传 `null` 恢复
- `src/utils/thirdParty/html/__tests__/engine-battery.test.ts` 对 24 个真实页面 fixture + 内联边界用例跑生产在用的全部选择器/方法组合，与 `battery-baseline/<engine>.json` 逐字节对照；`REGEN_FIXTURES=1` 重新生成基线
- 修改引擎适配层或 `src/utils/thirdParty/html/` 解析逻辑后，必须跑 battery 并确认两引擎基线零 diff；新引擎行为确需变化时先改适配层归一（如实体大小写归一在 `engines/slim.ts`），归一不了才 REGEN 基线并记录理由
- 注意：文档级 `$`（`cheerio(html)` 的返回值）没有 `.find` 等实例方法，`cFind($, ...)` 会走 catch 返回 `$el` 本身，`.length` 是函数 arity——测试中 cFind/cList 必须作用于元素实例（与生产一致）

以下模式来自 `src/stores/user/__tests__/common.test.ts`，按需参考，自行判断是否适用。

## 结构

```
describe('运行时结构符合类型定义', () => {   // 放在最前
  it('函数名 返回 TypeName', () => {          // 用 expect.any() 验证每项字段类型
    ...
  })
})

describe('函数名', () => {                    // 具体的值验证
  it('总数验证', ...)                         // toHaveLength(n)
  it('首项关键字段', ...)                      // toMatchObject({ ... })
  it('边界情况', ...)                          // Re: 前缀、空值、特殊字符
  it('异常/边缘值', ...)                       // userId 为 0、HTML 实体编码
})
```

## 常见做法

1. **类型校验优先**：第一个 describe 用 `expect.any(String)` / `expect.any(Boolean)` 验证每项结果是否匹配 TS 类型定义，数据类型不符时第一时间暴露
2. **HTML 存外部文件**：真实页面 HTML 放在 `__tests__/html/` 下，通过 `fs.readFileSync` 加载，该目录建议加入 `.gitignore`
3. **使用 cHelper 方法测试**：测 cheerio 解析函数推荐用项目封装的 `cParse` / `cMap` / `cFind` / `cText` / `cData` / `cHtml` / `cHasClass`，而非直接用 cheerio，保证与生产代码行为一致
4. **`toMatchObject` 部分匹配**：用 `toMatchObject` 只检查关心的字段，避免因`cHtml`对中文做 HTML 实体编码（如`测试`→`&#x6D4B;&#x8BD5;`）导致全量匹配失败
5. **内容含中文用 `toContain`**：`cHtml` 返回 HTML 实体编码后的字符串，精确匹配可考虑使用 `toContain` 配合部分内容
6. **每类边缘场景一个 it**：Re: 前缀拆分、`/` 分隔符、`icon.jpg` 默认头像、`&amp;` HTML 实体等各自独立成 it
7. **`[类型定义问题]` 标注类型与实际不符**：如果函数运行时返回值结构与 TS 类型定义不一致（如 label 项缺少 `name`/`avatar`/`userId` 但类型标记为 required），可在注释中用 `[类型定义问题]` 标注
8. **`@utils` 在 jest 中被 jest.setup.js 虚拟 mock**：mock 只覆盖部分导出，若解析函数用到 `@utils` 中 mock 未提供的工具，需先在 jest.setup.js 补充对应实现（与真实实现保持一致），否则运行时会是 undefined
9. **`cText` / `cEach` / `cPagination` 等解析工具直接复用真实实现**：为避免 mock 与生产代码分叉，jest.setup.js 的 `@utils` mock 通过 `require(__dirname + '/src/utils/thirdParty/html/parse')` 直接复用 `parse.ts` 中抽离的解析函数，不要重复实现

# src/styles 测试规范

参考 `src/styles/__tests__/`。**不要断言字面量**（改源码就要同步改测试，纯噪音），只测不变量/派生关系：亮暗配色成对、Raw 能从源色解析、工具类引用统一 token（`mt.sm.marginTop === sm`）、几何自洽（`wind === floor((width - contentWidth)/2)`）、`tabsHeaderHeight === headerHeight + tabsHeight`、`lineHeight === floor(fontSize * lineHeightRatio)`、默认导出 `_` 覆盖全部命名导出。

注意点：

- **layout/utils 需 mock 环境**：顶部 `jest.mock('@constants/device')` 补全 `PAD`/`RATIO`/`STORYBOOK_*` 等（全局 mock 只有 `WEB`），`expo-constants` 须带 `__esModule: true`；测 `IOS` 分支再 mock `@constants/constants`
- **jest 不支持 `expect(a, msg)` 双参**，用 `expect(cond).toBe(true)`
- **`@styles`（无子路径）不在 moduleNameMapper**，`index.test.ts` 用相对路径 `../index`

---

### .Claude Docs Token Budget (`.claude/docs/token-budget.md`)

# Token 预算规则

## 禁止读取的路径（文件过大，浪费 tokens）

- `src/assets/json/` — 所有 JSON 数据文件（mono.json、vib.json、substrings/_、typerank/_）
- `src/assets/` — 图片、字体等二进制资源（39M+）
- `dist/`、`node_modules/`、`metro-cache/`
- `web/test/*.json` — 测试数据
- 任何超过 50KB 的 `.json` 文件

## 替代方案

- `src/` 源码与 `package.json`、`tsconfig.json` 等配置文件可自由读取
- 用 grep/find 查找 JSON key 或资源在代码中的引用
- 了解数据结构先看类型定义、import 语句、消费它的 store，而非直接读数据文件
- 如确需了解 JSON 结构，只用 Read 工具的 `limit` 参数读前 20 行

---

--- METRICS ---
- Files Extracted: 14
- Estimated Token Budget: ~12886 tokens
- Canonical Reference: https://codewiki.google/github.com/czy0729/Bangumi
