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