# 找书(Legado 书源)开发文档
彩读的「找书」功能兼容 [legado-E(阅读Sigma)](https://github.com/Luoyacheng/legado-E) 文本书源 JSON 格式,
在主进程用 TypeScript 复刻其核心解析链路,渲染进程提供**独立找书窗口**:书架、搜索、发现、详情、在线阅读、书源管理与整书下载。
> 用户入口:**更多 → 找书**(快捷键 `F7`),打开独立窗口(非主窗口内嵌面板)。
> 亦可:桌面快捷方式、`--find-book` 启动;开发时用 `npm run dev:find`(`electron-vite dev -- --find-book`)。
> 实现参考:
> - [Legado_Max 帮助文档](https://github.com/youfengknight/Legado_Max/tree/main/app/src/main/assets/web/help/md)
> - [Legado 书源规则说明](https://mgz0227.github.io/The-tutorial-of-Legado/Rule/source.html)
> - [破冰的源教程](https://www.yuque.com/legado/yuan/pe61gy)
---
## 1. 目录结构
```
src/
├── shared/bookSource/ # 主进程 / 渲染进程共享
│ ├── types.ts / ipc.ts / url.ts / paths.ts
│ ├── loginUi.ts / wordCountFormat.ts / legadoFlexStyle.ts
│ └── chapterReadingOrder.ts
├── shared/findBookWindowTitle.ts
├── main/findBookLaunch.ts # --find-book、桌面快捷方式、初始 Tab
├── main/bookSource/
│ ├── registerBookSourceIpc.ts / searchService.ts / downloadService.ts
│ ├── checkSourceService.ts / integrationSmoke.ts
│ ├── store/bookSourceStore.ts
│ └── engine/ # Legado 规则引擎(见 §3)
│ ├── webBook.ts / chapterCache.ts / getChapterContentWithCache.ts
│ ├── analyzeRule.ts / analyzeUrl.ts / jsExtensions.ts / …
│ ├── bookSourceJsTimeout.ts / legadoJsoupShim.ts / …
│ └── exploreKinds.ts / coverImage.ts / loginCheck.ts / …
└── renderer/src/
├── findBookMain.ts / FindBookWindow.vue / find-book.html
├── components/
│ ├── AppCaptchaHost.vue / IconButton.vue
│ ├── LoadingDotsBounce.vue / LoadingDotsRotate.vue # 「加载中」动画
│ ├── RefreshIcon.vue # 刷新图标(静止 / 旋转)
│ └── HighlightedCodeTextarea.vue # 透明 textarea + 高亮叠层(编辑书源内联)
└── bookSource/
├── components/
│ ├── FindBookPanel.vue # 壳:三 Tab + 叠层
│ ├── FindBookshelfPanel.vue / FindDiscoverPanel.vue
│ ├── BookDetailPanel.vue / FindBookReaderPanel.vue
│ ├── FindBookReaderHeader.vue / BookSourcePanel.vue
│ ├── FindBookListItem.vue # 搜索/发现书籍行(VirtualList 固定行高)
│ ├── findBookListLayout.ts # 搜索/发现虚拟列表 rowStride
│ ├── ReplaceRulePanel.vue # 文本替换管理
│ ├── EditBookSourcePanel.vue / ImportBookSourcePanel.vue
│ ├── BookSourceFieldMonacoModal.vue # 单字段全屏 Monaco(按需动态加载)
│ ├── BookSourceLoginPanel.vue / CheckSourceConfigPanel.vue
│ ├── DisclaimerPanel.vue
│ └── FindBookSettings*.vue
├── editBookSourceFields.ts # 编辑表单字段定义 / 行高上限
├── highlightBookSourceCode.ts # 内联轻量正则高亮(对齐 CodeView)
├── formatBookSourceFieldText.ts # 全屏字段语言推断 + js-beautify 格式化
├── monaco/
│ ├── bookSourceRuntimeLib.ts # 书源 JS 运行时 d.ts 文本(补全用)
│ └── ensureBookSourceMonacoLibs.ts # addExtraLib 幂等挂载
├── composables/
│ ├── useBookSource.ts / useFindBookBookshelf.ts
│ ├── useBookshelfUpdate.ts / useChapterCacheMarks.ts
│ ├── useFindBookSettings.ts / useFindBookReaderSettings.ts
│ └── useFindBook*Shortcuts.ts
├── findBookBookshelf.ts / bookshelfOpenReader.ts
├── services/clearBookChapterCache.ts / findBookDownloadActions.ts
└── …
```
**持久化位置(userData)**
| 路径 / 键 | 说明 |
|------|------|
| `book-sources.db` | 书源 JSON、登录字段、source 级 cache |
| `localStorage: colortxt:replaceRules:findBook` | 找书文本替换规则 |
| `localStorage: colortxt:replaceRules:app` | 主窗口文本替换规则 |
| `book_source_cookies`(表) | 按域名 Cookie |
| `book-source/files/` | `importScript` / `cacheFile` 本地脚本 |
| `DownloadedBooks/` | 默认整书导出目录(找书设置可改) |
| `book_cache/` | 章节正文离线缓存(找书设置「缓存目录」可改) |
| `bookSourceAndroidId.txt` | `java.androidId()` 稳定值 |
| `localStorage: colortxt:findBookBookshelf` | 书架(进度、可选目录缓存) |
| 找书设置相关 localStorage | 与主应用「设置」分离(缓存/下载目录、阅读偏好、网络代理等) |
---
## 2. 架构与数据流
```
渲染进程 (FindBookWindow → FindBookPanel)
│ window.colorTxt.bookSource* (preload → IPC)
▼
registerBookSourceIpc.ts
├── bookSourceStore / searchService / downloadService
└── engine/webBook.ts → AnalyzeUrl + AnalyzeRule + jsExtensions
```
### 2.1 UI 面板与叠层
```
FindBookPanel(standalone 时即为找书窗口内容)
├── Tab:书架 | 找书 | 发现
├── BookSourcePanel / FindBookSettingsPanel / DisclaimerPanel
├── BookDetailPanel ← 叠层(书籍信息)
└── FindBookReaderPanel ← 叠层(在线阅读)
```
**三 Tab 显隐**:书架 / 找书 / 发现均用 `v-show`(找书为 `.findBookSearchPane`),切换 Tab 时**保留**各自列表滚动位置。
找书隐藏时不跑 `tryAutoLoadMoreSearch`(避免 `display:none` 尺寸为 0 误触发加载);切回找书再补一次。
**详情 ↔ 阅读器(`AppModal` + `modalStack.bringToFront`)**
| 操作 | 行为 |
|------|------|
| 首次从详情开阅读 | 打开阅读器;**不关**详情(压在下层) |
| 详情再次点章 /「开始阅读」且阅读器已在 | 阅读器抬到最前并切章,**同时关闭详情** |
| 阅读器顶栏「更多 → 书籍信息」 | 打开或抬升详情(阅读器可留在下层) |
| 换书(另一 `bookUrl`+`origin`) | 关闭阅读器并清空其 props,避免串书 |
两个「更多」:阅读器**顶栏**(书籍信息 / 编辑书源 / 清除缓存)vs **阅读工具栏**(窄屏版式与设置)。顶栏「刷新」「登录」已外置为图标按钮,勿与主应用「清除缓存」
(清 localStorage)混淆。
### 2.2 典型流程
**搜索** — `searchService` 多源并发 → `searchEvent` 流式推送。
**发现** — `exploreKinds` → `webBook.exploreBook`(`ruleExplore`)。
分类书籍列表支持顶栏「第 N 页」跳转指定页(对齐 Legado);滚动仍可继续加载后续页。
**详情 → 目录 → 正文**:
- 搜索/发现列表经 `searchBookToBook` 得到未完善 `Book`(`tocUrl` 为空)。
- `getBookInfo` 写入真目录地址并完善同一份 `Book`。
- `getChapterList` / `getChapterContentWithCache` 只收完整 `book`(读写 `book_cache`;
正文可 `preferCache: false` 强制联网)。
- 列表字段顺序对齐 Legado:`kind` 先于 `bookUrl`,以便 `{{book.kind}}`(如 `resourceId`)
在拼详情链接时已是规则结果(含 `##` 去前缀)。
- 目录/正文通过 `AnalyzeRule.setBook(book)` 使用。
**整书下载** — 先缓存全部非分卷章,再导出 UTF-8 `.txt`(正文非空段首「 」;标题不加)。
**离线缓存** — 阅读器侧栏「离线缓存」走下载管道 `cacheOnly: true`,只写 `book_cache`、不导出 `.txt`。
**书架开读** — 立即打开阅读器(可先展示书架侧详情 + 空目录,`tocLoading`),后台拉目录后再加载正文;有本地目录缓存可加速。
---
## 3. 核心模块原理
> **Legado 对照源码**
> - `app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt`
> - `app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt`
> - 子解析器:`AnalyzeByJSoup.kt` / `AnalyzeByJSonPath.kt` / `AnalyzeByRegex.kt`
>
> **ColorTxt 实现**
> - `engine/analyzeRule.ts` — 规则解释器
> - `engine/analyzeUrl.ts` — URL 解析与请求
> - `engine/legadoRuleSplit.ts` — `splitSourceRule`
> - `engine/legadoDefaultRule.ts` — `##` 正则后缀、Cheerio 选择器
> - `engine/legadoCompositeRule.ts` — `makeUpRule`、`parseLegadoUrlSuffixJson`
### 3.1 AnalyzeRule(规则解析)
**职责**:对**已有 HTML/JSON/对象**应用 Legado 规则链,输出字符串、URL 或元素列表。
**不负责发请求**;网络由 `AnalyzeUrl` / `java.ajax` 完成。
#### 3.1.1 Legado 总流程
```mermaid
flowchart TD
A["setContent 判定 JSON/HTML"] --> B["splitSourceRule 仅按 JS 切段"]
B --> C["SourceRule 构造"]
C --> C1["detectMode + splitPutRule"]
C --> C2["解析 @get / 双花括号模板 为 ruleParam"]
C --> C3["makeUpRule 无 result 预展开"]
D["getString / getElements 链式循环"] --> E["putRule @put"]
E --> F["makeUpRule 带当前 result"]
F --> G{mode}
G -->|Js| H[evalJS]
G -->|Json| I[AnalyzeByJSonPath]
G -->|XPath| J[AnalyzeByXPath]
G -->|Default| K[AnalyzeByJSoup]
G -->|Regex| L[AnalyzeByRegex]
H --> M["replaceRegex ##后缀"]
I --> M
J --> M
K --> M
L --> M
M --> N{"result 为空?"}
N -->|是| O[链中断]
N -->|否| D
```
**`splitSourceRule`(Legado)**:**只**在 `…` / `@js:` 处切段;
`&&` / `||` / `%%` **不会**在此层拆成多段。
`@js:` 对齐 Legado `JS_PATTERN`(`@js:([\w\W]*)` 贪婪到规则末尾);续行纯 JSONPath(`$.a`)可截断,
**勿**把 `$[i].select(...)` 等 JS 误判为 JSONPath。
规则 JS 里 Jsoup `Element.select` 含根节点自身匹配(`li.select("li")`);
`getElements` / Default 与 `@css:` 列表结果均为 Jsoup Element(可 `.attr` / `.select` / `.forEach`),
`java.getElement` 对齐 Legado 返回 **Elements**(可直接 `.text()` / `.attr()`,如部分书源发现
`java.getElement("@@tag.a.0").text()`)。
非仅 HTML 字符串;`JSON.stringify(Element)` 对齐 Rhino 输出 `outerHtml`(见 `toJSON`)。
`select()` 返回的 Elements 为**数组**(数字下标可枚举,`size`/`attr` 等不可枚举),以便
`for (i in els) { els[i].attr(...) }` 与 Rhino 一致。
若 `` 返回 `JSON.stringify(Elements)`,下一跳选择器会先 `JSON.parse` 再拼 HTML,
避免 Cheerio 把 `\"` 当属性截断(如目录排序后的 `tag.a`)。
部分书源章名/章链 JS 仍按「Legado 把 JSON 当 HTML 解析」时的 `\"` 形态写正则;
`bindJsHtmlValue` / `mangleLegadoObfuscatedAnchorHtml` 会把干净的 `` 还原成该形态,
并将 base64 型 `data-*` 重排到 mangled `class` 之后,以匹配 try 分支正则顺序。
列表项为 Element 时,`isPlainRuleObject` / `{{@@…}}` kind 模板须走嵌套 `getString`,
不可误走 `readJsonField`(否则分类/状态标签会整段丢失)。
非 JS 段整段交给 `AnalyzeByJSoup` 等,由 `RuleAnalyzer.splitRule("&&","||","%%")` 在**同一段、
同一 DOM/JSON 上下文**内处理。
**`SourceRule`(Legado 内嵌类)** 每段规则在构造时完成:
1. **模式**:
- `@js:` → Js;`@Json:`/`$.` → Json;`@XPath:`/`//` → XPath;含
`@get:`/`{{}}` 且无 `##` → Regex;否则 Default。
- `@put`/`@get` 展开后的 `https://…` / `data:` /
`/book/x.html` 视为字面 URL(`isLegadoLiteralUrlRule`),勿再当 JsonPath。
- **仍含 `{{…}}`/`@get:` 时不可当字面量**(须先 `makeUpRule`,如 `$.bid`→`…`→
`https://…?bookid={{result}}`)。
- **含 `&&`/`||`/`%%` 亦不可当字面量**(须分段:URL 前缀原样拼接 href,非法 CSS 勿抛)。
- `@js:` 后续行若以 `/pat/.test(...)` 等正则表达式开头,
勿拆成 XPath(否则 isVip 只剩 `//` 注释 → `Unexpected end of input`)。
- 搜索 `@put:{id}` 须写入 `SearchBookItem.variable` 并随 `getBookInfo` 带入
(否则 `tocUrl=list/@get:{id}` 丢 id,目录回退详情页把推荐书当章节)。
- **`AnalyzeRule.put` 对齐 Legado**:`chapter` → `book` → `ruleData` → **source**(有
book/chapter 时**不**写入书源 cache);否则详情 `@put:{bid:id}` 会污染全局 cache,
书架直开正文 `java.get('bid')` 串到其它书。书架须持久化 `Book.variable`。
- 从 URL 回填 `bid` 时:勿覆盖**本书** `book.variable` / 规则链已有值;勿把书源
cache 里其它书的 bid 当成「已有」;且须剥掉 `bid=bid=hash`
(部分书源把 `Params="bid="+hash` 再拼进 `&bid=`,裸捕获会得到 `bid=hash`,正文「无此书」)。
2. **`@put{…}`**:剥离到 `putMap`,链开始前 `putRule` 用 `getString(value)` 写入变量。
3. **`makeUpRule(result)`**:展开 `@get:`、`{{…}}`(内嵌规则或 `evalJS`);
再按 `##` 拆出 `replaceRegex` / `replacement`;**`ruleStrS[0].trim()`** 后再替换
(否则如部分书源 `{{…##·|\d.*}}##.*\s` 展开带尾空白时 `.*\s` 会整段清空,「完结」tag 丢失);
**四段** `rule##pat##repl###` 时 `replaceFirst=true`。
替换正则对齐 Kotlin `Regex` 默认:**`.` 不匹配换行**(勿加 DOTALL,
否则 `##(\s*.*【1】.*)?\s*标记` 类分页清理会把整篇正文吞掉);
跨行需求应由提取侧对齐 Jsoup(text/ownText 折叠空白、
`@html` pretty-print 换行,见 §7.2)解决。
4. **链式 `getString`**:每段 `makeUpRule(当前 result)` → 按 mode 解析 → `replaceRegex`;
`result == null` 则 **continue**(不回退 `content`)。
5. **`getElements`**:与 `getString` 不同,Legado **不在循环内**再次 `makeUpRule(result)`(仅构造期预展开);
`getElement` **会**在循环内 `makeUpRule(result)`。
**`evalJS` 绑定**(Legado `JsExtensions`):
| 绑定 | 含义 |
|------|------|
| `java` | `AnalyzeRule` 自身(implements JsExtensions) |
| `result` | 链上当前值 |
| `src` | 原始 `content`(整段响应) |
| `source` / `book` / `chapter` | 书源与书籍上下文 |
| `baseUrl` / `nextChapterUrl` | URL 上下文 |
| `cookie` / `cache` | CookieStore / CacheManager |
**变量 `put` / `get` 优先级**(Legado):`chapter` → `book` → `ruleData` → `source`。
#### 3.1.2 ColorTxt 映射
| Legado | ColorTxt | 说明 |
|--------|----------|------|
| `SourceRule` | `getOne` / `evalStringChainSegment` | 单段规则执行 |
| `splitSourceRule` | `legadoRuleSplit.splitSourceRule` | 仅按 JS 切段;`&&` 在段内处理 |
| `makeUpRule` | `expandAllTemplateExprs` + `applyPutPrefixRule` + `splitRuleRegexSuffix` | 模板展开与 `##` 后缀(见下) |
| `evalJS` | `evalRuleJs` → `evalJsAsync` | Node AsyncFunction,非 Rhino |
| `AnalyzeByJSoup` | `legadoDefaultRule` + cheerio | `&&`/`||`/`%%` 在段内处理 |
| `getStringList` NativeObject 分支 | `isJsonItemContent` + 字段直读 | JSON 列表项上 `{{$.id}}` 等 |
`makeUpRule` 补充:`{{@@…}}` / `{{$.…}}` / `{{result}}` 走嵌套 `getString`;其余 `{{}}` 内表达式走 JS;`##` 在 `legadoDefaultRule.ts`。
**规则模式**(`detectMode`):
| 前缀 / 特征 | 模式 | 说明 |
|-------------|------|------|
| `@css:` / `class.` / `tag.` | default | cheerio,对齐 Legado Default |
| `@json:` / `@Json:` / `$.` | json | jsonpath-plus |
| `@XPath:` / `@xpath:` / `//` | xpath | @xmldom/xmldom;见下 |
| `@js:` / `` | js | `evalJsAsync` + `legadoAsyncJs` |
| `@webjs:` | webJs | ColorTxt 扩展;Legado 正文 webView 多在 URL 后缀 |
| `{{` | template | 纯模板;内嵌 `@`/`@@`/`$.`/`//` 为嵌套规则(对齐 Legado `isRule`),非 Rhino |
| 纯 `{{jsExpr}}` | js → expand 后直接返回 | `{{'书名'}}` 勿再 eval 展开结果 |
| `:` 开头(`allInOne`) | regex | `AnalyzeByRegex`;Java `(?s)`/`(?i)`/`(?m)`/`(?u)` 转 JS flags(勿直接 `new RegExp`,否则部分书源目录为空) |
| 其他 | default | `legadoDefaultRule` |
JSON 正文时:裸字段/`result.list[*]` 走 JsonPath;含 `@text`、`class.`/`tag.`/`^.`、或 **CSS 属性选择器**
`[attr=…]` 的仍走 default。不可用宽泛 `\[[^\]]+\]` 判断,否则 `[*]` 会被误判(如部分书源目录)。
XPath 补充:`text/html` 带 XHTML 默认 ns 时须 `xpathIgnoreXhtmlDefaultNs`;`getElements` 返回元素 HTML(非 textContent)。
未声明的 `xlink:href` 等会使 xmldom 抛 NamespaceError——解析前补 `xmlns:xlink`(或剥前缀);
`//tag[@id=…]/*` 另用 cheerio 兜底。规则 JS 对 HTML 元素列表的 `String(result)` 须对齐 Java
Elements:`[html1, html2]`(部分书源 `slice(1,-1).split(/, (?=`、标签错配等)xmldom 解析会直接抛 fatalError——须先经 cheerio/parse5
纠错重排再解析,否则正文 `nextContentUrl` 等 XPath 静默取空、分页只加载第 1 页。
**链式与组合**(务必区分两层):
| 层级 | 分隔符 | Legado | ColorTxt |
|------|--------|--------|----------|
| **规则链** | 仅 `@js`/`` 切段 | 前段输出作后段 `result` | 已对齐 |
| **段内组合** | `&&` / `||` / `%%` | JSoup/Regex 内部 `splitRule` | `splitLegadoCompoundRule` + `mergeLegadoDefaultCompound` |
| **字段备选** | `\|\|`(kind 等) | `splitLegadoCompoundRule` | `shouldSplitOrAlternatives` + `getOne` 内 `\|\|` |
| **正则后缀** | `##pat##repl` / `###` | `SourceRule.replaceFirst` | `splitRuleRegexSuffix` |
**常用 API**:`getString` / `getStringList` / `getElements` / `getUrl`;
`setContent` / `setBook` / `setChapter`;`put` / `get`(与 `source.put`、`@get:` 对齐)。
**已知与 Legado 的差异(修引擎时优先核对)**:
1. **`getElements` 空结果**:Legado `result ?: continue` 不回退;
裸规则 `html`/`text`/`all` 在 **list** 模式须当 CSS/选择器(`select("html")`),不可走 `@html` 提取
(否则 `chapterList:"html"` 单章源得到字符串列表、目录解析失败)。
章名 `{{book.name}}` / `{{title}}` / `{{chapter.title}}` 等须走 Legado 绑定变量展开,勿误当 JS 表达式求值(空标题→章节被跳过;正文 `replaceRegex` 中 `{{chapter.title}}` 未展开会变成 `.*.*` 清空正文)。
ColorTxt 链中空段直接中断(已去掉回退 `content`)。
2. **JS 引擎**:Rhino 同步 + `shareScope` vs Node 异步 + `await java.ajax`;空返回、
`result`/`src` 语义需按 Legado 测。
- **零宽/格式字符**:须在 prepare 前剥离(`\u200B` 等);否则如部分书源
`source.getSource()` 在 V8 抛 `missing ) after argument list`。
- **`source.getSource()`**:对齐 Legado `BaseSource.getSource()` 返回书源自身;
`eval(String(source.getSource().bookSourceComment))` 须能内联注释(与
`source.bookSourceComment` 相同)。
- **`JavaImporter` + DES**:须提供 `DESKeySpec` / `SecretKeyFactory` /
`Cipher.ENCRYPT_MODE` 与 `android.util.Base64.encodeToString`(部分书源
`encryptByDES`);`Cipher.getInstance("DES")` 按 `DES/ECB/PKCS5Padding` 处理。
扁平 `Base64` 须同时保留 `java.util.Base64.getDecoder()`(部分 API 书源 AES 正文),
勿用纯 android 桩覆盖。
- **`java.getElements` / `getStringList`**:须返回**真正的 Array**(`ensureLegadoListApi`),
不可用非 Array 的 `legadoJsList` 对象;否则 `bookList` 中 `@js: …; return a` 后
`getElements` 会把整表包成 1 条,发现列表为空(部分论坛书源发现分类)。
`getElements` 对仍带 `toArray()` 的返回值须展开。
- **`ensureLegadoScriptReturn`**:跳过末尾纯注释行;`if (cond) a=…` 补 `return a`(勿 `return if`)。
`prepareLegadoAsyncJs` / 同步 IIFE 须在 `})()` 前换行,避免 `return //注释; })()` 被行注释吃掉收尾
(`Unexpected end of input`,如部分论坛书源发现 `bookList`)。
- **`evalRuleJs` 显式 `""`**:不可回退整页 `content`(否则无 `TextContent` 的 404 页经
`formatKeepImg` 会把 `