Initial publish of book-source-creator skill

This commit is contained in:
Narylr committed 2026-03-19 22:47:30 +08:00
commit 5435ebac6e
13 files changed
+1795

No files matched your search

+129
View File
@@ -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
```
+421
View File
@@ -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
```
<js>result.replace(/\\s+/g, ' ')</js>
```
#### 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
// 复杂数据处理
<js>
const data = JSON.parse(result);
return data.books.map(b => b.name).join(',');
</js>
```
### 3. 正则净化
```
// 移除广告
all##<div class="ad">.*?</div>##
// 替换空格
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处理错误
<js>
try {
return result.match(/书名:(.*?)</)[1];
} catch(e) {
return "未知";
}
</js>
```
### 5. 性能优化
- 避免复杂的正则表达式
- 使用简单的选择器
- 减少JavaScript使用
- 合理使用缓存
## 最佳实践
### 1. 规则简洁性
- 优先使用简单的选择器
- 避免过度嵌套
- 使用明确的定位
### 2. 错误容忍
- 提供备选规则
- 处理空值情况
- 添加默认值
### 3. 维护性
- 添加注释说明
- 模块化规则
- 定期更新
### 4. 兼容性
- 测试不同页面结构
- 处理网站改版
- 支持移动端和PC端
### 5. 功能选择
- **搜索功能**: 大多数书源必备,用于查找书籍
- **发现功能**: 一般不使用,除非网站有专门的推荐或排行榜页面且用户明确要求
- 默认情况下应禁用发现功能(设置 `"enabledExplore": false`)
- 只在用户明确需要时才配置 `exploreUrl` 和 `ruleExplore`
### 6. 工具与判断的优先级
- 先看真实页面、真实响应、真实登录态,再看脚本输出
- 脚本用于加速验证,不应用来否定已经通过页面和接口证据支持的判断
- 工具不可用时继续推进人工分析,不要把脚本是否可运行当作前置条件
- 发现登录相关限制时,先解决登录问题,再继续做规则分析
通过本技能,您可以快速创建高质量的书源,为Legado阅读器提供丰富的小说资源。
+403
View File
@@ -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##<script[^>]*>.*?</script>##",
"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": "<js>result</js>",
"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:'<img src=\"'+result+'\">'",
"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": "<js>var kind = result; return kind ? kind.split('/')[0] : '';</js>",
"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##<div class=\"ad\">.*?</div>##",
"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##<script[^>]*>.*?</script>## ##<div class=\"advertisement\">.*?</div>## ##\\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**: `<js>result.replace(/\\s+/g, ' ')</js>`
6. **正则AllInOne**: `:href=\"(/chapter/[^\"]*)\"[^>]*>([^<]*)</a>`
7. **正则净化**: `##<script[^>]*>.*?</script>##`
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. **反爬虫**: 添加合适的请求头
这些示例涵盖了大多数常见场景,可以根据实际网站结构进行调整。
+180
View File
@@ -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();
+18
View File
@@ -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())
+138
View File
@@ -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();
+18
View File
@@ -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())
+284
View File
@@ -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('<js>') ||
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');
}
+60
View File
@@ -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();
+18
View File
@@ -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())
+40
View File
@@ -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();
+18
View File
@@ -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())
+68
View File
@@ -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');
});