commit 5435ebac6ee999ab61058414654df65768bd3a38
Author: Narylr <2378396898@qq.com>
Date: Thu Mar 19 22:47:30 2026 +0800
Initial publish of book-source-creator skill
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..df3942d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,129 @@
+# Legado书源创建技能
+
+用于为 Legado 阅读器创建、调试和验证书源。这个技能默认遵循三条原则:
+
+- AI 对页面结构、接口链路和规则语义的判断优先于脚本输出
+- 网站可登录时,优先在登录态下分析
+- 登录需要扫码、验证码、短信或人工确认时,应立即请求人类协助
+- 正式生成书源前,必须先做网站可生成性评估
+
+## 可生成性评估
+
+在写规则前,先输出一份站点评估。评级只允许使用以下四类:
+
+- `可直接生成`
+- `可生成但高风险`
+- `需登录后再评估`
+- `不建议生成`
+
+如果评级为 `需登录后再评估` 或 `不建议生成`,仍然允许继续,但必须明确标成 `高风险`,并说明继续生成的理由。
+
+推荐输出模板:
+
+```markdown
+## 网站可生成性评估
+- 目标站点:
+- 登录状态:
+- 搜索可用性:
+- 详情可用性:
+- 目录可用性:
+- 正文可用性:
+- 特殊风险:
+- 可生成性评级:
+- 是否继续生成:
+- 继续生成理由 / 停止理由:
+```
+
+## 脚本现状
+
+主脚本现在以 Node 为主:
+
+- `scripts/analyze_with_playwright.mjs`
+- `scripts/validate_source.mjs`
+- `scripts/test_rules.mjs`
+- `scripts/generate_template.mjs`
+
+同名 `.py` 文件保留为兼容入口,会自动转调到对应的 `.mjs` 脚本。
+
+## 依赖
+
+```bash
+npm i playwright
+```
+
+如果只使用 `validate_source.mjs`、`test_rules.mjs` 或 `generate_template.mjs`,不需要额外依赖。
+
+## 推荐用法
+
+```bash
+# 分析网站,支持人工登录后继续
+node scripts/analyze_with_playwright.mjs https://novel-site.com --manual-login --save analysis.json
+
+# 基于分析结果生成模板
+node scripts/generate_template.mjs --analysis analysis.json
+
+# 审计规则,不伪装成完整 Legado 解析器
+node scripts/test_rules.mjs my_source.json --keyword 凡人修仙传
+
+# 验证书源结构
+node scripts/validate_source.mjs my_source.json
+```
+
+兼容旧命令:
+
+```bash
+python scripts/analyze_with_playwright.py https://novel-site.com --manual-login
+python scripts/test_rules.py my_source.json
+python scripts/validate_source.py my_source.json
+```
+
+推荐执行顺序:
+
+1. 先判断是否需要登录
+2. 输出网站可生成性评估
+3. 再分析搜索、详情、目录、正文
+4. 最后生成和验证书源
+
+## 脚本说明
+
+### `analyze_with_playwright.mjs`
+
+- 打开真实浏览器分析页面
+- 可通过 `--manual-login` 让人类先完成登录
+- 记录登录相关元素、搜索入口、候选结果区块和部分网络请求
+- 适合需要登录态、动态内容或接口观察的站点
+
+### `validate_source.mjs`
+
+- 验证必填字段
+- 验证常见字段类型
+- 检查基础规则组结构
+- 输出错误和警告,适合做结构体检
+
+### `test_rules.mjs`
+
+- 审计规则是否仍是占位文案
+- 标记 JS/正则类高风险字段
+- 预览 `searchUrl` 变量替换结果
+- 不声称能完整执行 Legado 规则
+
+### `generate_template.mjs`
+
+- 支持交互式生成模板
+- 支持基于 `analysis.json` 生成模板
+- 默认禁用发现功能
+- 可填写登录 URL
+
+## 调试建议
+
+- 先看真实页面和真实请求,再看脚本输出
+- 脚本失败时先判断是脚本问题、环境问题还是站点行为变化
+- 如果脚本结论和页面真实表现冲突,应以页面和接口证据为准
+- 登录前后页面结构不同的站点,必须分别检查搜索、详情、目录和正文
+- 评估结论为 `需登录后再评估` 或 `不建议生成` 时,继续生成必须明确标记为高风险
+
+## 测试
+
+```bash
+node --test tests/book_source_tools.test.mjs
+```
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..11cf949
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,421 @@
+---
+name: book-source-creator
+description: Use when 需要为 Legado 阅读器创建、调试或验证书源,尤其是在分析网站结构、登录态页面、接口行为或排查解析规则异常时
+---
+
+# Legado书源创建技能
+
+本技能帮助用户为Legado阅读器创建和调试书源。Legado是一款Android阅读应用,支持自定义书源来访问各种小说网站。
+
+## 核心原则
+
+- AI模型对页面结构、接口链路和规则语义的分析应作为主判断依据,脚本和工具只用于辅助验证。
+- 如果脚本结果与页面实际表现、网络请求或模型推断冲突,优先相信直接观察到的证据,不要死磕脚本。
+- 网站支持或依赖登录时,应优先在登录态下分析搜索、详情、目录和正文,而不是默认以未登录状态直接分析。
+- 如果登录需要扫码、验证码、短信、二次验证或其他人工操作,应立即提醒人类协助登录,完成后再继续分析。
+- 在正式生成书源前,必须先做网站可生成性评估,先判断站点是否具备稳定生成条件,再决定是否继续推进。
+
+## 快速开始
+
+### 1. 先确认登录前提
+- 判断目标网站是否存在登录后才可见的搜索结果、书籍详情、目录或正文。
+- 能登录的网站,优先在登录态下进行分析。
+- 如果登录过程需要人类协助,应立即提醒人类完成登录,不要跳过登录直接继续。
+
+### 2. 做网站可生成性评估
+- 在写任何规则前,先判断搜索、详情、目录、正文是否都具备可分析入口。
+- 先给出评级,再决定是否继续生成,不要先写规则再回头补评估。
+- 可生成性评级只允许使用以下四类:
+ - `可直接生成`
+ - `可生成但高风险`
+ - `需登录后再评估`
+ - `不建议生成`
+- 如果评级为 `需登录后再评估` 或 `不建议生成`,仍然可以继续生成,但必须明确标成 `高风险`,并说明继续生成的理由和已知风险。
+
+### 3. 分析网站结构
+使用Playwright MCP分析目标小说网站的结构:
+- 导航到网站首页
+- 分析搜索功能
+- 查看书籍详情页
+- 检查目录页结构
+- 查看正文内容
+
+### 4. 创建书源模板
+基于网站结构创建书源JSON模板:
+
+```json
+{
+ "bookSourceName": "示例书源",
+ "bookSourceUrl": "https://example.com",
+ "bookSourceGroup": "分类",
+ "searchUrl": "搜索URL规则",
+ "ruleSearch": {
+ "bookList": "书籍列表规则",
+ "name": "书名规则",
+ "author": "作者规则",
+ "coverUrl": "封面规则",
+ "bookUrl": "详情页URL规则"
+ },
+ "ruleBookInfo": {
+ "name": "书名规则",
+ "author": "作者规则",
+ "coverUrl": "封面规则",
+ "intro": "简介规则",
+ "tocUrl": "目录URL规则"
+ },
+ "ruleToc": {
+ "chapterList": "章节列表规则",
+ "chapterName": "章节名称规则",
+ "chapterUrl": "章节URL规则"
+ },
+ "ruleContent": {
+ "content": "正文内容规则"
+ }
+}
+```
+
+### 5. 输出评估结论
+在开始生成规则前,至少输出一次以下模板:
+
+```markdown
+## 网站可生成性评估
+- 目标站点:
+- 登录状态:
+- 搜索可用性:
+- 详情可用性:
+- 目录可用性:
+- 正文可用性:
+- 特殊风险:
+- 可生成性评级:
+- 是否继续生成:
+- 继续生成理由 / 停止理由:
+```
+
+### 6. 测试书源
+使用测试工具验证书源功能:
+- 测试搜索功能
+- 测试书籍详情获取
+- 测试目录解析
+- 测试正文获取
+- 脚本报错或结果异常时,回到页面结构、请求链路和规则本身重新判断,不要让脚本决定结论
+
+## 核心概念
+
+### 书源结构
+Legado书源包含以下主要部分:
+
+1. **基本信息**
+ - `bookSourceName`: 书源名称
+ - `bookSourceUrl`: 书源网站URL
+ - `bookSourceGroup`: 分组
+ - `bookUrlPattern`: 书籍URL正则
+
+2. **搜索功能**
+ - `searchUrl`: 搜索URL模板
+ - `ruleSearch`: 搜索结果解析规则
+
+3. **发现功能**(一般不使用,除非用户明确要求)
+ - `exploreUrl`: 发现页URL
+ - `ruleExplore`: 发现列表解析规则
+ - 注意:大多数书源不需要发现功能,建议默认禁用
+
+4. **书籍详情**
+ - `ruleBookInfo`: 书籍信息解析规则
+
+5. **目录解析**
+ - `ruleToc`: 目录解析规则
+
+6. **正文获取**
+ - `ruleContent`: 正文解析规则
+
+### 规则语法
+
+Legado支持多种规则语法:
+
+#### 1. Jsoup Default语法
+```
+tag.div.0@class.content@text
+```
+- `tag`: 标签类型
+- `div`: 标签名
+- `0`: 位置索引
+- `@`: 分隔符
+- `text`: 获取文本内容
+
+#### 2. CSS选择器
+```
+@css:.book-list li a@text
+```
+
+#### 3. XPath
+```
+@XPath://div[@class="content"]/text()
+```
+
+#### 4. JSONPath
+```
+@json:$.data.books[*].name
+```
+
+#### 5. JavaScript
+```
+result.replace(/\\s+/g, ' ')
+```
+
+#### 6. 正则表达式
+- AllInOne: `:正则表达式`
+- OnlyOne: `##正则表达式##替换内容###`
+- 净化: `##正则表达式##替换内容`
+
+## 工作流程
+
+### 步骤1:先判断是否需要登录
+1. 检查网站是否存在登录入口、登录拦截、会员可见内容或未登录降级页面。
+2. 能登录时,优先完成登录,再分析搜索、详情、目录和正文。
+3. 如果登录需要扫码、验证码、短信、二次验证或人工确认,立即提醒人类协助完成登录。
+
+### 步骤2:先做网站可生成性评估
+1. 检查搜索是否可触达,是否能拿到稳定的结果列表。
+2. 检查详情页是否可触达,是否能拿到书名、作者、简介、封面或目录入口。
+3. 检查目录是否完整,是否存在分页、懒加载、倒序或登录后差异。
+4. 检查正文是否为可提取文本,是否存在单章多页、图片化、脚本渲染、强广告或内容截断。
+5. 检查是否存在明显反爬、跳转、签名、验证码、App 专属接口或高不确定性链路。
+6. 输出可生成性评级:
+ - `可直接生成`
+ - `可生成但高风险`
+ - `需登录后再评估`
+ - `不建议生成`
+7. 若评级为 `需登录后再评估` 或 `不建议生成`,仍可继续,但必须明确标记为 `高风险`,并写出继续生成的理由。
+
+### 步骤3:网站分析
+使用Playwright MCP或 `scripts/analyze_with_playwright.mjs` 分析目标网站:
+```javascript
+await page.goto('https://novel-site.com');
+// 如果网站需要登录,先由人类协助完成登录,再继续分析
+```
+
+分析时应同时观察页面DOM、接口请求、跳转逻辑和登录态差异。脚本可用于辅助抓取或整理结果,但不应替代模型对站点行为的判断。
+
+### 步骤4:创建搜索规则
+基于网站分析结果:
+1. 确定搜索URL格式
+2. 分析搜索结果列表结构
+3. 提取书籍信息字段
+
+### 步骤5:创建详情规则
+分析书籍详情页:
+1. 书名提取规则
+2. 作者提取规则
+3. 封面图片URL
+4. 简介内容
+5. 目录链接
+
+### 步骤6:创建目录规则
+分析目录页:
+1. 章节列表结构
+2. 章节名称提取
+3. 章节链接提取
+4. 分页处理
+
+### 步骤7:创建正文规则
+分析正文页:
+1. 正文内容提取
+2. 广告过滤
+3. 分页处理
+4. 特殊编码处理
+
+### 步骤8:输出评估模板
+在展示规则、生成 JSON 或宣称可继续之前,应先输出一份简短评估:
+
+```markdown
+## 网站可生成性评估
+- 目标站点:
+- 登录状态:
+- 搜索可用性:
+- 详情可用性:
+- 目录可用性:
+- 正文可用性:
+- 特殊风险:
+- 可生成性评级:
+- 是否继续生成:
+- 继续生成理由 / 停止理由:
+```
+
+若评级为 `需登录后再评估` 或 `不建议生成`,模板中的“是否继续生成”与“继续生成理由 / 停止理由”必须明确说明这是高风险继续推进,而不是默认通过。
+
+## 实用工具
+
+以下工具都应视为辅助工具,而不是决策中心。工具失效、脚本报错、规则测试不稳定时,应回到网页实际结构、接口响应和登录态行为重新分析。
+
+### 1. 网站分析脚本
+- 主脚本:见 [scripts/analyze_with_playwright.mjs](scripts/analyze_with_playwright.mjs)
+- 兼容入口:见 [scripts/analyze_with_playwright.py](scripts/analyze_with_playwright.py)
+- 说明:支持人工登录后继续分析,适合需要浏览器上下文和登录态的站点
+
+### 2. 书源验证脚本
+- 主脚本:见 [scripts/validate_source.mjs](scripts/validate_source.mjs)
+- 兼容入口:见 [scripts/validate_source.py](scripts/validate_source.py)
+- 说明:验证 JSON 结构、字段类型和基础规则组完整性
+
+### 3. 规则审计工具
+- 主脚本:见 [scripts/test_rules.mjs](scripts/test_rules.mjs)
+- 兼容入口:见 [scripts/test_rules.py](scripts/test_rules.py)
+- 说明:只做规则占位检查、风险提示和搜索URL预览,不模拟 Legado 的完整解析行为
+
+### 4. 模板生成器
+- 主脚本:见 [scripts/generate_template.mjs](scripts/generate_template.mjs)
+- 兼容入口:见 [scripts/generate_template.py](scripts/generate_template.py)
+- 说明:基于分析结果或交互输入生成书源模板
+
+## 常见问题
+
+### 1. 编码问题
+- GBK网站:添加 `"charset": "gbk"`
+- UTF-8网站:默认不需要指定
+
+### 2. 登录需求
+- 需要登录的网站:优先在登录态下分析,再决定是否配置 `loginUrl`
+- 使用Cookie:`java.getCookie()`
+- 如果未登录和已登录页面结构不同,应分别检查搜索、详情、目录、正文是否受影响
+- 如果登录需要人类协助完成,应立即提醒,不要假设可以跳过登录继续分析
+
+### 3. 可生成性评估
+- 不是所有网站都适合直接生成书源,应先评估再写规则。
+- 若搜索、详情、目录、正文任一链路证据不足,必须先把风险写明。
+- `需登录后再评估` 和 `不建议生成` 不是硬阻断,但继续推进时必须标记为高风险。
+- 不允许跳过评估直接给出“可用书源”结论。
+
+### 4. 动态内容
+- JavaScript渲染:使用 `"webView": true`
+- AJAX加载:分析API接口
+
+### 5. 反爬虫
+- 添加请求头:`User-Agent`, `Referer`
+- 使用代理:`"proxy": "http://127.0.0.1:8080"`
+- 添加延迟:JavaScript等待
+
+## 高级功能
+
+### 1. 变量使用
+```javascript
+// 在规则中使用变量
+{{key}} // 搜索关键词
+{{page}} // 页码
+{{java.base64Encode(key)}} // 编码关键词
+```
+
+### 2. JavaScript处理
+```javascript
+// 复杂数据处理
+
+const data = JSON.parse(result);
+return data.books.map(b => b.name).join(',');
+
+```
+
+### 3. 正则净化
+```
+// 移除广告
+all##
.*?
##
+// 替换空格
+text##\\s+##
+```
+
+### 4. 多规则组合
+```
+// 使用||取第一个有效值
+name1||name2||name3
+// 使用&&合并所有值
+author1&&author2
+```
+
+## 参考资源
+
+### 1. 官方文档
+- [Legado书源规则说明](https://mgz0227.github.io/The-tutorial-of-Legado/Rule/source.html)
+- [Jsoup文档](https://jsoup.org/)
+- [XPath教程](https://www.w3schools.com/xml/xpath_intro.asp)
+- [JSONPath语法](https://goessner.net/articles/JsonPath/)
+
+### 2. 在线工具
+- [Jsoup在线测试](https://try.jsoup.org/)
+- [XPath测试工具](https://extendsclass.com/xpath-tester.html)
+- [JSONPath评估器](https://jsonpath.com/)
+
+### 3. 示例书源
+见 [references/examples.md](references/examples.md)
+
+## 调试技巧
+
+### 1. 逐步测试
+1. 先测试搜索功能
+2. 再测试详情获取
+3. 然后测试目录解析
+4. 最后测试正文获取
+
+### 2. 使用日志
+```javascript
+// 添加调试日志
+java.log("当前URL: " + baseUrl);
+java.log("获取到书籍数量: " + books.length);
+```
+
+### 3. 脚本失效时的处理
+1. 先确认脚本失败是脚本问题、环境问题还是站点行为变化。
+2. 回到浏览器页面、网络请求和响应内容,直接分析真实数据来源。
+3. 如果模型已经能从页面结构或接口中得出稳定规则,不要因为脚本不可用而停止推进。
+4. 将脚本结论视为辅助证据,而不是唯一裁判。
+
+### 4. 错误处理
+```javascript
+// 使用try-catch处理错误
+
+try {
+ return result.match(/书名:(.*?))[1];
+} catch(e) {
+ return "未知";
+}
+
+```
+
+### 5. 性能优化
+- 避免复杂的正则表达式
+- 使用简单的选择器
+- 减少JavaScript使用
+- 合理使用缓存
+
+## 最佳实践
+
+### 1. 规则简洁性
+- 优先使用简单的选择器
+- 避免过度嵌套
+- 使用明确的定位
+
+### 2. 错误容忍
+- 提供备选规则
+- 处理空值情况
+- 添加默认值
+
+### 3. 维护性
+- 添加注释说明
+- 模块化规则
+- 定期更新
+
+### 4. 兼容性
+- 测试不同页面结构
+- 处理网站改版
+- 支持移动端和PC端
+
+### 5. 功能选择
+- **搜索功能**: 大多数书源必备,用于查找书籍
+- **发现功能**: 一般不使用,除非网站有专门的推荐或排行榜页面且用户明确要求
+- 默认情况下应禁用发现功能(设置 `"enabledExplore": false`)
+- 只在用户明确需要时才配置 `exploreUrl` 和 `ruleExplore`
+
+### 6. 工具与判断的优先级
+- 先看真实页面、真实响应、真实登录态,再看脚本输出
+- 脚本用于加速验证,不应用来否定已经通过页面和接口证据支持的判断
+- 工具不可用时继续推进人工分析,不要把脚本是否可运行当作前置条件
+- 发现登录相关限制时,先解决登录问题,再继续做规则分析
+
+通过本技能,您可以快速创建高质量的书源,为Legado阅读器提供丰富的小说资源。
diff --git a/references/examples.md b/references/examples.md
new file mode 100644
index 0000000..9c8bd67
--- /dev/null
+++ b/references/examples.md
@@ -0,0 +1,403 @@
+# Legado书源示例
+
+本文档包含各种类型的Legado书源示例,供参考和学习。
+
+## 目录
+
+1. [简单HTML网站书源](#简单html网站书源)
+2. [JSON API书源](#json-api书源)
+3. [需要登录的书源](#需要登录的书源)
+4. [有声书源](#有声书源)
+5. [漫画书源](#漫画书源)
+6. [WebView书源](#webview书源)
+7. [多规则组合书源](#多规则组合书源)
+
+## 重要说明
+
+### 关于发现功能
+- **发现功能一般不使用**:大多数书源只需要搜索功能,不需要发现功能
+- **默认设置**:建议将 `"enabledExplore": false` 作为默认设置
+- **使用场景**:只有当网站有专门的推荐页、排行榜页或分类浏览页,且用户明确需要时才启用发现功能
+- **配置原则**:优先保证搜索功能正常工作,发现功能作为可选补充
+
+## 简单HTML网站书源
+
+### 示例1:基础小说网站
+
+```json
+{
+ "bookSourceName": "示例小说网",
+ "bookSourceUrl": "https://novel.example.com",
+ "bookSourceType": 0,
+ "bookSourceGroup": "小说",
+ "searchUrl": "/search?keyword={{key}}&page={{page}}",
+ "ruleSearch": {
+ "bookList": "@css:.book-item",
+ "name": "@css:.book-title@text",
+ "author": "@css:.book-author@text",
+ "coverUrl": "@css:.book-cover img@src",
+ "bookUrl": "@css:.book-link@href",
+ "intro": "@css:.book-intro@text",
+ "kind": "@css:.book-category@text",
+ "lastChapter": "@css:.book-last-chapter@text",
+ "wordCount": "@css:.book-word-count@text"
+ },
+ "ruleBookInfo": {
+ "name": "@css:h1.book-title@text",
+ "author": "@css:.book-meta .author@text",
+ "coverUrl": "@css:.book-cover img@src",
+ "intro": "@css:.book-description@html",
+ "kind": "@css:.book-meta .category@text",
+ "lastChapter": "@css:.book-meta .last-chapter@text",
+ "wordCount": "@css:.book-meta .word-count@text",
+ "tocUrl": "@css:#toc-link@href"
+ },
+ "ruleToc": {
+ "chapterList": "@css:.chapter-list li",
+ "chapterName": "@css:.chapter-title@text",
+ "chapterUrl": "@css:.chapter-link@href",
+ "nextTocUrl": "@css:.next-page@href"
+ },
+ "ruleContent": {
+ "content": "@css:.chapter-content@html####",
+ "nextContentUrl": "@css:.next-chapter@href"
+ },
+ "enabled": true,
+ "enabledExplore": false, // 默认禁用发现功能
+ "weight": 0
+}
+```
+
+### 示例2:使用XPath的书源
+
+```json
+{
+ "bookSourceName": "XPath示例书源",
+ "bookSourceUrl": "https://xpath.example.com",
+ "bookSourceType": 0,
+ "searchUrl": "/search?q={{key}}",
+ "ruleSearch": {
+ "bookList": "@XPath://div[@class='book-list']/div",
+ "name": "@XPath:.//h3/a/text()",
+ "author": "@XPath:.//span[@class='author']/text()",
+ "coverUrl": "@XPath:.//img/@src",
+ "bookUrl": "@XPath:.//a/@href"
+ },
+ "ruleBookInfo": {
+ "name": "@XPath://h1[@class='book-title']/text()",
+ "author": "@XPath://div[@class='author']/text()",
+ "coverUrl": "@XPath://img[@class='book-cover']/@src",
+ "intro": "@XPath://div[@class='description']/html()",
+ "tocUrl": "@XPath://a[@id='toc']/@href"
+ },
+ "ruleToc": {
+ "chapterList": "@XPath://ul[@class='chapter-list']/li",
+ "chapterName": "@XPath:.//a/text()",
+ "chapterUrl": "@XPath:.//a/@href"
+ },
+ "ruleContent": {
+ "content": "@XPath://div[@id='content']/html()"
+ }
+}
+```
+
+## JSON API书源
+
+### 示例3:REST API书源
+
+```json
+{
+ "bookSourceName": "JSON API书源",
+ "bookSourceUrl": "https://api.novel.com",
+ "bookSourceType": 0,
+ "searchUrl": "/api/search?keyword={{java.encodeURI(key)}}&page={{page}}",
+ "ruleSearch": {
+ "bookList": "@json:$.data.books[*]",
+ "name": "@json:$.name",
+ "author": "@json:$.author",
+ "coverUrl": "@json:$.cover",
+ "bookUrl": "@json:$.url",
+ "intro": "@json:$.description",
+ "kind": "@json:$.category",
+ "lastChapter": "@json:$.last_chapter",
+ "wordCount": "@json:$.word_count"
+ },
+ "ruleBookInfo": {
+ "name": "@json:$.book.name",
+ "author": "@json:$.book.author",
+ "coverUrl": "@json:$.book.cover",
+ "intro": "@json:$.book.description",
+ "kind": "@json:$.book.category",
+ "lastChapter": "@json:$.book.last_chapter",
+ "wordCount": "@json:$.book.word_count",
+ "tocUrl": "@json:$.book.toc_url"
+ },
+ "ruleToc": {
+ "chapterList": "@json:$.chapters[*]",
+ "chapterName": "@json:$.title",
+ "chapterUrl": "@json:$.url",
+ "nextTocUrl": "@json:$.next_page"
+ },
+ "ruleContent": {
+ "content": "@json:$.content"
+ },
+ "header": "{\"Content-Type\": \"application/json\"}"
+}
+```
+
+## 需要登录的书源
+
+### 示例4:需要Cookie的书源
+
+```json
+{
+ "bookSourceName": "需要登录的书源",
+ "bookSourceUrl": "https://member.novel.com",
+ "bookSourceType": 0,
+ "loginUrl": "https://member.novel.com/login",
+ "searchUrl": "/api/search?q={{key}}",
+ "ruleSearch": {
+ "bookList": "@json:$.data",
+ "name": "@json:$.title",
+ "author": "@json:$.author",
+ "bookUrl": "@json:$.id@js:'/book/'+result"
+ },
+ "ruleBookInfo": {
+ "name": "@json:$.book.title",
+ "author": "@json:$.book.author",
+ "tocUrl": "@json:$.book.toc_url"
+ },
+ "ruleToc": {
+ "chapterList": "@json:$.chapters",
+ "chapterName": "@json:$.title",
+ "chapterUrl": "@json:$.url@js:result+',{\"headers\":{\"Cookie\":\"'+java.getCookie(baseUrl)+'\"}}'"
+ },
+ "ruleContent": {
+ "content": "@json:$.content"
+ },
+ "header": "{\"Cookie\": \"{{java.getCookie(baseUrl)}}\"}"
+}
+```
+
+## 有声书源
+
+### 示例5:音频书源
+
+```json
+{
+ "bookSourceName": "有声小说",
+ "bookSourceUrl": "https://audio.novel.com",
+ "bookSourceType": 1,
+ "searchUrl": "/search?keyword={{key}}",
+ "ruleSearch": {
+ "bookList": "@css:.audio-item",
+ "name": "@css:.audio-title@text",
+ "author": "@css:.audio-author@text",
+ "coverUrl": "@css:.audio-cover img@src",
+ "bookUrl": "@css:.audio-link@href"
+ },
+ "ruleBookInfo": {
+ "name": "@css:h1.audio-title@text",
+ "author": "@css:.audio-author@text",
+ "coverUrl": "@css:.audio-cover img@src",
+ "intro": "@css:.audio-description@html",
+ "tocUrl": "@css:.toc-link@href"
+ },
+ "ruleToc": {
+ "chapterList": "@css:.chapter-list li",
+ "chapterName": "@css:.chapter-title@text",
+ "chapterUrl": "@css:.chapter-play@href##$##,{\"webView\":true}",
+ "isVip": "@css:.vip-icon@js:result?true:false"
+ },
+ "ruleContent": {
+ "content": "result",
+ "sourceRegex": ".*\\.(mp3|m4a).*"
+ }
+}
+```
+
+## 漫画书源
+
+### 示例6:漫画网站
+
+```json
+{
+ "bookSourceName": "漫画源",
+ "bookSourceUrl": "https://comic.example.com",
+ "bookSourceType": 0,
+ "searchUrl": "/search?q={{key}}",
+ "ruleSearch": {
+ "bookList": "@css:.comic-item",
+ "name": "@css:.comic-title@text",
+ "author": "@css:.comic-author@text",
+ "coverUrl": "@css:.comic-cover img@src",
+ "bookUrl": "@css:.comic-link@href",
+ "intro": "@css:.comic-desc@text",
+ "kind": "@css:.comic-tags@text"
+ },
+ "ruleBookInfo": {
+ "name": "@css:h1.comic-title@text",
+ "author": "@css:.comic-author@text",
+ "coverUrl": "@css:.comic-cover img@src",
+ "intro": "@css:.comic-description@html",
+ "kind": "@css:.comic-tags@text",
+ "tocUrl": "@css:.chapter-list-link@href"
+ },
+ "ruleToc": {
+ "chapterList": "@css:.chapter-list li",
+ "chapterName": "@css:.chapter-title@text",
+ "chapterUrl": "@css:.chapter-link@href##$##,{\"webView\":true}"
+ },
+ "ruleContent": {
+ "content": "@css:.comic-images img@src@js:'
'",
+ "webJs": "loadAllImages();"
+ }
+}
+```
+
+## WebView书源
+
+### 示例7:需要JavaScript渲染
+
+```json
+{
+ "bookSourceName": "WebView示例",
+ "bookSourceUrl": "https://spa.novel.com",
+ "bookSourceType": 0,
+ "searchUrl": "/search/{{key}}",
+ "ruleSearch": {
+ "bookList": "@css:.book-item",
+ "name": "@css:.book-name@text",
+ "author": "@css:.book-author@text",
+ "bookUrl": "@css:.book-link@href##$##,{\"webView\":true}"
+ },
+ "ruleBookInfo": {
+ "name": "@css:h1.book-title@text",
+ "author": "@css:.book-author@text",
+ "tocUrl": "@css:.toc-link@href##$##,{\"webView\":true}"
+ },
+ "ruleToc": {
+ "chapterList": "@css:.chapter-item",
+ "chapterName": "@css:.chapter-title@text",
+ "chapterUrl": "@css:.chapter-link@href##$##,{\"webView\":true}"
+ },
+ "ruleContent": {
+ "content": "@css:.chapter-content@html",
+ "webJs": "document.querySelector('.ads').remove(); return document.querySelector('.content').innerHTML;"
+ }
+}
+```
+
+## 多规则组合书源
+
+### 示例8:复杂规则组合
+
+```json
+{
+ "bookSourceName": "复杂规则示例",
+ "bookSourceUrl": "https://complex.example.com",
+ "bookSourceType": 0,
+ "searchUrl": "/search?q={{key}}&page={{page}}",
+ "ruleSearch": {
+ "bookList": "@css:.result-item",
+ "name": "@css:.title@text||@css:h3@text",
+ "author": "@css:.author@text&&@css:.writer@text",
+ "coverUrl": "@css:.cover img@src##https?://##https://proxy.example.com/##",
+ "bookUrl": "@css:.link@href@js:baseUrl + result",
+ "intro": "@css:.desc@html##<[^>]+>## ##\\s+## ",
+ "kind": "var kind = result; return kind ? kind.split('/')[0] : '';",
+ "lastChapter": "@css:.latest@text##.*?:(.*)##$1###"
+ },
+ "ruleBookInfo": {
+ "name": "@css:h1@text##《(.*)》##$1###",
+ "author": "@css:.info .author@text||@css:.meta .author@text",
+ "coverUrl": "@css:.cover img@src",
+ "intro": "@css:.intro@html##.*?
##",
+ "tocUrl": "@css:.toc@href||@css:.chapter-list@href",
+ "canReName": "@put:{name:\"@css:.alt-name@text\"}"
+ },
+ "ruleToc": {
+ "chapterList": "-@css:.chapter-list li",
+ "chapterName": "@css:.chapter-title@text##第(\\d+)章##第$1章 ##",
+ "chapterUrl": "@css:.chapter-link@href@js:result.indexOf('http') === 0 ? result : baseUrl + result",
+ "isVip": "@css:.vip@js:result ? 'VIP' : ''",
+ "updateTime": "@css:.time@text@js:java.timeFormat(result)",
+ "nextTocUrl": "@css:.next-page@href@js:result ? [result] : []"
+ },
+ "ruleContent": {
+ "content": "@css:.content@html#### ##.*?
## ##\\n{3,}##\\n\\n",
+ "nextContentUrl": "@css:.next@href@js:result ? [result] : []",
+ "webJs": "removeAds(); formatText();"
+ },
+ "header": "{\"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\", \"Referer\": \"{{baseUrl}}\"}",
+ "bookUrlPattern": "https?://complex\\.example\\.com/book/\\d+"
+}
+```
+
+## 规则说明
+
+### 常用规则语法
+
+1. **Jsoup Default**: `tag.div.0@class.content@text`
+2. **CSS选择器**: `@css:.book-list li a@text`
+3. **XPath**: `@XPath://div[@class="content"]/text()`
+4. **JSONPath**: `@json:$.data.books[*].name`
+5. **JavaScript**: `result.replace(/\\s+/g, ' ')`
+6. **正则AllInOne**: `:href=\"(/chapter/[^\"]*)\"[^>]*>([^<]*)`
+7. **正则净化**: `####`
+8. **正则OnlyOne**: `##.*?:(.*)##$1###`
+
+### 连接符号
+
+1. **||**: 取第一个有效值
+ ```json
+ "name": "@css:.title@text||@css:h3@text"
+ ```
+
+2. **&&**: 合并所有值
+ ```json
+ "author": "@css:.author@text&&@css:.writer@text"
+ ```
+
+3. **%%**: 交替取值
+ ```json
+ "chapters": "list1%%list2%%list3"
+ ```
+
+### 特殊用法
+
+1. **列表倒序**: 在规则前加 `-`
+ ```json
+ "chapterList": "-@css:.chapter-list li"
+ ```
+
+2. **变量使用**: 使用 `{{}}`
+ ```json
+ "searchUrl": "/search?q={{key}}&page={{page}}"
+ ```
+
+3. **JavaScript处理**:
+ ```json
+ "bookUrl": "@css:.link@href@js:baseUrl + result"
+ ```
+
+4. **正则替换**:
+ ```json
+ "coverUrl": "@css:.cover img@src##http://##https://##"
+ ```
+
+## 调试技巧
+
+1. **逐步测试**: 先测试搜索,再测试详情,最后测试正文
+2. **使用日志**: 在JavaScript中添加 `java.log()` 调试
+3. **错误处理**: 使用try-catch处理异常
+4. **备选规则**: 使用 `||` 提供备选方案
+
+## 常见问题
+
+1. **编码问题**: 添加 `"charset": "gbk"` 到URL选项
+2. **登录问题**: 配置 `loginUrl` 和 `header`
+3. **动态内容**: 使用 `"webView": true`
+4. **反爬虫**: 添加合适的请求头
+
+这些示例涵盖了大多数常见场景,可以根据实际网站结构进行调整。
\ No newline at end of file
diff --git a/scripts/analyze_with_playwright.mjs b/scripts/analyze_with_playwright.mjs
new file mode 100644
index 0000000..c8b44ce
--- /dev/null
+++ b/scripts/analyze_with_playwright.mjs
@@ -0,0 +1,180 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import readline from 'node:readline/promises';
+import { stdin as input, stdout as output } from 'node:process';
+
+function parseArgs(argv) {
+ const args = {
+ headless: false,
+ manualLogin: false,
+ save: '',
+ };
+ const positional = [];
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (token === '--headless') {
+ args.headless = true;
+ continue;
+ }
+ if (token === '--manual-login') {
+ args.manualLogin = true;
+ continue;
+ }
+ if (token === '--save') {
+ args.save = argv[index + 1] ?? '';
+ index += 1;
+ continue;
+ }
+ positional.push(token);
+ }
+
+ args.url = positional[0];
+ return args;
+}
+
+async function prompt(message) {
+ const rl = readline.createInterface({ input, output });
+ const answer = await rl.question(message);
+ rl.close();
+ return answer.trim();
+}
+
+async function importPlaywright() {
+ try {
+ return await import('playwright');
+ } catch (error) {
+ console.error('未检测到 playwright 依赖。请先安装: npm i playwright');
+ throw error;
+ }
+}
+
+async function collectSelectors(page, candidates) {
+ const result = {};
+ for (const [name, selector] of Object.entries(candidates)) {
+ const locator = page.locator(selector);
+ const count = await locator.count();
+ result[name] = {
+ selector,
+ count,
+ };
+ if (count > 0) {
+ result[name].sampleText = (await locator.first().innerText().catch(() => '')).slice(0, 120);
+ }
+ }
+ return result;
+}
+
+async function analyze(url, options) {
+ const { chromium } = await importPlaywright();
+ const browser = await chromium.launch({ headless: options.headless });
+ const context = await browser.newContext({
+ viewport: { width: 1440, height: 900 },
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122 Safari/537.36',
+ });
+ const page = await context.newPage();
+ const requests = [];
+
+ page.on('response', (response) => {
+ const resourceType = response.request().resourceType();
+ if (['xhr', 'fetch', 'document'].includes(resourceType)) {
+ requests.push({
+ status: response.status(),
+ method: response.request().method(),
+ resourceType,
+ url: response.url(),
+ });
+ }
+ });
+
+ const notes = [];
+
+ try {
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
+
+ const loginIndicators = await collectSelectors(page, {
+ loginButton: 'a[href*="login"], button:has-text("登录"), button:has-text("Sign in"), .login, .signin',
+ passwordInput: 'input[type="password"]',
+ searchInput: 'input[type="search"], input[name*="search"], input[name*="keyword"], input[placeholder*="搜索"]',
+ bookLinks: 'a[href*="book"], a[href*="novel"], .book-link, .novel-link',
+ chapterLinks: 'a[href*="chapter"], a[href*="read"], .chapter a',
+ contentContainer: '#content, .content, .read-content, .chapter-content',
+ });
+
+ const title = await page.title();
+ const currentUrl = page.url();
+
+ if (loginIndicators.passwordInput.count > 0 || loginIndicators.loginButton.count > 0) {
+ notes.push('检测到登录相关元素,建议优先确认是否需要登录后再分析。');
+ }
+
+ if (options.manualLogin) {
+ notes.push('已启用人工登录流程。');
+ console.log('浏览器已打开。若网站需要登录,请由人类在浏览器中完成登录。');
+ await prompt('登录完成后按 Enter 继续分析。');
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
+ }
+
+ const finalSignals = await collectSelectors(page, {
+ searchInput: 'input[type="search"], input[name*="search"], input[name*="keyword"], input[placeholder*="搜索"]',
+ resultItems: '.book-item, .novel-item, .book-list li, .search-result li',
+ tocItems: '.chapter-list li, .directory li, .volume-list li',
+ contentContainer: '#content, .content, .read-content, .chapter-content',
+ });
+
+ const report = {
+ url,
+ initialTitle: title,
+ finalUrl: currentUrl,
+ notes,
+ loginIndicators,
+ finalSignals,
+ capturedRequests: requests.slice(0, 30),
+ };
+
+ return { report, browser };
+ } catch (error) {
+ await browser.close();
+ throw error;
+ }
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+
+ if (!args.url) {
+ console.error('用法: node scripts/analyze_with_playwright.mjs <网站URL> [--manual-login] [--save analysis.json] [--headless]');
+ process.exit(1);
+ }
+
+ const normalizedUrl = args.url.startsWith('http') ? args.url : `https://${args.url}`;
+
+ try {
+ const { report, browser } = await analyze(normalizedUrl, args);
+ console.log(JSON.stringify(report, null, 2));
+
+ if (args.save) {
+ const savePath = path.resolve(args.save);
+ await fs.writeFile(savePath, JSON.stringify(report, null, 2), 'utf8');
+ console.log(`分析结果已保存到: ${savePath}`);
+ }
+
+ if (!args.headless) {
+ const answer = await prompt('按 Enter 关闭浏览器,或输入 keep 保持浏览器打开: ');
+ if (answer.toLowerCase() !== 'keep') {
+ await browser.close();
+ }
+ return;
+ }
+
+ await browser.close();
+ } catch (error) {
+ console.error(`分析失败: ${error.message}`);
+ process.exit(1);
+ }
+}
+
+await main();
diff --git a/scripts/analyze_with_playwright.py b/scripts/analyze_with_playwright.py
new file mode 100644
index 0000000..cf97af7
--- /dev/null
+++ b/scripts/analyze_with_playwright.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""Compatibility wrapper for analyze_with_playwright.mjs."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+
+def main() -> int:
+ script_path = Path(__file__).with_suffix(".mjs")
+ result = subprocess.run(["node", str(script_path), *sys.argv[1:]], check=False)
+ return result.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/generate_template.mjs b/scripts/generate_template.mjs
new file mode 100644
index 0000000..a503ca3
--- /dev/null
+++ b/scripts/generate_template.mjs
@@ -0,0 +1,138 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import readline from 'node:readline/promises';
+import { stdin as input, stdout as output } from 'node:process';
+
+function baseTemplate() {
+ return {
+ bookSourceName: '',
+ bookSourceUrl: '',
+ bookSourceType: 0,
+ bookSourceGroup: '',
+ bookUrlPattern: '',
+ customOrder: 0,
+ enabled: true,
+ enabledExplore: false,
+ header: '',
+ loginUrl: '',
+ bookSourceComment: '',
+ lastUpdateTime: 0,
+ weight: 0,
+ exploreUrl: '',
+ searchUrl: '',
+ ruleSearch: {},
+ ruleExplore: {},
+ ruleBookInfo: {},
+ ruleToc: {},
+ ruleContent: {},
+ };
+}
+
+function mergeAnalysis(analysis) {
+ const template = baseTemplate();
+ template.lastUpdateTime = Math.floor(Date.now() / 1000);
+
+ if (analysis.name) template.bookSourceName = analysis.name;
+ if (analysis.url) template.bookSourceUrl = analysis.url;
+ if (analysis.group) template.bookSourceGroup = analysis.group;
+
+ if (analysis.login?.url) {
+ template.loginUrl = analysis.login.url;
+ }
+
+ if (analysis.search?.url) {
+ template.searchUrl = analysis.search.url;
+ }
+ if (analysis.search?.rules) {
+ template.ruleSearch = { ...analysis.search.rules };
+ }
+
+ if (analysis.explore?.url) {
+ template.exploreUrl = analysis.explore.url;
+ template.enabledExplore = true;
+ }
+ if (analysis.explore?.rules) {
+ template.ruleExplore = { ...analysis.explore.rules };
+ }
+
+ if (analysis.book_info?.rules) {
+ template.ruleBookInfo = { ...analysis.book_info.rules };
+ }
+ if (analysis.toc?.rules) {
+ template.ruleToc = { ...analysis.toc.rules };
+ }
+ if (analysis.content?.rules) {
+ template.ruleContent = { ...analysis.content.rules };
+ }
+
+ return template;
+}
+
+async function interactiveTemplate() {
+ const rl = readline.createInterface({ input, output });
+ const template = baseTemplate();
+
+ template.bookSourceName = (await rl.question('书源名称: ')).trim() || '示例书源';
+ template.bookSourceUrl = (await rl.question('网站URL: ')).trim() || 'https://example.com';
+ template.bookSourceGroup = (await rl.question('分组(可选): ')).trim();
+ template.bookSourceComment = (await rl.question('注释(可选): ')).trim();
+
+ const loginNeeded = (await rl.question('该网站是否需要登录后再分析?(y/n): ')).trim().toLowerCase() === 'y';
+ if (loginNeeded) {
+ template.loginUrl = (await rl.question('登录URL(可选): ')).trim();
+ }
+
+ const hasSearch = (await rl.question('是否支持搜索?(y/n): ')).trim().toLowerCase() === 'y';
+ if (hasSearch) {
+ template.searchUrl = (await rl.question('搜索URL(使用{{key}}表示关键词): ')).trim() || '/search?q={{key}}';
+ }
+
+ const hasExplore = (await rl.question('是否需要发现功能?(y/n,默认n): ')).trim().toLowerCase() === 'y';
+ template.enabledExplore = hasExplore;
+ if (hasExplore) {
+ template.exploreUrl = (await rl.question('发现URL(使用{{page}}表示页码): ')).trim() || '/list?page={{page}}';
+ }
+
+ template.enabled = (await rl.question('默认启用书源?(y/n,默认y): ')).trim().toLowerCase() !== 'n';
+ template.lastUpdateTime = Math.floor(Date.now() / 1000);
+
+ rl.close();
+ return template;
+}
+
+async function main() {
+ const [, , firstArg, secondArg] = process.argv;
+
+ if (firstArg === '--example') {
+ console.log(JSON.stringify(mergeAnalysis({
+ name: '示例小说网站',
+ url: 'https://novel.example.com',
+ search: {
+ url: '/search?q={{key}}&page={{page}}',
+ rules: {
+ bookList: '@css:.book-list li',
+ name: '@css:.book-title@text',
+ author: '@css:.book-author@text',
+ coverUrl: '@css:.book-cover img@src',
+ bookUrl: '@css:.book-link@href',
+ },
+ },
+ }), null, 2));
+ return;
+ }
+
+ let template;
+ if (firstArg === '--analysis' && secondArg) {
+ const analysisPath = path.resolve(secondArg);
+ const analysis = JSON.parse(await fs.readFile(analysisPath, 'utf8'));
+ template = mergeAnalysis(analysis);
+ } else {
+ template = await interactiveTemplate();
+ }
+
+ console.log(JSON.stringify(template, null, 2));
+}
+
+await main();
diff --git a/scripts/generate_template.py b/scripts/generate_template.py
new file mode 100644
index 0000000..b7171ad
--- /dev/null
+++ b/scripts/generate_template.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""Compatibility wrapper for generate_template.mjs."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+
+def main() -> int:
+ script_path = Path(__file__).with_suffix(".mjs")
+ result = subprocess.run(["node", str(script_path), *sys.argv[1:]], check=False)
+ return result.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/lib/book_source_tools.mjs b/scripts/lib/book_source_tools.mjs
new file mode 100644
index 0000000..50ef822
--- /dev/null
+++ b/scripts/lib/book_source_tools.mjs
@@ -0,0 +1,284 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+export const REQUIRED_FIELDS = [
+ 'bookSourceName',
+ 'bookSourceUrl',
+ 'bookSourceType',
+];
+
+export const OPTIONAL_TYPE_CHECKS = {
+ bookSourceGroup: ['string', 'null'],
+ bookUrlPattern: ['string', 'null'],
+ loginUrl: ['string', 'null'],
+ searchUrl: ['string', 'null'],
+ exploreUrl: ['string', 'null'],
+ weight: ['number', 'null'],
+ customOrder: ['number', 'null'],
+ enabled: ['boolean', 'null'],
+ enabledExplore: ['boolean', 'null'],
+ lastUpdateTime: ['number', 'null'],
+};
+
+export const RULE_FIELDS = {
+ ruleSearch: ['bookList', 'name', 'author', 'coverUrl', 'bookUrl'],
+ ruleExplore: ['bookList', 'name', 'author', 'coverUrl', 'bookUrl'],
+ ruleBookInfo: ['name', 'author', 'coverUrl', 'intro', 'tocUrl'],
+ ruleToc: ['chapterList', 'chapterName', 'chapterUrl'],
+ ruleContent: ['content'],
+};
+
+const PLACEHOLDER_TEXT = new Set([
+ '书籍列表规则',
+ '书名规则',
+ '作者规则',
+ '封面规则',
+ '详情页URL规则',
+ '简介规则',
+ '分类规则',
+ '最新章节规则',
+ '字数规则',
+ '目录URL规则',
+ '允许修改书名作者规则',
+ '章节列表规则',
+ '章节名称规则',
+ '章节URL规则',
+ 'VIP标识规则',
+ '更新时间规则',
+ '目录下一页规则',
+ '正文内容规则',
+ '正文下一页规则',
+ 'WebView JavaScript',
+ '资源正则规则',
+]);
+
+export async function loadSourceFile(filePath) {
+ const absolutePath = path.resolve(filePath);
+ const raw = await fs.readFile(absolutePath, 'utf8');
+ const parsed = JSON.parse(raw);
+ return {
+ absolutePath,
+ raw,
+ parsed,
+ sources: Array.isArray(parsed) ? parsed : [parsed],
+ };
+}
+
+export function buildSearchPreview(searchUrl, keyword = '测试', page = '1') {
+ if (!searchUrl || typeof searchUrl !== 'string') {
+ return '';
+ }
+
+ return searchUrl
+ .replaceAll('{{key}}', keyword)
+ .replaceAll('{{page}}', page);
+}
+
+export function validateSourceObject(source) {
+ const errors = [];
+ const warnings = [];
+
+ for (const field of REQUIRED_FIELDS) {
+ if (!(field in source)) {
+ errors.push(`缺少必填字段: ${field}`);
+ continue;
+ }
+
+ if (source[field] === '' || source[field] === null || source[field] === undefined) {
+ warnings.push(`字段为空: ${field}`);
+ }
+ }
+
+ if ('bookSourceType' in source && source.bookSourceType !== null && typeof source.bookSourceType !== 'number') {
+ errors.push('字段类型错误: bookSourceType 应为 number');
+ }
+
+ for (const [field, acceptedTypes] of Object.entries(OPTIONAL_TYPE_CHECKS)) {
+ if (!(field in source)) {
+ continue;
+ }
+
+ const value = source[field];
+ const valueType = value === null ? 'null' : typeof value;
+ if (!acceptedTypes.includes(valueType)) {
+ errors.push(`字段类型错误: ${field} 应为 ${acceptedTypes.join(' 或 ')}`);
+ }
+ }
+
+ for (const [ruleGroup, expectedFields] of Object.entries(RULE_FIELDS)) {
+ if (!(ruleGroup in source) || source[ruleGroup] === '' || source[ruleGroup] === null) {
+ continue;
+ }
+
+ if (typeof source[ruleGroup] !== 'object' || Array.isArray(source[ruleGroup])) {
+ errors.push(`${ruleGroup} 应为对象`);
+ continue;
+ }
+
+ if (Object.keys(source[ruleGroup]).length === 0) {
+ continue;
+ }
+
+ for (const field of expectedFields) {
+ if (!(field in source[ruleGroup])) {
+ warnings.push(`${ruleGroup} 缺少推荐字段: ${field}`);
+ }
+ }
+ }
+
+ for (const field of ['bookSourceUrl', 'searchUrl', 'exploreUrl', 'loginUrl']) {
+ if (!(field in source) || !source[field]) {
+ continue;
+ }
+
+ if (typeof source[field] !== 'string') {
+ continue;
+ }
+
+ if (!source[field].startsWith('http://') && !source[field].startsWith('https://') && !source[field].startsWith('/')) {
+ warnings.push(`${field} 看起来不是标准 URL 或相对路径: ${source[field]}`);
+ }
+ }
+
+ if (typeof source.searchUrl === 'string' && source.searchUrl && !source.searchUrl.includes('{{key}}')) {
+ warnings.push('searchUrl 中可能缺少 {{key}} 变量');
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ warnings,
+ stats: {
+ sourceName: source.bookSourceName ?? '未知',
+ sourceUrl: source.bookSourceUrl ?? '未知',
+ configuredRuleGroups: Object.keys(RULE_FIELDS).filter((group) => group in source).length,
+ totalRuleGroups: Object.keys(RULE_FIELDS).length,
+ },
+ };
+}
+
+export function auditSourceRules(source) {
+ const sections = {};
+
+ for (const [sectionName, sectionValue] of Object.entries(source)) {
+ if (!sectionName.startsWith('rule')) {
+ continue;
+ }
+
+ if (!sectionValue || typeof sectionValue !== 'object' || Array.isArray(sectionValue)) {
+ sections[sectionName] = {
+ totalFields: 0,
+ placeholderFields: [],
+ riskyFields: [],
+ notes: ['该规则组为空或不是对象'],
+ };
+ continue;
+ }
+
+ const placeholderFields = [];
+ const riskyFields = [];
+
+ for (const [fieldName, fieldValue] of Object.entries(sectionValue)) {
+ if (typeof fieldValue !== 'string') {
+ continue;
+ }
+
+ const normalized = fieldValue.trim();
+ if (!normalized) {
+ continue;
+ }
+
+ if (PLACEHOLDER_TEXT.has(normalized)) {
+ placeholderFields.push(fieldName);
+ }
+
+ if (
+ normalized.includes('') ||
+ normalized.startsWith('@js:') ||
+ normalized.includes('##') ||
+ normalized.startsWith(':')
+ ) {
+ riskyFields.push(fieldName);
+ }
+ }
+
+ const notes = [];
+ if (placeholderFields.length > 0) {
+ notes.push('存在占位规则,表示这些字段还没有被真实规则替换');
+ }
+ if (riskyFields.length > 0) {
+ notes.push('存在 JS 或正则类规则,建议结合真实页面和接口再次人工确认');
+ }
+
+ sections[sectionName] = {
+ totalFields: Object.keys(sectionValue).length,
+ placeholderFields,
+ riskyFields,
+ notes,
+ };
+ }
+
+ return {
+ searchPreview: buildSearchPreview(source.searchUrl),
+ loginConfigured: Boolean(source.loginUrl),
+ sections,
+ };
+}
+
+export function formatValidationReport(report, index = null) {
+ const lines = [];
+ if (index !== null) {
+ lines.push(`书源 #${index + 1}`);
+ }
+
+ if (report.errors.length > 0) {
+ lines.push('错误:');
+ for (const error of report.errors) {
+ lines.push(` - ${error}`);
+ }
+ }
+
+ if (report.warnings.length > 0) {
+ lines.push('警告:');
+ for (const warning of report.warnings) {
+ lines.push(` - ${warning}`);
+ }
+ }
+
+ if (report.errors.length === 0 && report.warnings.length === 0) {
+ lines.push('验证通过');
+ }
+
+ lines.push(`名称: ${report.stats.sourceName}`);
+ lines.push(`URL: ${report.stats.sourceUrl}`);
+ lines.push(`规则组: ${report.stats.configuredRuleGroups}/${report.stats.totalRuleGroups}`);
+ return lines.join('\n');
+}
+
+export function formatAuditReport(source, audit) {
+ const lines = [];
+ lines.push(`书源: ${source.bookSourceName ?? '未知'}`);
+ lines.push(`站点: ${source.bookSourceUrl ?? '未知'}`);
+ lines.push(`登录配置: ${audit.loginConfigured ? '已配置 loginUrl' : '未配置 loginUrl'}`);
+
+ if (audit.searchPreview) {
+ lines.push(`搜索预览: ${audit.searchPreview}`);
+ }
+
+ for (const [sectionName, section] of Object.entries(audit.sections)) {
+ lines.push('');
+ lines.push(`${sectionName}:`);
+ lines.push(` 字段数: ${section.totalFields}`);
+ lines.push(` 占位字段: ${section.placeholderFields.length > 0 ? section.placeholderFields.join(', ') : '无'}`);
+ lines.push(` 高风险字段: ${section.riskyFields.length > 0 ? section.riskyFields.join(', ') : '无'}`);
+
+ for (const note of section.notes) {
+ lines.push(` 说明: ${note}`);
+ }
+ }
+
+ lines.push('');
+ lines.push('提示: 这个脚本只做规则审计和预览,不模拟 Legado 的完整解析结果。');
+ lines.push('提示: 真实是否可用,应结合浏览器页面、网络请求和 AI 对站点结构的分析来判断。');
+ return lines.join('\n');
+}
diff --git a/scripts/test_rules.mjs b/scripts/test_rules.mjs
new file mode 100644
index 0000000..6a386f7
--- /dev/null
+++ b/scripts/test_rules.mjs
@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+
+import {
+ loadSourceFile,
+ auditSourceRules,
+ buildSearchPreview,
+ formatAuditReport,
+} from './lib/book_source_tools.mjs';
+
+function parseArgs(argv) {
+ const args = { keyword: '测试', page: '1' };
+ const positional = [];
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (token === '--keyword') {
+ args.keyword = argv[index + 1] ?? args.keyword;
+ index += 1;
+ continue;
+ }
+
+ if (token === '--page') {
+ args.page = argv[index + 1] ?? args.page;
+ index += 1;
+ continue;
+ }
+
+ positional.push(token);
+ }
+
+ args.filePath = positional[0];
+ return args;
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+
+ if (!args.filePath) {
+ console.error('用法: node scripts/test_rules.mjs <书源文件路径> [--keyword 关键词] [--page 页码]');
+ process.exit(1);
+ }
+
+ try {
+ const { sources } = await loadSourceFile(args.filePath);
+ const source = sources[0];
+ const audit = auditSourceRules(source);
+
+ if (typeof source.searchUrl === 'string' && source.searchUrl) {
+ audit.searchPreview = buildSearchPreview(source.searchUrl, args.keyword, args.page);
+ }
+
+ console.log(formatAuditReport(source, audit));
+ process.exit(0);
+ } catch (error) {
+ console.error(`审计失败: ${error.message}`);
+ process.exit(1);
+ }
+}
+
+await main();
diff --git a/scripts/test_rules.py b/scripts/test_rules.py
new file mode 100644
index 0000000..e939a2b
--- /dev/null
+++ b/scripts/test_rules.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""Compatibility wrapper for test_rules.mjs."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+
+def main() -> int:
+ script_path = Path(__file__).with_suffix(".mjs")
+ result = subprocess.run(["node", str(script_path), *sys.argv[1:]], check=False)
+ return result.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/validate_source.mjs b/scripts/validate_source.mjs
new file mode 100644
index 0000000..6a8c0ab
--- /dev/null
+++ b/scripts/validate_source.mjs
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+
+import {
+ loadSourceFile,
+ validateSourceObject,
+ formatValidationReport,
+} from './lib/book_source_tools.mjs';
+
+async function main() {
+ const [, , filePath, ...rest] = process.argv;
+ const jsonOutput = rest.includes('--json');
+
+ if (!filePath) {
+ console.error('用法: node scripts/validate_source.mjs <书源文件路径> [--json]');
+ process.exit(1);
+ }
+
+ try {
+ const { sources, absolutePath } = await loadSourceFile(filePath);
+ const reports = sources.map((source) => validateSourceObject(source));
+ const valid = reports.every((report) => report.valid);
+
+ if (jsonOutput) {
+ console.log(JSON.stringify({ file: absolutePath, valid, reports }, null, 2));
+ } else {
+ console.log(`验证文件: ${absolutePath}`);
+ reports.forEach((report, index) => {
+ console.log('');
+ console.log(formatValidationReport(report, sources.length > 1 ? index : null));
+ });
+ }
+
+ process.exit(valid ? 0 : 1);
+ } catch (error) {
+ console.error(`加载失败: ${error.message}`);
+ process.exit(1);
+ }
+}
+
+await main();
diff --git a/scripts/validate_source.py b/scripts/validate_source.py
new file mode 100644
index 0000000..e9c018e
--- /dev/null
+++ b/scripts/validate_source.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""Compatibility wrapper for validate_source.mjs."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+
+def main() -> int:
+ script_path = Path(__file__).with_suffix(".mjs")
+ result = subprocess.run(["node", str(script_path), *sys.argv[1:]], check=False)
+ return result.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/book_source_tools.test.mjs b/tests/book_source_tools.test.mjs
new file mode 100644
index 0000000..3d55e26
--- /dev/null
+++ b/tests/book_source_tools.test.mjs
@@ -0,0 +1,68 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ validateSourceObject,
+ auditSourceRules,
+ buildSearchPreview,
+} from '../scripts/lib/book_source_tools.mjs';
+
+test('validateSourceObject reports missing required fields as errors', () => {
+ const report = validateSourceObject({
+ bookSourceName: 'Demo',
+ bookSourceType: 0,
+ });
+
+ assert.equal(report.valid, false);
+ assert.deepEqual(report.errors, ['缺少必填字段: bookSourceUrl']);
+});
+
+test('validateSourceObject accepts a minimal valid source', () => {
+ const report = validateSourceObject({
+ bookSourceName: 'Demo',
+ bookSourceUrl: 'https://example.com',
+ bookSourceType: 0,
+ });
+
+ assert.equal(report.valid, true);
+ assert.deepEqual(report.errors, []);
+});
+
+test('validateSourceObject does not warn on empty optional rule groups', () => {
+ const report = validateSourceObject({
+ bookSourceName: 'Demo',
+ bookSourceUrl: 'https://example.com',
+ bookSourceType: 0,
+ ruleExplore: {},
+ ruleBookInfo: {},
+ ruleToc: {},
+ ruleContent: {},
+ });
+
+ assert.equal(report.valid, true);
+ assert.deepEqual(report.warnings, []);
+});
+
+test('auditSourceRules flags placeholder rules instead of pretending to execute them', () => {
+ const audit = auditSourceRules({
+ searchUrl: 'https://example.com/search?q={{key}}',
+ ruleSearch: {
+ bookList: '书籍列表规则',
+ name: '@css:.title@text',
+ },
+ });
+
+ assert.equal(audit.sections.ruleSearch.totalFields, 2);
+ assert.deepEqual(audit.sections.ruleSearch.placeholderFields, ['bookList']);
+ assert.equal(audit.sections.ruleSearch.riskyFields.length, 0);
+});
+
+test('buildSearchPreview fills Legado variables for quick inspection', () => {
+ const preview = buildSearchPreview(
+ 'https://example.com/search?q={{key}}&page={{page}}',
+ '凡人修仙传',
+ '3',
+ );
+
+ assert.equal(preview, 'https://example.com/search?q=凡人修仙传&page=3');
+});