Initial publish of book-source-creator skill
This commit is contained in:
commit
5435ebac6e
13 files changed
+1795
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -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阅读器提供丰富的小说资源。
|
||||
@@ -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. **反爬虫**: 添加合适的请求头
|
||||
|
||||
这些示例涵盖了大多数常见场景,可以根据实际网站结构进行调整。
|
||||
@@ -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();
|
||||
@@ -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())
|
||||
@@ -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();
|
||||
@@ -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())
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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())
|
||||
@@ -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();
|
||||
@@ -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())
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user