# 找书(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` 会把 `