diff --git a/parser/.flattened-pom.xml b/parser/.flattened-pom.xml new file mode 100644 index 0000000..79176ba --- /dev/null +++ b/parser/.flattened-pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + cn.qaiu + parser + 10.1.17 + cn.qaiu:parser + NFD parser module + https://qaiu.top + + + MIT License + https://opensource.org/license/mit + + + + + qaiu + qaiu00@gmail.com + https://qaiu.top + + + + scm:git:https://github.com/qaiu/netdisk-fast-download.git + scm:git:ssh://git@github.com:qaiu/netdisk-fast-download.git + https://github.com/qaiu/netdisk-fast-download + + + + ch.qos.logback + logback-classic + 1.5.19 + runtime + + + org.slf4j + slf4j-api + 2.0.5 + compile + + + io.vertx + vertx-web-client + 4.5.21 + compile + + + org.apache.commons + commons-lang3 + 3.18.0 + compile + + + org.openjdk.nashorn + nashorn-core + 15.4 + compile + + + org.brotli + dec + 0.1.2 + compile + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.6.0 + true + + + + diff --git a/parser/doc/CUSTOM_PARSER_GUIDE.md b/parser/doc/CUSTOM_PARSER_GUIDE.md index b27416c..e8ed36a 100644 --- a/parser/doc/CUSTOM_PARSER_GUIDE.md +++ b/parser/doc/CUSTOM_PARSER_GUIDE.md @@ -233,11 +233,22 @@ public class Example { .setShareLinkInfoPwd("1234") // 设置密码(可选) .createTool(); // 创建工具实例 - // 解析获取下载链接 + // 方式1: 使用同步方法解析(推荐) String downloadUrl = tool.parseSync(); System.out.println("下载链接: " + downloadUrl); - // 方式2: 异步解析 + // 方式2: 使用同步方法解析文件列表 + List files = tool.parseFileListSync(); + System.out.println("文件列表: " + files.size() + " 个文件"); + + // 方式3: 使用同步方法根据文件ID获取下载链接 + if (!files.isEmpty()) { + String fileId = files.get(0).getFileId(); + String fileDownloadUrl = tool.parseByIdSync(); + System.out.println("文件下载链接: " + fileDownloadUrl); + } + + // 方式4: 异步解析(仍支持) tool.parse().onSuccess(url -> { System.out.println("异步获取下载链接: " + url); }).onFailure(err -> { @@ -247,6 +258,42 @@ public class Example { } ``` +## 同步方法支持 + +解析器现在支持三种同步方法,简化了使用方式: + +### 1. parseSync() +解析单个文件的下载链接: +```java +String downloadUrl = tool.parseSync(); +``` + +### 2. parseFileListSync() +解析文件列表(目录): +```java +List files = tool.parseFileListSync(); +for (FileInfo file : files) { + System.out.println("文件: " + file.getFileName()); +} +``` + +### 3. parseByIdSync() +根据文件ID获取下载链接: +```java +String fileDownloadUrl = tool.parseByIdSync(); +``` + +### 同步方法优势 +- **简化使用**: 无需处理 Future 和回调 +- **异常处理**: 同步方法会抛出异常,便于错误处理 +- **代码简洁**: 减少异步代码的复杂性 + +### 异步方法仍可用 +原有的异步方法仍然支持: +- `parse()`: 返回 `Future` +- `parseFileList()`: 返回 `Future>` +- `parseById()`: 返回 `Future` + ## 注意事项 ### 1. 类型标识规范 @@ -363,9 +410,21 @@ public class CompleteExample { // 创建工具并解析 IPanTool tool = parser.createTool(); + + // 使用同步方法解析 String url = tool.parseSync(); System.out.println("✓ 下载链接: " + url); + // 解析文件列表 + List files = tool.parseFileListSync(); + System.out.println("✓ 文件列表: " + files.size() + " 个文件"); + + // 根据文件ID获取下载链接 + if (!files.isEmpty()) { + String fileDownloadUrl = tool.parseByIdSync(); + System.out.println("✓ 文件下载链接: " + fileDownloadUrl); + } + } catch (Exception e) { System.err.println("✗ 解析失败: " + e.getMessage()); } diff --git a/parser/doc/JAVASCRIPT_PARSER_GUIDE.md b/parser/doc/JAVASCRIPT_PARSER_GUIDE.md new file mode 100644 index 0000000..8bdc4cf --- /dev/null +++ b/parser/doc/JAVASCRIPT_PARSER_GUIDE.md @@ -0,0 +1,465 @@ +# JavaScript解析器扩展开发指南 + +## 概述 + +本指南介绍如何使用JavaScript编写自定义网盘解析器,支持通过JavaScript代码实现网盘解析逻辑,无需编写Java代码。 + +## 快速开始 + +### 1. 创建JavaScript脚本 + +在 `./custom-parsers/` 目录下创建 `.js` 文件,使用以下模板: + +```javascript +// ==UserScript== +// @name 我的解析器 +// @type my_parser +// @displayName 我的网盘 +// @description 使用JavaScript实现的网盘解析器 +// @match https?://example\.com/s/(?\w+) +// @author yourname +// @version 1.0.0 +// ==/UserScript== + +// 使用require导入类型定义(仅用于IDE类型提示) +var types = require('./types'); +/** @typedef {types.ShareLinkInfo} ShareLinkInfo */ +/** @typedef {types.JsHttpClient} JsHttpClient */ +/** @typedef {types.JsLogger} JsLogger */ +/** @typedef {types.FileInfo} FileInfo */ + +/** + * 解析单个文件下载链接 + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志对象 + * @returns {string} 下载链接 + */ +function parse(shareLinkInfo, http, logger) { + var url = shareLinkInfo.getShareUrl(); + var response = http.get(url); + return response.body(); +} + +// 导出函数 +exports.parse = parse; +``` + +### 2. 重启应用 + +重启应用后,JavaScript解析器会自动加载并注册。 + +## 元数据格式 + +### 必填字段 + +- `@name`: 脚本名称 +- `@type`: 解析器类型标识(唯一) +- `@displayName`: 显示名称 +- `@match`: URL匹配正则(必须包含 `(?...)` 命名捕获组) + +### 可选字段 + +- `@description`: 描述信息 +- `@author`: 作者 +- `@version`: 版本号 + +### 示例 + +```javascript +// ==UserScript== +// @name 蓝奏云解析器 +// @type lanzou_js +// @displayName 蓝奏云(JS) +// @description 使用JavaScript实现的蓝奏云解析器 +// @match https?://.*\.lanzou[a-z]\.com/(?\w+) +// @match https?://.*\.lanzoui\.com/(?\w+) +// @author qaiu +// @version 1.0.0 +// ==/UserScript== +``` + +## API参考 + +### ShareLinkInfo对象 + +提供分享链接信息的访问接口: + +```javascript +// 获取分享URL +var shareUrl = shareLinkInfo.getShareUrl(); + +// 获取分享Key +var shareKey = shareLinkInfo.getShareKey(); + +// 获取分享密码 +var password = shareLinkInfo.getSharePassword(); + +// 获取网盘类型 +var type = shareLinkInfo.getType(); + +// 获取网盘名称 +var panName = shareLinkInfo.getPanName(); + +// 获取其他参数 +var dirId = shareLinkInfo.getOtherParam("dirId"); +var paramJson = shareLinkInfo.getOtherParam("paramJson"); + +// 检查参数是否存在 +if (shareLinkInfo.hasOtherParam("customParam")) { + var value = shareLinkInfo.getOtherParamAsString("customParam"); +} +``` + +### JsHttpClient对象 + +提供HTTP请求功能: + +```javascript +// GET请求 +var response = http.get("https://api.example.com/data"); + +// POST请求 +var response = http.post("https://api.example.com/submit", { + key: "value", + data: "test" +}); + +// 设置请求头 +http.putHeader("User-Agent", "MyBot/1.0") + .putHeader("Authorization", "Bearer token"); + +// 发送表单数据 +var formResponse = http.sendForm({ + username: "user", + password: "pass" +}); + +// 发送JSON数据 +var jsonResponse = http.sendJson({ + name: "test", + value: 123 +}); +``` + +### JsHttpResponse对象 + +处理HTTP响应: + +```javascript +var response = http.get("https://api.example.com/data"); + +// 获取响应体(字符串) +var body = response.body(); + +// 解析JSON响应 +var data = response.json(); + +// 获取状态码 +var status = response.statusCode(); + +// 获取响应头 +var contentType = response.header("Content-Type"); +var allHeaders = response.headers(); + +// 检查请求是否成功 +if (response.isSuccess()) { + logger.info("请求成功"); +} else { + logger.error("请求失败: " + status); +} +``` + +### JsLogger对象 + +提供日志功能: + +```javascript +// 不同级别的日志 +logger.debug("调试信息"); +logger.info("一般信息"); +logger.warn("警告信息"); +logger.error("错误信息"); + +// 带参数的日志 +logger.info("用户 {} 访问了 {}", username, url); + +// 检查日志级别 +if (logger.isDebugEnabled()) { + logger.debug("详细的调试信息"); +} +``` + +## 实现方法 + +JavaScript解析器支持三种方法,对应Java接口的三种同步方法: + +### parse方法(必填) + +解析单个文件的下载链接,对应Java的 `parseSync()` 方法: + +```javascript +function parse(shareLinkInfo, http, logger) { + var shareUrl = shareLinkInfo.getShareUrl(); + var password = shareLinkInfo.getSharePassword(); + + // 发起请求获取页面 + var response = http.get(shareUrl); + var html = response.body(); + + // 解析HTML获取下载链接 + var regex = /downloadUrl["']:\s*["']([^"']+)["']/; + var match = html.match(regex); + + if (match) { + return match[1]; // 返回下载链接 + } else { + throw new Error("无法解析下载链接"); + } +} +``` + +### parseFileList方法(可选) + +解析文件列表(目录),对应Java的 `parseFileListSync()` 方法: + +```javascript +function parseFileList(shareLinkInfo, http, logger) { + var dirId = shareLinkInfo.getOtherParam("dirId") || "0"; + + // 请求文件列表API + var response = http.get("/api/list?dirId=" + dirId); + var data = response.json(); + + var fileList = []; + for (var i = 0; i < data.files.length; i++) { + var file = data.files[i]; + + var fileInfo = { + fileName: file.name, + fileId: file.id, + fileType: file.isDir ? "folder" : "file", + size: file.size, + sizeStr: formatSize(file.size), + createTime: file.createTime, + parserUrl: "/v2/redirectUrl/my_parser/" + file.id + }; + + fileList.push(fileInfo); + } + + return fileList; +} +``` + +### parseById方法(可选) + +根据文件ID获取下载链接,对应Java的 `parseByIdSync()` 方法: + +```javascript +function parseById(shareLinkInfo, http, logger) { + var paramJson = shareLinkInfo.getOtherParam("paramJson"); + var fileId = paramJson.fileId; + + // 请求下载API + var response = http.get("/api/download?fileId=" + fileId); + var data = response.json(); + + return data.downloadUrl; +} +``` + +## 同步方法支持 + +JavaScript解析器的方法都是同步执行的,对应Java接口的三种同步方法: + +### 方法对应关系 + +| JavaScript方法 | Java同步方法 | 说明 | +|----------------|-------------|------| +| `parse()` | `parseSync()` | 解析单个文件下载链接 | +| `parseFileList()` | `parseFileListSync()` | 解析文件列表 | +| `parseById()` | `parseByIdSync()` | 根据文件ID获取下载链接 | + +### 使用示例 + +```javascript +// 在Java中调用JavaScript解析器 +IPanTool tool = ParserCreate.fromType("my_js_parser") + .shareKey("abc123") + .createTool(); + +// 使用同步方法调用JavaScript函数 +String downloadUrl = tool.parseSync(); // 调用 parse() 函数 +List files = tool.parseFileListSync(); // 调用 parseFileList() 函数 +String fileUrl = tool.parseByIdSync(); // 调用 parseById() 函数 +``` + +### 注意事项 + +- JavaScript方法都是同步执行的,无需处理异步回调 +- 如果JavaScript方法抛出异常,Java同步方法会抛出相应的异常 +- 建议在JavaScript方法中添加适当的错误处理和日志记录 + +## 导出方式 + +支持三种导出方式: + +### 方式1:分别导出(推荐) + +```javascript +function parse(shareLinkInfo, http, logger) { } +function parseFileList(shareLinkInfo, http, logger) { } +function parseById(shareLinkInfo, http, logger) { } + +exports.parse = parse; +exports.parseFileList = parseFileList; +exports.parseById = parseById; +``` + +### 方式2:直接挂载 + +```javascript +exports.parse = function(shareLinkInfo, http, logger) { }; +exports.parseFileList = function(shareLinkInfo, http, logger) { }; +exports.parseById = function(shareLinkInfo, http, logger) { }; +``` + +### 方式3:module.exports + +```javascript +function parse(shareLinkInfo, http, logger) { } +function parseFileList(shareLinkInfo, http, logger) { } +function parseById(shareLinkInfo, http, logger) { } + +module.exports = { + parse: parse, + parseFileList: parseFileList, + parseById: parseById +}; +``` + +## VSCode配置 + +### 1. 安装JavaScript扩展 + +安装 "JavaScript (ES6) code snippets" 扩展。 + +### 2. 配置jsconfig.json + +在 `custom-parsers` 目录下创建 `jsconfig.json`: + +```json +{ + "compilerOptions": { + "checkJs": true, + "target": "ES5", + "lib": ["ES5"], + "allowJs": true, + "noEmit": true + }, + "include": ["*.js", "types.d.ts"], + "exclude": ["node_modules"] +} +``` + +### 3. 使用类型提示 + +```javascript +// 引用类型定义 +var types = require('./types'); +/** @typedef {types.ShareLinkInfo} ShareLinkInfo */ +/** @typedef {types.JsHttpClient} JsHttpClient */ + +// 使用类型注解 +/** + * @param {ShareLinkInfo} shareLinkInfo + * @param {JsHttpClient} http + * @returns {string} + */ +function parse(shareLinkInfo, http, logger) { + // VSCode会提供代码补全和类型检查 +} +``` + +## 调试技巧 + +### 1. 使用日志 + +```javascript +function parse(shareLinkInfo, http, logger) { + logger.info("开始解析: " + shareLinkInfo.getShareUrl()); + + var response = http.get(shareLinkInfo.getShareUrl()); + logger.debug("响应状态: " + response.statusCode()); + logger.debug("响应内容: " + response.body().substring(0, 100)); + + // 解析逻辑... +} +``` + +### 2. 错误处理 + +```javascript +function parse(shareLinkInfo, http, logger) { + try { + var response = http.get(shareLinkInfo.getShareUrl()); + + if (!response.isSuccess()) { + throw new Error("HTTP请求失败: " + response.statusCode()); + } + + var data = response.json(); + return data.downloadUrl; + + } catch (e) { + logger.error("解析失败: " + e.message); + throw e; // 重新抛出异常 + } +} +``` + +### 3. 启用调试模式 + +设置系统属性启用详细日志: + +```bash +-Dnfd.js.debug=true +``` + +## 常见问题 + +### Q: 如何获取分享密码? + +A: 使用 `shareLinkInfo.getSharePassword()` 方法。 + +### Q: 如何处理需要登录的网盘? + +A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm()` 发送登录表单。 + +### Q: 如何解析复杂的HTML? + +A: 使用正则表达式或字符串方法解析HTML内容。 + +### Q: 如何处理异步请求? + +A: 当前版本使用同步API,所有HTTP请求都是同步的。 + +### Q: 如何调试JavaScript代码? + +A: 使用 `logger.debug()` 输出调试信息,查看应用日志。 + +## 示例脚本 + +参考 `parser/src/main/resources/custom-parsers/example-demo.js` 文件,包含完整的示例实现。 + +## 限制说明 + +1. **JavaScript版本**: 仅支持ES5.1语法(Nashorn引擎限制) +2. **同步执行**: 所有HTTP请求都是同步的 +3. **内存限制**: 长时间运行可能存在内存泄漏风险 +4. **安全限制**: 无法访问文件系统或执行系统命令 + +## 更新日志 + +- v1.0.0: 初始版本,支持基本的JavaScript解析器功能 diff --git a/parser/doc/README.md b/parser/doc/README.md index 5f7bfc1..723a6c7 100644 --- a/parser/doc/README.md +++ b/parser/doc/README.md @@ -29,19 +29,25 @@ public class ParserQuickStart { // .setShareLinkInfoPwd("1234") // 如有提取码可设置 .createTool(); - // 3) 异步 -> 同步等待,获取文件列表 - List files = tool.parseFileList() - .toCompletionStage().toCompletableFuture().join(); + // 3) 使用同步方法获取文件列表(推荐) + List files = tool.parseFileListSync(); for (FileInfo f : files) { System.out.printf("%s\t%s\t%s\n", f.getFileName(), f.getSizeStr(), f.getParserUrl()); } - // 4) 原始解析输出(不同盘实现差异较大,仅供调试) + // 4) 使用同步方法获取原始解析输出(不同盘实现差异较大,仅供调试) String raw = tool.parseSync(); System.out.println("raw: " + (raw == null ? "null" : raw.substring(0, Math.min(raw.length(), 200)) + "...")); + + // 5) 使用同步方法根据文件ID获取下载链接(可选) + if (!files.isEmpty()) { + String fileId = files.get(0).getFileId(); + String downloadUrl = tool.parseByIdSync(); + System.out.println("文件下载链接: " + downloadUrl); + } - // 5) 生成 parser 短链 path(可用于上层路由聚合显示) + // 6) 生成 parser 短链 path(可用于上层路由聚合显示) String path = ParserCreate.fromShareUrl(shareUrl).genPathSuffix(); System.out.println("path suffix: /" + path); @@ -56,13 +62,17 @@ IPanTool tool = ParserCreate.fromType("lz") // 对应 PanDomainTemplate.LZ .shareKey("abcd12") // 必填:分享 key .setShareLinkInfoPwd("1234") // 可选:提取码 .createTool(); -// 获取文件列表 -List files = tool.parseFileList().toCompletionStage().toCompletableFuture().join(); +// 获取文件列表(使用同步方法) +List files = tool.parseFileListSync(); ``` 要点: - 必须先 WebClientVertxInit.init(Vertx);若未显式初始化,内部将懒加载 Vertx.vertx(),建议显式注入以统一生命周期。 -- parseFileList 返回 Future>,可直接 join/await;parseSync 仅针对 parse() 的 String 结果。 +- 支持三种同步方法: + - `parseSync()`: 解析单个文件下载链接 + - `parseFileListSync()`: 解析文件列表 + - `parseByIdSync()`: 根据文件ID获取下载链接 +- 异步方法仍可用:parse()、parseFileList()、parseById() 返回 Future 对象 - 生成短链 path:ParserCreate.genPathSuffix()(用于页面/服务端聚合)。 --- diff --git a/parser/src/main/java/cn/qaiu/WebClientVertxInit.java b/parser/src/main/java/cn/qaiu/WebClientVertxInit.java index 641abf3..61449ae 100644 --- a/parser/src/main/java/cn/qaiu/WebClientVertxInit.java +++ b/parser/src/main/java/cn/qaiu/WebClientVertxInit.java @@ -1,5 +1,6 @@ package cn.qaiu; +import cn.qaiu.parser.CustomParserRegistry; import io.vertx.core.Vertx; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,12 +13,26 @@ public class WebClientVertxInit { public static void init(Vertx vx) { INSTANCE.vertx = vx; + + // 自动加载JavaScript解析器脚本 + try { + CustomParserRegistry.autoLoadJsScripts(); + } catch (Exception e) { + log.warn("自动加载JavaScript解析器脚本失败", e); + } } public static Vertx get() { if (INSTANCE.vertx == null) { log.info("getVertx: Vertx实例不存在, 创建Vertx实例."); INSTANCE.vertx = Vertx.vertx(); + + // 如果Vertx实例是新创建的,也尝试加载JavaScript脚本 + try { + CustomParserRegistry.autoLoadJsScripts(); + } catch (Exception e) { + log.warn("自动加载JavaScript解析器脚本失败", e); + } } return INSTANCE.vertx; } diff --git a/parser/src/main/java/cn/qaiu/parser/CustomParserConfig.java b/parser/src/main/java/cn/qaiu/parser/CustomParserConfig.java index 87aa7ae..5ee2d24 100644 --- a/parser/src/main/java/cn/qaiu/parser/CustomParserConfig.java +++ b/parser/src/main/java/cn/qaiu/parser/CustomParserConfig.java @@ -2,6 +2,7 @@ package cn.qaiu.parser; import cn.qaiu.entity.ShareLinkInfo; +import java.util.Map; import java.util.regex.Pattern; /** @@ -46,6 +47,21 @@ public class CustomParserConfig { */ private final Pattern matchPattern; + /** + * JavaScript代码(用于JavaScript解析器) + */ + private final String jsCode; + + /** + * 是否为JavaScript解析器 + */ + private final boolean isJsParser; + + /** + * 元数据信息(从脚本注释中解析) + */ + private final Map metadata; + private CustomParserConfig(Builder builder) { this.type = builder.type; this.displayName = builder.displayName; @@ -53,6 +69,9 @@ public class CustomParserConfig { this.standardUrlTemplate = builder.standardUrlTemplate; this.panDomain = builder.panDomain; this.matchPattern = builder.matchPattern; + this.jsCode = builder.jsCode; + this.isJsParser = builder.isJsParser; + this.metadata = builder.metadata; } public String getType() { @@ -79,6 +98,18 @@ public class CustomParserConfig { return matchPattern; } + public String getJsCode() { + return jsCode; + } + + public boolean isJsParser() { + return isJsParser; + } + + public Map getMetadata() { + return metadata; + } + /** * 检查是否支持从分享链接自动识别 * @return true表示支持,false表示不支持 @@ -101,6 +132,9 @@ public class CustomParserConfig { private String standardUrlTemplate; private String panDomain; private Pattern matchPattern; + private String jsCode; + private boolean isJsParser; + private Map metadata; /** * 设置解析器类型标识(必填,唯一) @@ -167,6 +201,33 @@ public class CustomParserConfig { return this; } + /** + * 设置JavaScript代码(用于JavaScript解析器) + * @param jsCode JavaScript代码 + */ + public Builder jsCode(String jsCode) { + this.jsCode = jsCode; + return this; + } + + /** + * 设置是否为JavaScript解析器 + * @param isJsParser 是否为JavaScript解析器 + */ + public Builder isJsParser(boolean isJsParser) { + this.isJsParser = isJsParser; + return this; + } + + /** + * 设置元数据信息 + * @param metadata 元数据信息 + */ + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + /** * 构建配置对象 * @return CustomParserConfig @@ -178,20 +239,29 @@ public class CustomParserConfig { if (displayName == null || displayName.trim().isEmpty()) { throw new IllegalArgumentException("displayName不能为空"); } - if (toolClass == null) { - throw new IllegalArgumentException("toolClass不能为空"); - } - // 验证toolClass是否实现了IPanTool接口 - if (!IPanTool.class.isAssignableFrom(toolClass)) { - throw new IllegalArgumentException("toolClass必须实现IPanTool接口"); - } - - // 验证toolClass是否有ShareLinkInfo单参构造器 - try { - toolClass.getDeclaredConstructor(ShareLinkInfo.class); - } catch (NoSuchMethodException e) { - throw new IllegalArgumentException("toolClass必须有ShareLinkInfo单参构造器", e); + // 如果是JavaScript解析器,验证jsCode + if (isJsParser) { + if (jsCode == null || jsCode.trim().isEmpty()) { + throw new IllegalArgumentException("JavaScript解析器的jsCode不能为空"); + } + } else { + // 如果是Java解析器,验证toolClass + if (toolClass == null) { + throw new IllegalArgumentException("Java解析器的toolClass不能为空"); + } + + // 验证toolClass是否实现了IPanTool接口 + if (!IPanTool.class.isAssignableFrom(toolClass)) { + throw new IllegalArgumentException("toolClass必须实现IPanTool接口"); + } + + // 验证toolClass是否有ShareLinkInfo单参构造器 + try { + toolClass.getDeclaredConstructor(ShareLinkInfo.class); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("toolClass必须有ShareLinkInfo单参构造器", e); + } } // 验证正则表达式(如果提供) @@ -212,10 +282,13 @@ public class CustomParserConfig { return "CustomParserConfig{" + "type='" + type + '\'' + ", displayName='" + displayName + '\'' + - ", toolClass=" + toolClass.getName() + + ", toolClass=" + (toolClass != null ? toolClass.getName() : "null") + ", standardUrlTemplate='" + standardUrlTemplate + '\'' + ", panDomain='" + panDomain + '\'' + ", matchPattern=" + (matchPattern != null ? matchPattern.pattern() : "null") + + ", jsCode=" + (jsCode != null ? "[JavaScript代码]" : "null") + + ", isJsParser=" + isJsParser + + ", metadata=" + metadata + '}'; } } diff --git a/parser/src/main/java/cn/qaiu/parser/CustomParserRegistry.java b/parser/src/main/java/cn/qaiu/parser/CustomParserRegistry.java index 679312f..b224540 100644 --- a/parser/src/main/java/cn/qaiu/parser/CustomParserRegistry.java +++ b/parser/src/main/java/cn/qaiu/parser/CustomParserRegistry.java @@ -1,5 +1,9 @@ package cn.qaiu.parser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -12,6 +16,8 @@ import java.util.concurrent.ConcurrentHashMap; */ public class CustomParserRegistry { + private static final Logger log = LoggerFactory.getLogger(CustomParserRegistry.class); + /** * 存储自定义解析器配置的Map,key为类型标识,value为配置对象 */ @@ -51,6 +57,108 @@ public class CustomParserRegistry { } CUSTOM_PARSERS.put(type, config); + log.info("注册自定义解析器成功: {} ({})", config.getDisplayName(), type); + } + + /** + * 注册JavaScript解析器 + * + * @param config JavaScript解析器配置 + * @throws IllegalArgumentException 如果type已存在或与内置解析器冲突 + */ + public static void registerJs(CustomParserConfig config) { + if (config == null) { + throw new IllegalArgumentException("config不能为空"); + } + + if (!config.isJsParser()) { + throw new IllegalArgumentException("config必须是JavaScript解析器配置"); + } + + register(config); + } + + /** + * 从JavaScript代码字符串注册解析器 + * + * @param jsCode JavaScript代码 + * @throws IllegalArgumentException 如果解析失败 + */ + public static void registerJsFromCode(String jsCode) { + if (jsCode == null || jsCode.trim().isEmpty()) { + throw new IllegalArgumentException("JavaScript代码不能为空"); + } + + try { + CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode); + registerJs(config); + } catch (Exception e) { + throw new IllegalArgumentException("解析JavaScript代码失败: " + e.getMessage(), e); + } + } + + /** + * 从文件注册JavaScript解析器 + * + * @param filePath 文件路径 + * @throws IllegalArgumentException 如果文件不存在或解析失败 + */ + public static void registerJsFromFile(String filePath) { + if (filePath == null || filePath.trim().isEmpty()) { + throw new IllegalArgumentException("文件路径不能为空"); + } + + try { + CustomParserConfig config = JsScriptLoader.loadFromFile(filePath); + registerJs(config); + } catch (Exception e) { + throw new IllegalArgumentException("从文件加载JavaScript解析器失败: " + e.getMessage(), e); + } + } + + /** + * 从资源文件注册JavaScript解析器 + * + * @param resourcePath 资源路径 + * @throws IllegalArgumentException 如果资源不存在或解析失败 + */ + public static void registerJsFromResource(String resourcePath) { + if (resourcePath == null || resourcePath.trim().isEmpty()) { + throw new IllegalArgumentException("资源路径不能为空"); + } + + try { + CustomParserConfig config = JsScriptLoader.loadFromResource(resourcePath); + registerJs(config); + } catch (Exception e) { + throw new IllegalArgumentException("从资源加载JavaScript解析器失败: " + e.getMessage(), e); + } + } + + /** + * 自动加载所有JavaScript脚本 + */ + public static void autoLoadJsScripts() { + try { + List configs = JsScriptLoader.loadAllScripts(); + int successCount = 0; + int failCount = 0; + + for (CustomParserConfig config : configs) { + try { + registerJs(config); + successCount++; + } catch (Exception e) { + log.error("加载JavaScript脚本失败: {}", config.getType(), e); + failCount++; + } + } + + log.info("自动加载JavaScript脚本完成: 成功 {} 个,失败 {} 个", successCount, failCount); + + } catch (Exception e) { + log.error("自动加载JavaScript脚本时发生异常", e); + } } /** @@ -63,7 +171,13 @@ public class CustomParserRegistry { if (type == null || type.trim().isEmpty()) { return false; } - return CUSTOM_PARSERS.remove(type.toLowerCase()) != null; + + CustomParserConfig removed = CUSTOM_PARSERS.remove(type.toLowerCase()); + if (removed != null) { + log.info("注销自定义解析器: {} ({})", removed.getDisplayName(), type); + return true; + } + return false; } /** diff --git a/parser/src/main/java/cn/qaiu/parser/IPanTool.java b/parser/src/main/java/cn/qaiu/parser/IPanTool.java index cb8a676..72ca898 100644 --- a/parser/src/main/java/cn/qaiu/parser/IPanTool.java +++ b/parser/src/main/java/cn/qaiu/parser/IPanTool.java @@ -7,6 +7,11 @@ import io.vertx.core.Promise; import java.util.List; public interface IPanTool { + + /** + * 解析文件 + * @return 文件内容 + */ Future parse(); default String parseSync() { @@ -23,6 +28,10 @@ public interface IPanTool { return promise.future(); } + default List parseFileListSync() { + return parseFileList().toCompletionStage().toCompletableFuture().join(); + } + /** * 根据文件ID获取下载链接 * @return url @@ -32,4 +41,8 @@ public interface IPanTool { promise.complete("Not implemented yet"); return promise.future(); } + + default String parseByIdSync() { + return parseById().toCompletionStage().toCompletableFuture().join(); + } } diff --git a/parser/src/main/java/cn/qaiu/parser/JsHttpClient.java b/parser/src/main/java/cn/qaiu/parser/JsHttpClient.java new file mode 100644 index 0000000..d78c962 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/JsHttpClient.java @@ -0,0 +1,300 @@ +package cn.qaiu.parser; + +import cn.qaiu.WebClientVertxInit; +import cn.qaiu.util.HttpResponseHelper; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Promise; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.client.HttpRequest; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * JavaScript HTTP客户端封装 + * 为JavaScript提供同步API风格的HTTP请求功能 + * + * @author QAIU + * Create at 2025/10/17 + */ +public class JsHttpClient { + + private static final Logger log = LoggerFactory.getLogger(JsHttpClient.class); + + private final WebClient client; + private final WebClientSession clientSession; + private MultiMap headers; + + public JsHttpClient() { + this.client = WebClient.create(WebClientVertxInit.get()); + this.clientSession = WebClientSession.create(client); + this.headers = MultiMap.caseInsensitiveMultiMap(); + } + + /** + * 发起GET请求 + * @param url 请求URL + * @return HTTP响应 + */ + public JsHttpResponse get(String url) { + return executeRequest(() -> { + HttpRequest request = client.getAbs(url); + if (!headers.isEmpty()) { + request.putHeaders(headers); + } + return request.send(); + }); + } + + /** + * 发起GET请求并跟随重定向 + * @param url 请求URL + * @return HTTP响应 + */ + public JsHttpResponse getWithRedirect(String url) { + return executeRequest(() -> { + HttpRequest request = client.getAbs(url); + if (!headers.isEmpty()) { + request.putHeaders(headers); + } + // 设置跟随重定向 + request.followRedirects(true); + return request.send(); + }); + } + + /** + * 发起GET请求但不跟随重定向(用于获取Location头) + * @param url 请求URL + * @return HTTP响应 + */ + public JsHttpResponse getNoRedirect(String url) { + return executeRequest(() -> { + HttpRequest request = client.getAbs(url); + if (!headers.isEmpty()) { + request.putHeaders(headers); + } + // 设置不跟随重定向 + request.followRedirects(false); + return request.send(); + }); + } + + /** + * 发起POST请求 + * @param url 请求URL + * @param data 请求数据 + * @return HTTP响应 + */ + public JsHttpResponse post(String url, Object data) { + return executeRequest(() -> { + HttpRequest request = client.postAbs(url); + if (!headers.isEmpty()) { + request.putHeaders(headers); + } + + if (data != null) { + if (data instanceof String) { + request.sendBuffer(Buffer.buffer((String) data)); + } else if (data instanceof Map) { + @SuppressWarnings("unchecked") + Map mapData = (Map) data; + request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData)); + } else { + request.sendJson(data); + } + } else { + request.send(); + } + + return request.send(); + }); + } + + /** + * 设置请求头 + * @param name 头名称 + * @param value 头值 + * @return 当前客户端实例(支持链式调用) + */ + public JsHttpClient putHeader(String name, String value) { + if (name != null && value != null) { + headers.set(name, value); + } + return this; + } + + /** + * 发送表单数据 + * @param data 表单数据 + * @return HTTP响应 + */ + public JsHttpResponse sendForm(Map data) { + return executeRequest(() -> { + HttpRequest request = client.postAbs(""); + if (!headers.isEmpty()) { + request.putHeaders(headers); + } + + MultiMap formData = MultiMap.caseInsensitiveMultiMap(); + if (data != null) { + formData.addAll(data); + } + + return request.sendForm(formData); + }); + } + + /** + * 发送JSON数据 + * @param data JSON数据 + * @return HTTP响应 + */ + public JsHttpResponse sendJson(Object data) { + return executeRequest(() -> { + HttpRequest request = client.postAbs(""); + if (!headers.isEmpty()) { + request.putHeaders(headers); + } + + return request.sendJson(data); + }); + } + + /** + * 执行HTTP请求(同步) + */ + private JsHttpResponse executeRequest(RequestExecutor executor) { + try { + Promise> promise = Promise.promise(); + Future> future = executor.execute(); + + future.onComplete(promise); + + // 等待响应完成(最多30秒) + HttpResponse response = promise.future().toCompletionStage() + .toCompletableFuture() + .get(30, TimeUnit.SECONDS); + + return new JsHttpResponse(response); + + } catch (Exception e) { + log.error("HTTP请求执行失败", e); + throw new RuntimeException("HTTP请求执行失败: " + e.getMessage(), e); + } + } + + /** + * 请求执行器接口 + */ + @FunctionalInterface + private interface RequestExecutor { + Future> execute(); + } + + /** + * JavaScript HTTP响应封装 + */ + public static class JsHttpResponse { + + private final HttpResponse response; + + public JsHttpResponse(HttpResponse response) { + this.response = response; + } + + /** + * 获取响应体(字符串) + * @return 响应体字符串 + */ + public String body() { + return HttpResponseHelper.asText(response); + } + + /** + * 解析JSON响应 + * @return JSON对象或数组 + */ + public Object json() { + try { + String body = response.bodyAsString(); + if (body == null || body.trim().isEmpty()) { + return null; + } + + // 尝试解析为JSON对象 + try { + JsonObject jsonObject = response.bodyAsJsonObject(); + // 将JsonObject转换为Map,这样JavaScript可以正确访问 + return jsonObject.getMap(); + } catch (Exception e) { + // 如果解析为对象失败,尝试解析为数组 + try { + return response.bodyAsJsonArray().getList(); + } catch (Exception e2) { + // 如果都失败了,返回原始字符串 + log.warn("无法解析为JSON,返回原始字符串: {}", body); + return body; + } + } + } catch (Exception e) { + log.error("解析JSON响应失败", e); + throw new RuntimeException("解析JSON响应失败: " + e.getMessage(), e); + } + } + + /** + * 获取HTTP状态码 + * @return 状态码 + */ + public int statusCode() { + return response.statusCode(); + } + + /** + * 获取响应头 + * @param name 头名称 + * @return 头值 + */ + public String header(String name) { + return response.getHeader(name); + } + + /** + * 获取所有响应头 + * @return 响应头Map + */ + public Map headers() { + MultiMap responseHeaders = response.headers(); + Map result = new java.util.HashMap<>(); + for (String name : responseHeaders.names()) { + result.put(name, responseHeaders.get(name)); + } + return result; + } + + /** + * 检查请求是否成功 + * @return true表示成功(2xx状态码),false表示失败 + */ + public boolean isSuccess() { + int status = statusCode(); + return status >= 200 && status < 300; + } + + /** + * 获取原始响应对象 + * @return HttpResponse对象 + */ + public HttpResponse getOriginalResponse() { + return response; + } + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/JsLogger.java b/parser/src/main/java/cn/qaiu/parser/JsLogger.java new file mode 100644 index 0000000..969e6a8 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/JsLogger.java @@ -0,0 +1,144 @@ +package cn.qaiu.parser; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JavaScript日志封装 + * 为JavaScript提供日志功能 + * + * @author QAIU + * Create at 2025/10/17 + */ +public class JsLogger { + + private final Logger logger; + private final String prefix; + + public JsLogger(String name) { + this.logger = LoggerFactory.getLogger(name); + this.prefix = "[" + name + "] "; + } + + public JsLogger(Class clazz) { + this.logger = LoggerFactory.getLogger(clazz); + this.prefix = "[" + clazz.getSimpleName() + "] "; + } + + /** + * 调试日志 + * @param message 日志消息 + */ + public void debug(String message) { + logger.debug(prefix + message); + } + + /** + * 调试日志(带参数) + * @param message 日志消息模板 + * @param args 参数 + */ + public void debug(String message, Object... args) { + logger.debug(prefix + message, args); + } + + /** + * 信息日志 + * @param message 日志消息 + */ + public void info(String message) { + logger.info(prefix + message); + } + + /** + * 信息日志(带参数) + * @param message 日志消息模板 + * @param args 参数 + */ + public void info(String message, Object... args) { + logger.info(prefix + message, args); + } + + /** + * 警告日志 + * @param message 日志消息 + */ + public void warn(String message) { + logger.warn(prefix + message); + } + + /** + * 警告日志(带参数) + * @param message 日志消息模板 + * @param args 参数 + */ + public void warn(String message, Object... args) { + logger.warn(prefix + message, args); + } + + /** + * 错误日志 + * @param message 日志消息 + */ + public void error(String message) { + logger.error(prefix + message); + } + + /** + * 错误日志(带参数) + * @param message 日志消息模板 + * @param args 参数 + */ + public void error(String message, Object... args) { + logger.error(prefix + message, args); + } + + /** + * 错误日志(带异常) + * @param message 日志消息 + * @param throwable 异常对象 + */ + public void error(String message, Throwable throwable) { + logger.error(prefix + message, throwable); + } + + /** + * 检查是否启用调试级别日志 + * @return true表示启用,false表示不启用 + */ + public boolean isDebugEnabled() { + return logger.isDebugEnabled(); + } + + /** + * 检查是否启用信息级别日志 + * @return true表示启用,false表示不启用 + */ + public boolean isInfoEnabled() { + return logger.isInfoEnabled(); + } + + /** + * 检查是否启用警告级别日志 + * @return true表示启用,false表示不启用 + */ + public boolean isWarnEnabled() { + return logger.isWarnEnabled(); + } + + /** + * 检查是否启用错误级别日志 + * @return true表示启用,false表示不启用 + */ + public boolean isErrorEnabled() { + return logger.isErrorEnabled(); + } + + /** + * 获取原始Logger对象 + * @return Logger对象 + */ + public Logger getOriginalLogger() { + return logger; + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/JsParserExecutor.java b/parser/src/main/java/cn/qaiu/parser/JsParserExecutor.java new file mode 100644 index 0000000..1cc0e7c --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/JsParserExecutor.java @@ -0,0 +1,283 @@ +package cn.qaiu.parser; + +import cn.qaiu.entity.FileInfo; +import cn.qaiu.entity.ShareLinkInfo; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import org.openjdk.nashorn.api.scripting.ScriptObjectMirror; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.script.Invocable; +import javax.script.ScriptEngine; +import javax.script.ScriptEngineManager; +import javax.script.ScriptException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * JavaScript解析器执行器 + * 实现IPanTool接口,执行JavaScript解析器逻辑 + * + * @author QAIU + * Create at 2025/10/17 + */ +public class JsParserExecutor implements IPanTool { + + private static final Logger log = LoggerFactory.getLogger(JsParserExecutor.class); + + private final CustomParserConfig config; + private final ShareLinkInfo shareLinkInfo; + private final ScriptEngine engine; + private final JsHttpClient httpClient; + private final JsLogger jsLogger; + private final JsShareLinkInfoWrapper shareLinkInfoWrapper; + private final Promise promise = Promise.promise(); + + public JsParserExecutor(ShareLinkInfo shareLinkInfo, CustomParserConfig config) { + this.config = config; + this.shareLinkInfo = shareLinkInfo; + this.engine = initEngine(); + this.httpClient = new JsHttpClient(); + this.jsLogger = new JsLogger("JsParser-" + config.getType()); + this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo); + } + + /** + * 获取ShareLinkInfo对象 + * @return ShareLinkInfo对象 + */ + public ShareLinkInfo getShareLinkInfo() { + return shareLinkInfo; + } + + /** + * 初始化JavaScript引擎 + */ + private ScriptEngine initEngine() { + try { + ScriptEngineManager engineManager = new ScriptEngineManager(); + ScriptEngine engine = engineManager.getEngineByName("JavaScript"); + + if (engine == null) { + throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用"); + } + + // 注入Java对象到JavaScript环境 + engine.put("http", httpClient); + engine.put("logger", jsLogger); + engine.put("shareLinkInfo", shareLinkInfoWrapper); + + // 执行JavaScript代码 + engine.eval(config.getJsCode()); + + log.debug("JavaScript引擎初始化成功,解析器类型: {}", config.getType()); + return engine; + + } catch (Exception e) { + log.error("JavaScript引擎初始化失败", e); + throw new RuntimeException("JavaScript引擎初始化失败: " + e.getMessage(), e); + } + } + + @Override + public Future parse() { + try { + jsLogger.info("开始执行JavaScript解析器: {}", config.getType()); + + // 直接调用全局parse函数 + Object parseFunction = engine.get("parse"); + if (parseFunction == null) { + throw new RuntimeException("JavaScript代码中未找到parse函数"); + } + + if (parseFunction instanceof ScriptObjectMirror) { + ScriptObjectMirror parseMirror = (ScriptObjectMirror) parseFunction; + + Object result = parseMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger); + + if (result instanceof String) { + jsLogger.info("解析成功: {}", result); + promise.complete((String) result); + } else { + jsLogger.error("parse方法返回值类型错误,期望String,实际: {}", + result != null ? result.getClass().getSimpleName() : "null"); + promise.fail("parse方法返回值类型错误"); + } + } else { + throw new RuntimeException("parse函数类型错误"); + } + + } catch (Exception e) { + jsLogger.error("JavaScript解析器执行失败", e); + promise.fail("JavaScript解析器执行失败: " + e.getMessage()); + } + + return promise.future(); + } + + @Override + public Future> parseFileList() { + Promise> promise = Promise.promise(); + + try { + jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType()); + + // 直接调用全局parseFileList函数 + Object parseFileListFunction = engine.get("parseFileList"); + if (parseFileListFunction == null) { + throw new RuntimeException("JavaScript代码中未找到parseFileList函数"); + } + + // 调用parseFileList方法 + if (parseFileListFunction instanceof ScriptObjectMirror) { + ScriptObjectMirror parseFileListMirror = (ScriptObjectMirror) parseFileListFunction; + + Object result = parseFileListMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger); + + if (result instanceof ScriptObjectMirror) { + ScriptObjectMirror resultMirror = (ScriptObjectMirror) result; + List fileList = convertToFileInfoList(resultMirror); + + jsLogger.info("文件列表解析成功,共 {} 个文件", fileList.size()); + promise.complete(fileList); + } else { + jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}", + result != null ? result.getClass().getSimpleName() : "null"); + promise.fail("parseFileList方法返回值类型错误"); + } + } else { + throw new RuntimeException("parseFileList函数类型错误"); + } + + } catch (Exception e) { + jsLogger.error("JavaScript文件列表解析失败", e); + promise.fail("JavaScript文件列表解析失败: " + e.getMessage()); + } + + return promise.future(); + } + + @Override + public Future parseById() { + Promise promise = Promise.promise(); + + try { + jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType()); + + // 直接调用全局parseById函数 + Object parseByIdFunction = engine.get("parseById"); + if (parseByIdFunction == null) { + throw new RuntimeException("JavaScript代码中未找到parseById函数"); + } + + // 调用parseById方法 + if (parseByIdFunction instanceof ScriptObjectMirror) { + ScriptObjectMirror parseByIdMirror = (ScriptObjectMirror) parseByIdFunction; + + Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger); + + if (result instanceof String) { + jsLogger.info("按ID解析成功: {}", result); + promise.complete((String) result); + } else { + jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}", + result != null ? result.getClass().getSimpleName() : "null"); + promise.fail("parseById方法返回值类型错误"); + } + } else { + throw new RuntimeException("parseById函数类型错误"); + } + + } catch (Exception e) { + jsLogger.error("JavaScript按ID解析失败", e); + promise.fail("JavaScript按ID解析失败: " + e.getMessage()); + } + + return promise.future(); + } + + /** + * 将JavaScript对象数组转换为FileInfo列表 + */ + private List convertToFileInfoList(ScriptObjectMirror resultMirror) { + List fileList = new ArrayList<>(); + + if (resultMirror.isArray()) { + for (int i = 0; i < resultMirror.size(); i++) { + Object item = resultMirror.get(String.valueOf(i)); + if (item instanceof ScriptObjectMirror) { + FileInfo fileInfo = convertToFileInfo((ScriptObjectMirror) item); + if (fileInfo != null) { + fileList.add(fileInfo); + } + } + } + } + + return fileList; + } + + /** + * 将JavaScript对象转换为FileInfo + */ + private FileInfo convertToFileInfo(ScriptObjectMirror itemMirror) { + try { + FileInfo fileInfo = new FileInfo(); + + // 设置基本字段 + if (itemMirror.hasMember("fileName")) { + fileInfo.setFileName(itemMirror.getMember("fileName").toString()); + } + if (itemMirror.hasMember("fileId")) { + fileInfo.setFileId(itemMirror.getMember("fileId").toString()); + } + if (itemMirror.hasMember("fileType")) { + fileInfo.setFileType(itemMirror.getMember("fileType").toString()); + } + if (itemMirror.hasMember("size")) { + Object size = itemMirror.getMember("size"); + if (size instanceof Number) { + fileInfo.setSize(((Number) size).longValue()); + } + } + if (itemMirror.hasMember("sizeStr")) { + fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString()); + } + if (itemMirror.hasMember("createTime")) { + fileInfo.setCreateTime(itemMirror.getMember("createTime").toString()); + } + if (itemMirror.hasMember("updateTime")) { + fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString()); + } + if (itemMirror.hasMember("createBy")) { + fileInfo.setCreateBy(itemMirror.getMember("createBy").toString()); + } + if (itemMirror.hasMember("downloadCount")) { + Object downloadCount = itemMirror.getMember("downloadCount"); + if (downloadCount instanceof Number) { + fileInfo.setDownloadCount(((Number) downloadCount).intValue()); + } + } + if (itemMirror.hasMember("fileIcon")) { + fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString()); + } + if (itemMirror.hasMember("panType")) { + fileInfo.setPanType(itemMirror.getMember("panType").toString()); + } + if (itemMirror.hasMember("parserUrl")) { + fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString()); + } + if (itemMirror.hasMember("previewUrl")) { + fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString()); + } + + return fileInfo; + + } catch (Exception e) { + jsLogger.error("转换FileInfo对象失败", e); + return null; + } + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/JsScriptLoader.java b/parser/src/main/java/cn/qaiu/parser/JsScriptLoader.java new file mode 100644 index 0000000..785a633 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/JsScriptLoader.java @@ -0,0 +1,264 @@ +package cn.qaiu.parser; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * JavaScript脚本加载器 + * 自动加载资源目录和外部目录的JavaScript脚本文件 + * + * @author QAIU + * Create at 2025/10/17 + */ +public class JsScriptLoader { + + private static final Logger log = LoggerFactory.getLogger(JsScriptLoader.class); + + private static final String RESOURCE_PATH = "custom-parsers"; + private static final String EXTERNAL_PATH = "./custom-parsers"; + + // 系统属性配置的外部目录路径 + private static final String EXTERNAL_PATH_PROPERTY = "parser.custom-parsers.path"; + + /** + * 加载所有JavaScript脚本 + * @return 解析器配置列表 + */ + public static List loadAllScripts() { + List configs = new ArrayList<>(); + + // 1. 加载资源目录下的JS文件 + try { + List resourceConfigs = loadFromResources(); + configs.addAll(resourceConfigs); + log.info("从资源目录加载了 {} 个JavaScript解析器", resourceConfigs.size()); + } catch (Exception e) { + log.warn("从资源目录加载JavaScript脚本失败", e); + } + + // 2. 加载外部目录下的JS文件 + try { + List externalConfigs = loadFromExternal(); + configs.addAll(externalConfigs); + log.info("从外部目录加载了 {} 个JavaScript解析器", externalConfigs.size()); + } catch (Exception e) { + log.warn("从外部目录加载JavaScript脚本失败", e); + } + + log.info("总共加载了 {} 个JavaScript解析器", configs.size()); + return configs; + } + + /** + * 从资源目录加载JavaScript脚本 + */ + private static List loadFromResources() { + List configs = new ArrayList<>(); + + try { + // 获取资源目录的输入流 + InputStream resourceStream = JsScriptLoader.class.getClassLoader() + .getResourceAsStream(RESOURCE_PATH); + + if (resourceStream == null) { + log.debug("资源目录 {} 不存在", RESOURCE_PATH); + return configs; + } + + // 读取资源目录下的所有文件 + String resourcePath = JsScriptLoader.class.getClassLoader() + .getResource(RESOURCE_PATH).getPath(); + + try (Stream paths = Files.walk(Paths.get(resourcePath))) { + paths.filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".js")) + .filter(path -> !isExcludedFile(path.getFileName().toString())) + .forEach(path -> { + try { + String jsCode = Files.readString(path, StandardCharsets.UTF_8); + CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode); + configs.add(config); + log.debug("从资源目录加载脚本: {}", path.getFileName()); + } catch (Exception e) { + log.warn("加载资源脚本失败: {}", path.getFileName(), e); + } + }); + } + + } catch (Exception e) { + log.error("从资源目录加载脚本时发生异常", e); + } + + return configs; + } + + /** + * 从外部目录加载JavaScript脚本 + */ + private static List loadFromExternal() { + List configs = new ArrayList<>(); + + try { + // 获取外部目录路径,支持系统属性配置 + String externalPath = getExternalPath(); + Path externalDir = Paths.get(externalPath); + + if (!Files.exists(externalDir) || !Files.isDirectory(externalDir)) { + log.debug("外部目录 {} 不存在或不是目录", externalPath); + return configs; + } + + try (Stream paths = Files.walk(externalDir)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".js")) + .filter(path -> !isExcludedFile(path.getFileName().toString())) + .forEach(path -> { + try { + String jsCode = Files.readString(path, StandardCharsets.UTF_8); + CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode); + configs.add(config); + log.debug("从外部目录加载脚本: {}", path.getFileName()); + } catch (Exception e) { + log.warn("加载外部脚本失败: {}", path.getFileName(), e); + } + }); + } + + } catch (Exception e) { + log.error("从外部目录加载脚本时发生异常", e); + } + + return configs; + } + + /** + * 获取外部目录路径 + * 优先级:系统属性 > 环境变量 > 默认路径 + */ + private static String getExternalPath() { + // 1. 检查系统属性 + String systemProperty = System.getProperty(EXTERNAL_PATH_PROPERTY); + if (systemProperty != null && !systemProperty.trim().isEmpty()) { + log.debug("使用系统属性配置的外部目录: {}", systemProperty); + return systemProperty; + } + + // 2. 检查环境变量 + String envVariable = System.getenv("PARSER_CUSTOM_PARSERS_PATH"); + if (envVariable != null && !envVariable.trim().isEmpty()) { + log.debug("使用环境变量配置的外部目录: {}", envVariable); + return envVariable; + } + + // 3. 使用默认路径 + log.debug("使用默认外部目录: {}", EXTERNAL_PATH); + return EXTERNAL_PATH; + } + + /** + * 从指定文件加载JavaScript脚本 + * @param filePath 文件路径 + * @return 解析器配置 + */ + public static CustomParserConfig loadFromFile(String filePath) { + try { + Path path = Paths.get(filePath); + if (!Files.exists(path)) { + throw new IllegalArgumentException("文件不存在: " + filePath); + } + + String jsCode = Files.readString(path, StandardCharsets.UTF_8); + return JsScriptMetadataParser.parseScript(jsCode); + + } catch (IOException e) { + throw new RuntimeException("读取文件失败: " + filePath, e); + } + } + + /** + * 从指定文件加载JavaScript脚本(资源路径) + * @param resourcePath 资源路径 + * @return 解析器配置 + */ + public static CustomParserConfig loadFromResource(String resourcePath) { + try { + InputStream inputStream = JsScriptLoader.class.getClassLoader() + .getResourceAsStream(resourcePath); + + if (inputStream == null) { + throw new IllegalArgumentException("资源文件不存在: " + resourcePath); + } + + String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + return JsScriptMetadataParser.parseScript(jsCode); + + } catch (IOException e) { + throw new RuntimeException("读取资源文件失败: " + resourcePath, e); + } + } + + /** + * 检查外部目录是否存在 + * @return true表示存在,false表示不存在 + */ + public static boolean isExternalDirectoryExists() { + Path externalDir = Paths.get(EXTERNAL_PATH); + return Files.exists(externalDir) && Files.isDirectory(externalDir); + } + + /** + * 创建外部目录 + * @return true表示创建成功,false表示创建失败 + */ + public static boolean createExternalDirectory() { + try { + Path externalDir = Paths.get(EXTERNAL_PATH); + Files.createDirectories(externalDir); + log.info("创建外部目录成功: {}", EXTERNAL_PATH); + return true; + } catch (IOException e) { + log.error("创建外部目录失败: {}", EXTERNAL_PATH, e); + return false; + } + } + + /** + * 获取外部目录路径 + * @return 外部目录路径 + */ + public static String getExternalDirectoryPath() { + return EXTERNAL_PATH; + } + + /** + * 获取资源目录路径 + * @return 资源目录路径 + */ + public static String getResourceDirectoryPath() { + return RESOURCE_PATH; + } + + /** + * 检查文件是否应该被排除 + * @param fileName 文件名 + * @return true表示应该排除,false表示应该加载 + */ + private static boolean isExcludedFile(String fileName) { + // 排除类型定义文件和其他非解析器文件 + return fileName.equals("types.js") || + fileName.equals("jsconfig.json") || + fileName.equals("README.md") || + fileName.contains(".test.") || + fileName.contains(".spec."); + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/JsScriptMetadataParser.java b/parser/src/main/java/cn/qaiu/parser/JsScriptMetadataParser.java new file mode 100644 index 0000000..87d08b3 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/JsScriptMetadataParser.java @@ -0,0 +1,195 @@ +package cn.qaiu.parser; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * JavaScript脚本元数据解析器 + * 解析类油猴格式的元数据注释 + * + * @author QAIU + * Create at 2025/10/17 + */ +public class JsScriptMetadataParser { + + private static final Logger log = LoggerFactory.getLogger(JsScriptMetadataParser.class); + + // 元数据块匹配正则 + private static final Pattern METADATA_BLOCK_PATTERN = Pattern.compile( + "//\\s*==UserScript==\\s*(.*?)\\s*//\\s*==/UserScript==", + Pattern.DOTALL + ); + + // 元数据行匹配正则 + private static final Pattern METADATA_LINE_PATTERN = Pattern.compile( + "//\\s*@(\\w+)\\s+(.*)" + ); + + /** + * 解析JavaScript脚本,提取元数据并构建CustomParserConfig + * + * @param jsCode JavaScript代码 + * @return CustomParserConfig配置对象 + * @throws IllegalArgumentException 如果解析失败或缺少必填字段 + */ + public static CustomParserConfig parseScript(String jsCode) { + if (StringUtils.isBlank(jsCode)) { + throw new IllegalArgumentException("JavaScript代码不能为空"); + } + + // 1. 提取元数据块 + Map metadata = extractMetadata(jsCode); + + // 2. 验证必填字段 + validateRequiredFields(metadata); + + // 3. 构建CustomParserConfig + return buildConfig(metadata, jsCode); + } + + /** + * 提取元数据 + */ + private static Map extractMetadata(String jsCode) { + Map metadata = new HashMap<>(); + + Matcher blockMatcher = METADATA_BLOCK_PATTERN.matcher(jsCode); + if (!blockMatcher.find()) { + throw new IllegalArgumentException("未找到元数据块,请确保包含 // ==UserScript== ... // /UserScript== 格式的注释"); + } + + String metadataBlock = blockMatcher.group(1); + Matcher lineMatcher = METADATA_LINE_PATTERN.matcher(metadataBlock); + + while (lineMatcher.find()) { + String key = lineMatcher.group(1).toLowerCase(); + String value = lineMatcher.group(2).trim(); + metadata.put(key, value); + } + + log.debug("解析到元数据: {}", metadata); + return metadata; + } + + /** + * 验证必填字段 + */ + private static void validateRequiredFields(Map metadata) { + if (!metadata.containsKey("name")) { + throw new IllegalArgumentException("缺少必填字段 @name"); + } + if (!metadata.containsKey("type")) { + throw new IllegalArgumentException("缺少必填字段 @type"); + } + if (!metadata.containsKey("displayname")) { + throw new IllegalArgumentException("缺少必填字段 @displayName"); + } + if (!metadata.containsKey("match")) { + throw new IllegalArgumentException("缺少必填字段 @match"); + } + + // 验证match字段包含KEY命名捕获组 + String matchPattern = metadata.get("match"); + if (!matchPattern.contains("(?")) { + throw new IllegalArgumentException("@match 正则表达式必须包含命名捕获组 KEY,用于提取分享键"); + } + } + + /** + * 构建CustomParserConfig + */ + private static CustomParserConfig buildConfig(Map metadata, String jsCode) { + CustomParserConfig.Builder builder = CustomParserConfig.builder() + .type(metadata.get("type")) + .displayName(metadata.get("displayname")) + .isJsParser(true) + .jsCode(jsCode) + .metadata(metadata); + + // 设置匹配正则 + String matchPattern = metadata.get("match"); + if (StringUtils.isNotBlank(matchPattern)) { + builder.matchPattern(matchPattern); + } + + // 设置可选字段 + if (metadata.containsKey("description")) { + // description字段可以用于其他用途,暂时不存储到config中 + } + + if (metadata.containsKey("author")) { + // author字段可以用于其他用途,暂时不存储到config中 + } + + if (metadata.containsKey("version")) { + // version字段可以用于其他用途,暂时不存储到config中 + } + + return builder.build(); + } + + /** + * 检查JavaScript代码是否包含有效的元数据块 + * + * @param jsCode JavaScript代码 + * @return true表示包含有效元数据,false表示不包含 + */ + public static boolean hasValidMetadata(String jsCode) { + if (StringUtils.isBlank(jsCode)) { + return false; + } + + try { + Map metadata = extractMetadata(jsCode); + validateRequiredFields(metadata); + return true; + } catch (Exception e) { + log.debug("JavaScript代码不包含有效元数据: {}", e.getMessage()); + return false; + } + } + + /** + * 从JavaScript代码中提取脚本名称 + * + * @param jsCode JavaScript代码 + * @return 脚本名称,如果未找到则返回null + */ + public static String extractScriptName(String jsCode) { + if (StringUtils.isBlank(jsCode)) { + return null; + } + + try { + Map metadata = extractMetadata(jsCode); + return metadata.get("name"); + } catch (Exception e) { + return null; + } + } + + /** + * 从JavaScript代码中提取脚本类型 + * + * @param jsCode JavaScript代码 + * @return 脚本类型,如果未找到则返回null + */ + public static String extractScriptType(String jsCode) { + if (StringUtils.isBlank(jsCode)) { + return null; + } + + try { + Map metadata = extractMetadata(jsCode); + return metadata.get("type"); + } catch (Exception e) { + return null; + } + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/JsShareLinkInfoWrapper.java b/parser/src/main/java/cn/qaiu/parser/JsShareLinkInfoWrapper.java new file mode 100644 index 0000000..a5b6b1d --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/JsShareLinkInfoWrapper.java @@ -0,0 +1,163 @@ +package cn.qaiu.parser; + +import cn.qaiu.entity.ShareLinkInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * ShareLinkInfo的JavaScript包装器 + * 为JavaScript提供ShareLinkInfo对象的访问接口 + * + * @author QAIU + * Create at 2025/10/17 + */ +public class JsShareLinkInfoWrapper { + + private static final Logger log = LoggerFactory.getLogger(JsShareLinkInfoWrapper.class); + + private final ShareLinkInfo shareLinkInfo; + + public JsShareLinkInfoWrapper(ShareLinkInfo shareLinkInfo) { + this.shareLinkInfo = shareLinkInfo; + } + + /** + * 获取分享URL + * @return 分享URL + */ + public String getShareUrl() { + return shareLinkInfo.getShareUrl(); + } + + /** + * 获取分享Key + * @return 分享Key + */ + public String getShareKey() { + return shareLinkInfo.getShareKey(); + } + + /** + * 获取分享密码 + * @return 分享密码 + */ + public String getSharePassword() { + return shareLinkInfo.getSharePassword(); + } + + /** + * 获取网盘类型 + * @return 网盘类型 + */ + public String getType() { + return shareLinkInfo.getType(); + } + + /** + * 获取网盘名称 + * @return 网盘名称 + */ + public String getPanName() { + return shareLinkInfo.getPanName(); + } + + /** + * 获取其他参数 + * @param key 参数键 + * @return 参数值 + */ + public Object getOtherParam(String key) { + if (key == null) { + return null; + } + return shareLinkInfo.getOtherParam().get(key); + } + + /** + * 获取所有其他参数 + * @return 参数Map + */ + public Map getAllOtherParams() { + return shareLinkInfo.getOtherParam(); + } + + /** + * 检查是否包含指定参数 + * @param key 参数键 + * @return true表示包含,false表示不包含 + */ + public boolean hasOtherParam(String key) { + if (key == null) { + return false; + } + return shareLinkInfo.getOtherParam().containsKey(key); + } + + /** + * 获取其他参数的字符串值 + * @param key 参数键 + * @return 参数值(字符串形式) + */ + public String getOtherParamAsString(String key) { + Object value = getOtherParam(key); + return value != null ? value.toString() : null; + } + + /** + * 获取其他参数的整数值 + * @param key 参数键 + * @return 参数值(整数形式) + */ + public Integer getOtherParamAsInteger(String key) { + Object value = getOtherParam(key); + if (value instanceof Integer) { + return (Integer) value; + } else if (value instanceof Number) { + return ((Number) value).intValue(); + } else if (value instanceof String) { + try { + return Integer.parseInt((String) value); + } catch (NumberFormatException e) { + log.warn("无法将参数 {} 转换为整数: {}", key, value); + return null; + } + } + return null; + } + + /** + * 获取其他参数的布尔值 + * @param key 参数键 + * @return 参数值(布尔形式) + */ + public Boolean getOtherParamAsBoolean(String key) { + Object value = getOtherParam(key); + if (value instanceof Boolean) { + return (Boolean) value; + } else if (value instanceof String) { + return Boolean.parseBoolean((String) value); + } + return null; + } + + /** + * 获取原始的ShareLinkInfo对象 + * @return ShareLinkInfo对象 + */ + public ShareLinkInfo getOriginalShareLinkInfo() { + return shareLinkInfo; + } + + @Override + public String toString() { + return "JsShareLinkInfoWrapper{" + + "shareUrl='" + getShareUrl() + '\'' + + ", shareKey='" + getShareKey() + '\'' + + ", sharePassword='" + getSharePassword() + '\'' + + ", type='" + getType() + '\'' + + ", panName='" + getPanName() + '\'' + + '}'; + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/ParserCreate.java b/parser/src/main/java/cn/qaiu/parser/ParserCreate.java index 7cd14cf..454811c 100644 --- a/parser/src/main/java/cn/qaiu/parser/ParserCreate.java +++ b/parser/src/main/java/cn/qaiu/parser/ParserCreate.java @@ -148,13 +148,19 @@ public class ParserCreate { // 自定义解析器处理 if (isCustomParser) { - try { - return this.customParserConfig.getToolClass() - .getDeclaredConstructor(ShareLinkInfo.class) - .newInstance(shareLinkInfo); - } catch (Exception e) { - throw new RuntimeException("无法创建自定义工具实例: " + - customParserConfig.getToolClass().getName(), e); + // 检查是否为JavaScript解析器 + if (customParserConfig.isJsParser()) { + return new JsParserExecutor(shareLinkInfo, customParserConfig); + } else { + // Java实现的解析器 + try { + return this.customParserConfig.getToolClass() + .getDeclaredConstructor(ShareLinkInfo.class) + .newInstance(shareLinkInfo); + } catch (Exception e) { + throw new RuntimeException("无法创建自定义工具实例: " + + customParserConfig.getToolClass().getName(), e); + } } } @@ -226,10 +232,12 @@ public class ParserCreate { public ParserCreate setShareLinkInfoPwd(String pwd) { if (pwd != null) { shareLinkInfo.setSharePassword(pwd); - standardUrl = standardUrl.replace("{pwd}", pwd); - shareLinkInfo.setStandardUrl(standardUrl); - if (shareLinkInfo.getShareUrl().contains("{pwd}")) { - shareLinkInfo.setShareUrl(standardUrl); + if (standardUrl != null) { + standardUrl = standardUrl.replace("{pwd}", pwd); + shareLinkInfo.setStandardUrl(standardUrl); + if (shareLinkInfo.getShareUrl().contains("{pwd}")) { + shareLinkInfo.setShareUrl(standardUrl); + } } } return this; diff --git a/parser/src/main/resources/custom-parsers/README.md b/parser/src/main/resources/custom-parsers/README.md new file mode 100644 index 0000000..7cb79f7 --- /dev/null +++ b/parser/src/main/resources/custom-parsers/README.md @@ -0,0 +1,238 @@ +# JavaScript解析器扩展使用指南 + +## 概述 + +本项目支持用户使用JavaScript编写自定义网盘解析器,提供灵活的扩展能力。JavaScript解析器运行在Nashorn引擎中,支持ES5.1语法。 + +## 文件结构 + +``` +custom-parsers/ +├── types.js # 类型定义文件(JSDoc注释) +├── jsconfig.json # VSCode配置文件 +├── example-demo.js # 示例解析器 +└── README.md # 本说明文档 +``` + +## 快速开始 + +### 1. 创建解析器脚本 + +在 `custom-parsers/` 目录下创建 `.js` 文件,使用以下格式: + +```javascript +// ==UserScript== +// @name 你的解析器名称 +// @type 解析器类型标识 +// @displayName 显示名称 +// @description 解析器描述 +// @match 匹配URL的正则表达式 +// @author 作者 +// @version 版本号 +// ==/UserScript== + +/** + * 解析单个文件下载链接 + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志记录器 + * @returns {string} 下载链接 + */ +function parse(shareLinkInfo, http, logger) { + // 你的解析逻辑 + return "https://example.com/download/file.zip"; +} + +/** + * 解析文件列表(可选) + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志记录器 + * @returns {FileInfo[]} 文件信息列表 + */ +function parseFileList(shareLinkInfo, http, logger) { + // 你的文件列表解析逻辑 + return []; +} + +/** + * 根据文件ID获取下载链接(可选) + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志记录器 + * @returns {string} 下载链接 + */ +function parseById(shareLinkInfo, http, logger) { + // 你的按ID解析逻辑 + return "https://example.com/download/" + fileId; +} +``` + +### 2. 自动加载 + +解析器会在应用启动时自动加载和注册。支持两种加载方式: + +#### 内置解析器(jar包内) +- 位置:jar包内的 `custom-parsers/` 资源目录 +- 特点:随jar包一起发布,无需额外配置 + +#### 外部解析器(用户自定义) +- 默认位置:应用运行目录下的 `./custom-parsers/` 文件夹 +- 配置方式: + - **系统属性**:`-Dparser.custom-parsers.path=/path/to/your/parsers` + - **环境变量**:`PARSER_CUSTOM_PARSERS_PATH=/path/to/your/parsers` + - **默认路径**:`./custom-parsers/`(相对于应用运行目录) + +#### 配置示例 + +**Maven项目中使用:** +```bash +# 方式1:系统属性 +mvn exec:java -Dexec.mainClass="your.MainClass" -Dparser.custom-parsers.path=./src/main/resources/custom-parsers + +# 方式2:环境变量 +export PARSER_CUSTOM_PARSERS_PATH=./src/main/resources/custom-parsers +mvn exec:java -Dexec.mainClass="your.MainClass" +``` + +**jar包运行时:** +```bash +# 方式1:系统属性 +java -Dparser.custom-parsers.path=/path/to/your/parsers -jar your-app.jar + +# 方式2:环境变量 +export PARSER_CUSTOM_PARSERS_PATH=/path/to/your/parsers +java -jar your-app.jar +``` + +## API参考 + +### ShareLinkInfo + +分享链接信息对象: + +```javascript +shareLinkInfo.getShareUrl() // 获取分享URL +shareLinkInfo.getShareKey() // 获取分享Key +shareLinkInfo.getSharePassword() // 获取分享密码 +shareLinkInfo.getType() // 获取网盘类型 +shareLinkInfo.getPanName() // 获取网盘名称 +shareLinkInfo.getOtherParam(key) // 获取其他参数 +``` + +### JsHttpClient + +HTTP客户端对象: + +```javascript +http.get(url) // GET请求 +http.post(url, data) // POST请求 +http.putHeader(name, value) // 设置请求头 +http.sendForm(data) // 发送表单数据 +http.sendJson(data) // 发送JSON数据 +``` + +### JsHttpResponse + +HTTP响应对象: + +```javascript +response.body() // 获取响应体(字符串) +response.json() // 解析JSON响应 +response.statusCode() // 获取HTTP状态码 +response.header(name) // 获取响应头 +response.headers() // 获取所有响应头 +``` + +### JsLogger + +日志记录器: + +```javascript +logger.debug(message) // 调试日志 +logger.info(message) // 信息日志 +logger.warn(message) // 警告日志 +logger.error(message) // 错误日志 +``` + +### FileInfo + +文件信息对象: + +```javascript +{ + fileName: "文件名", + fileId: "文件ID", + fileType: "file|folder", + size: 1024, + sizeStr: "1KB", + createTime: "2024-01-01", + updateTime: "2024-01-01", + createBy: "创建者", + downloadCount: 100, + fileIcon: "file", + panType: "网盘类型", + parserUrl: "解析URL", + previewUrl: "预览URL" +} +``` + +## 开发提示 + +### VSCode支持 + +1. 确保安装了JavaScript扩展 +2. `types.js` 文件提供类型定义和代码补全 +3. `jsconfig.json` 配置了项目设置 + +### 调试 + +- 使用 `logger.debug()` 输出调试信息 +- 查看应用日志了解解析过程 +- 使用 `console.log()` 在Nashorn中输出信息 + +### 错误处理 + +```javascript +try { + var response = http.get(url); + if (response.statusCode() !== 200) { + throw new Error("请求失败: " + response.statusCode()); + } + return response.json(); +} catch (e) { + logger.error("解析失败: " + e.message); + throw e; +} +``` + +## 示例 + +参考 `example-demo.js` 文件,它展示了完整的解析器实现,包括: + +- 元数据配置 +- 三个核心方法的实现 +- 错误处理 +- 日志记录 +- 文件信息构建 + +## 注意事项 + +1. **ES5.1兼容**:只使用ES5.1语法,避免ES6+特性 +2. **同步API**:HTTP客户端提供同步接口,无需处理异步回调 +3. **全局函数**:解析器函数必须定义为全局函数,不能使用模块导出 +4. **错误处理**:始终包含适当的错误处理和日志记录 +5. **性能考虑**:避免在解析器中执行耗时操作 + +## 故障排除 + +### 常见问题 + +1. **解析器未加载**:检查元数据格式是否正确 +2. **类型错误**:确保函数签名与接口匹配 +3. **HTTP请求失败**:检查URL和网络连接 +4. **JSON解析错误**:验证响应格式 + +### 日志查看 + +查看应用日志了解详细的执行过程和错误信息。 diff --git a/parser/src/main/resources/custom-parsers/baidu-photo.js b/parser/src/main/resources/custom-parsers/baidu-photo.js new file mode 100644 index 0000000..029f1c8 --- /dev/null +++ b/parser/src/main/resources/custom-parsers/baidu-photo.js @@ -0,0 +1,400 @@ +// ==UserScript== +// @name 一刻相册解析器 +// @type baidu_photo +// @displayName 百度一刻相册(JS) +// @description 解析百度一刻相册分享链接,获取文件列表和下载链接 +// @match https?://photo\.baidu\.com/photo/(web/share\?inviteCode=|wap/albumShare\?shareId=)(?\w+) +// @author qaiu +// @version 1.0.0 +// ==/UserScript== + +/** + * API端点配置 + */ +var API_CONFIG = { + // 文件夹分享:通过pcode获取share_id + QUERY_PCODE: "https://photo.baidu.com/youai/album/v1/querypcode", + // 文件列表:获取文件列表 + LIST_FILES: "https://photo.baidu.com/youai/share/v2/list", + + // 请求参数 + CLIENT_TYPE: "70", + LIMIT: "100" +}; + +/** + * 设置标准请求头 + * @param {JsHttpClient} http - HTTP客户端 + * @param {string} referer - Referer URL + */ +function setStandardHeaders(http, referer) { + var headers = { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Type": "application/x-www-form-urlencoded", + "DNT": "1", + "Origin": "https://photo.baidu.com", + "Pragma": "no-cache", + "Referer": referer, + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0", + "X-Requested-With": "XMLHttpRequest", + "sec-ch-ua": "\"Microsoft Edge\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"macOS\"" + }; + + for (var key in headers) { + http.putHeader(key, headers[key]); + } +} + +/** + * 获取分享ID + * @param {string} shareKey - 分享键 + * @param {boolean} isFileShare - 是否为文件分享 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志记录器 + * @returns {string} 分享ID + */ +function getShareId(shareKey, isFileShare, http, logger) { + if (isFileShare) { + logger.info("文件分享模式,直接使用shareId: " + shareKey); + return shareKey; + } + + // 文件夹分享:通过pcode获取share_id + var queryUrl = API_CONFIG.QUERY_PCODE + "?clienttype=" + API_CONFIG.CLIENT_TYPE + "&pcode=" + shareKey + "&web=1"; + logger.debug("文件夹分享查询URL: " + queryUrl); + + setStandardHeaders(http, "https://photo.baidu.com/photo/web/share?inviteCode=" + shareKey); + + var queryResponse = http.get(queryUrl); + if (queryResponse.statusCode() !== 200) { + throw new Error("获取分享ID失败,状态码: " + queryResponse.statusCode()); + } + + var queryData = queryResponse.json(); + logger.debug("查询响应: " + JSON.stringify(queryData)); + + if (queryData.errno !== undefined && queryData.errno !== 0) { + throw new Error("API返回错误,errno: " + queryData.errno); + } + + var shareId = queryData.pdata && queryData.pdata.share_id; + if (!shareId) { + throw new Error("未找到share_id"); + } + + logger.info("获取到分享ID: " + shareId); + return shareId; +} + +/** + * 获取文件列表 + * @param {string} shareId - 分享ID + * @param {string} shareKey - 分享键 + * @param {boolean} isFileShare - 是否为文件分享 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志记录器 + * @returns {Array} 文件列表 + */ +function getFileList(shareId, shareKey, isFileShare, http, logger) { + var listUrl = API_CONFIG.LIST_FILES + "?clienttype=" + API_CONFIG.CLIENT_TYPE + "&share_id=" + shareId + "&limit=" + API_CONFIG.LIMIT; + logger.debug("获取文件列表 URL: " + listUrl); + + var referer = isFileShare ? + "https://photo.baidu.com/photo/wap/albumShare?shareId=" + shareKey : + "https://photo.baidu.com/photo/web/share?inviteCode=" + shareKey; + + setStandardHeaders(http, referer); + + var listResponse = http.get(listUrl); + if (listResponse.statusCode() !== 200) { + throw new Error("获取文件列表失败,状态码: " + listResponse.statusCode()); + } + + var listData = listResponse.json(); + logger.debug("文件列表响应: " + JSON.stringify(listData)); + + if (listData.errno !== undefined && listData.errno !== 0) { + throw new Error("获取文件列表API返回错误,errno: " + listData.errno); + } + + var fileList = listData.list; + if (!fileList || fileList.length === 0) { + logger.warn("文件列表为空"); + return []; + } + + logger.info("获取到文件列表,共 " + fileList.length + " 个文件"); + return fileList; +} + +/** + * 解析单个文件下载链接 + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息对象 + * @param {JsHttpClient} http - HTTP客户端实例 + * @param {JsLogger} logger - 日志记录器实例 + * @returns {string} 下载链接 + */ +function parse(shareLinkInfo, http, logger) { + logger.info("===== 开始执行 parse 方法 ====="); + + var shareKey = shareLinkInfo.getShareKey(); + logger.info("分享Key: " + shareKey); + + try { + // 判断分享类型 + // 如果shareKey是纯数字且长度较长,很可能是文件分享的share_id + // 如果shareKey包含字母,很可能是文件夹分享的inviteCode + var isFileShare = /^\d{10,}$/.test(shareKey); + logger.info("分享类型: " + (isFileShare ? "文件分享" : "文件夹分享")); + + // 获取分享ID + var shareId = getShareId(shareKey, isFileShare, http, logger); + + // 获取文件列表 + var fileList = getFileList(shareId, shareKey, isFileShare, http, logger); + + if (fileList.length === 0) { + throw new Error("文件列表为空"); + } + + // 返回第一个文件的下载链接 + var firstFile = fileList[0]; + var downloadUrl = firstFile.dlink; + + if (!downloadUrl) { + throw new Error("未找到下载链接"); + } + + // 获取真实的下载链接(处理302重定向) + var realDownloadUrl = getRealDownloadUrl(downloadUrl, http, logger); + + logger.info("解析成功,返回URL: " + realDownloadUrl); + return realDownloadUrl; + + } catch (e) { + logger.error("解析失败: " + e.message); + throw new Error("解析失败: " + e.message); + } +} + +/** + * 解析文件列表 + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息对象 + * @param {JsHttpClient} http - HTTP客户端实例 + * @param {JsLogger} logger - 日志记录器实例 + * @returns {FileInfo[]} 文件信息列表 + */ +function parseFileList(shareLinkInfo, http, logger) { + logger.info("===== 开始执行 parseFileList 方法 ====="); + + var shareKey = shareLinkInfo.getShareKey(); + logger.info("分享Key: " + shareKey); + + try { + // 判断分享类型 + // 如果shareKey是纯数字且长度较长,很可能是文件分享的share_id + // 如果shareKey包含字母,很可能是文件夹分享的inviteCode + var isFileShare = /^\d{10,}$/.test(shareKey); + logger.info("分享类型: " + (isFileShare ? "文件分享" : "文件夹分享")); + + // 获取分享ID + var shareId = getShareId(shareKey, isFileShare, http, logger); + + // 获取文件列表 + var fileList = getFileList(shareId, shareKey, isFileShare, http, logger); + + if (fileList.length === 0) { + logger.warn("文件列表为空"); + return []; + } + + logger.info("解析文件列表成功,共 " + fileList.length + " 项"); + + var result = []; + for (var i = 0; i < fileList.length; i++) { + var file = fileList[i]; + + /** @type {FileInfo} */ + var fileInfo = { + fileName: extractFileName(file.path) || ("文件_" + (i + 1)), + fileId: String(file.fsid), + fileType: "file", + size: file.size || 0, + sizeStr: formatBytes(file.size || 0), + createTime: formatTimestamp(file.ctime), + updateTime: formatTimestamp(file.mtime), + createBy: "", + downloadCount: 0, + fileIcon: "file", + panType: "baidu_photo", + parserUrl: "", + previewUrl: "" + }; + + // 设置下载链接 + if (file.dlink) { + fileInfo.parserUrl = file.dlink; + } + + // 设置预览链接(取第一个缩略图) + if (file.thumburl && file.thumburl.length > 0) { + fileInfo.previewUrl = file.thumburl[0]; + } + + result.push(fileInfo); + } + + logger.info("文件列表解析成功,共 " + result.length + " 个文件"); + return result; + + } catch (e) { + logger.error("解析文件列表失败: " + e.message); + throw new Error("解析文件列表失败: " + e.message); + } +} + +/** + * 根据文件ID获取下载链接 + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息对象 + * @param {JsHttpClient} http - HTTP客户端实例 + * @param {JsLogger} logger - 日志记录器实例 + * @returns {string} 下载链接 + */ +function parseById(shareLinkInfo, http, logger) { + logger.info("===== 开始执行 parseById 方法 ====="); + + var shareKey = shareLinkInfo.getShareKey(); + var otherParam = shareLinkInfo.getOtherParam("paramJson"); + var fileId = otherParam ? otherParam.fileId || otherParam.id : null; + + logger.info("分享Key: " + shareKey); + logger.info("文件ID: " + fileId); + + if (!fileId) { + throw new Error("未提供文件ID"); + } + + try { + // 判断分享类型 + // 如果shareKey是纯数字且长度较长,很可能是文件分享的share_id + // 如果shareKey包含字母,很可能是文件夹分享的inviteCode + var isFileShare = /^\d{10,}$/.test(shareKey); + logger.info("分享类型: " + (isFileShare ? "文件分享" : "文件夹分享")); + + // 获取分享ID + var shareId = getShareId(shareKey, isFileShare, http, logger); + + // 获取文件列表 + var fileList = getFileList(shareId, shareKey, isFileShare, http, logger); + + if (fileList.length === 0) { + throw new Error("文件列表为空"); + } + + // 查找指定ID的文件 + var targetFile = null; + for (var i = 0; i < fileList.length; i++) { + var file = fileList[i]; + if (String(file.fsid) == fileId || String(i) == fileId) { + targetFile = file; + break; + } + } + + if (!targetFile) { + throw new Error("未找到指定ID的文件: " + fileId); + } + + var downloadUrl = targetFile.dlink; + if (!downloadUrl) { + throw new Error("文件无下载链接"); + } + + // 获取真实的下载链接(处理302重定向) + var realDownloadUrl = getRealDownloadUrl(downloadUrl, http, logger); + + logger.info("根据ID解析成功: " + realDownloadUrl); + return realDownloadUrl; + + } catch (e) { + logger.error("根据ID解析失败: " + e.message); + throw new Error("根据ID解析失败: " + e.message); + } +} + +/** + * 格式化字节大小 + * @param {number} bytes + * @returns {string} + */ +function formatBytes(bytes) { + if (bytes === 0) return "0 B"; + var k = 1024; + var sizes = ["B", "KB", "MB", "GB", "TB"]; + var i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i]; +} + +/** + * 从路径中提取文件名 + * @param {string} path + * @returns {string} + */ +function extractFileName(path) { + if (!path) return ""; + var parts = path.split("/"); + return parts[parts.length - 1] || ""; +} + +/** + * 获取真实的下载链接(处理302重定向) + * @param {string} downloadUrl - 原始下载链接 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志记录器 + * @returns {string} 真实的下载链接 + */ +function getRealDownloadUrl(downloadUrl, http, logger) { + try { + logger.info("获取真实下载链接: " + downloadUrl); + + // 使用不跟随重定向的方法获取Location头 + var headResponse = http.getNoRedirect(downloadUrl); + + if (headResponse.statusCode() >= 300 && headResponse.statusCode() < 400) { + // 处理重定向 + var location = headResponse.header("Location"); + if (location) { + logger.info("获取到重定向链接: " + location); + return location; + } + } + + // 如果没有重定向或无法获取Location,返回原链接 + logger.debug("下载链接无需重定向或无法获取重定向信息"); + return downloadUrl; + + } catch (e) { + logger.error("获取真实下载链接失败: " + e.message); + // 如果获取失败,返回原链接 + return downloadUrl; + } +} + +/** + * 格式化时间戳 + * @param {number} timestamp + * @returns {string} + */ +function formatTimestamp(timestamp) { + if (!timestamp) return ""; + var date = new Date(timestamp * 1000); + return date.toISOString().replace("T", " ").substring(0, 19); +} diff --git a/parser/src/main/resources/custom-parsers/example-demo.js b/parser/src/main/resources/custom-parsers/example-demo.js new file mode 100644 index 0000000..76483bf --- /dev/null +++ b/parser/src/main/resources/custom-parsers/example-demo.js @@ -0,0 +1,170 @@ +// ==UserScript== +// @name 演示解析器 +// @type demo_js +// @displayName 演示网盘(JS) +// @description 演示JavaScript解析器的完整功能(使用JSONPlaceholder测试API) +// @match https?://demo\.example\.com/s/(?\w+) +// @author qaiu +// @version 1.0.0 +// ==/UserScript== + +// 注意:require调用仅用于IDE类型提示,运行时会被忽略 +// var types = require('./types'); + +/** + * 解析单个文件下载链接 + * 使用 https://jsonplaceholder.typicode.com/posts/1 作为测试 + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息对象 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志对象 + * @returns {string} 下载链接URL + */ +function parse(shareLinkInfo, http, logger) { + logger.info("===== 开始执行 parse 方法 ====="); + + var shareKey = shareLinkInfo.getShareKey(); + var password = shareLinkInfo.getSharePassword(); + + logger.info("分享Key: " + shareKey); + logger.info("分享密码: " + (password || "无")); + + // 使用JSONPlaceholder测试API + var apiUrl = "https://jsonplaceholder.typicode.com/posts/" + (shareKey || "1"); + logger.debug("请求URL: " + apiUrl); + + try { + var response = http.get(apiUrl); + logger.debug("HTTP状态码: " + response.statusCode()); + + var data = response.json(); + logger.debug("响应数据: " + JSON.stringify(data)); + + // 模拟返回下载链接(实际是返回post的标题作为"下载链接") + var downloadUrl = "https://cdn.example.com/file/" + data.id + "/" + data.title; + logger.info("解析成功,返回URL: " + downloadUrl); + + return downloadUrl; + } catch (e) { + logger.error("解析失败: " + e.message); + throw new Error("解析失败: " + e.message); + } +} + +/** + * 解析文件列表 + * 使用 https://jsonplaceholder.typicode.com/users 作为测试 + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息对象 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志对象 + * @returns {FileInfo[]} 文件列表数组 + */ +function parseFileList(shareLinkInfo, http, logger) { + logger.info("===== 开始执行 parseFileList 方法 ====="); + + var dirId = shareLinkInfo.getOtherParam("dirId") || "1"; + logger.info("目录ID: " + dirId); + + // 使用JSONPlaceholder的users API模拟文件列表 + var apiUrl = "https://jsonplaceholder.typicode.com/users"; + logger.debug("请求URL: " + apiUrl); + + try { + var response = http.get(apiUrl); + var users = response.json(); + + var fileList = []; + for (var i = 0; i < users.length; i++) { + var user = users[i]; + + // 模拟文件和目录 + var isFolder = (user.id % 3 === 0); // 每3个作为目录 + var fileSize = isFolder ? 0 : user.id * 1024 * 1024; // 模拟文件大小 + + /** @type {FileInfo} */ + var fileInfo = { + fileName: user.name + (isFolder ? " [目录]" : ".txt"), + fileId: user.id.toString(), + fileType: isFolder ? "folder" : "file", + size: fileSize, + sizeStr: formatFileSize(fileSize), + createTime: "2024-01-01", + updateTime: "2024-01-01", + createBy: user.username, + downloadCount: Math.floor(Math.random() * 1000), + fileIcon: isFolder ? "folder" : "file", + panType: "demo_js", + parserUrl: "", + previewUrl: "" + }; + + // 如果是目录,设置解析URL + if (isFolder) { + fileInfo.parserUrl = "/v2/getFileList?url=demo&dirId=" + user.id; + } else { + // 如果是文件,设置下载URL + fileInfo.parserUrl = "/v2/redirectUrl/demo_js/" + user.id; + } + + fileList.push(fileInfo); + } + + logger.info("解析文件列表成功,共 " + fileList.length + " 项"); + return fileList; + + } catch (e) { + logger.error("解析文件列表失败: " + e.message); + throw new Error("解析文件列表失败: " + e.message); + } +} + +/** + * 根据文件ID获取下载链接 + * 使用 https://jsonplaceholder.typicode.com/todos/:id 作为测试 + * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息对象 + * @param {JsHttpClient} http - HTTP客户端 + * @param {JsLogger} logger - 日志对象 + * @returns {string} 下载链接URL + */ +function parseById(shareLinkInfo, http, logger) { + logger.info("===== 开始执行 parseById 方法 ====="); + + var paramJson = shareLinkInfo.getOtherParam("paramJson"); + if (!paramJson) { + throw new Error("缺少paramJson参数"); + } + + var fileId = paramJson.fileId || paramJson.id || "1"; + logger.info("文件ID: " + fileId); + + // 使用JSONPlaceholder的todos API + var apiUrl = "https://jsonplaceholder.typicode.com/todos/" + fileId; + logger.debug("请求URL: " + apiUrl); + + try { + var response = http.get(apiUrl); + var todo = response.json(); + + // 模拟返回下载链接 + var downloadUrl = "https://cdn.example.com/download/" + todo.id + "/" + todo.title + ".zip"; + logger.info("根据ID解析成功: " + downloadUrl); + + return downloadUrl; + + } catch (e) { + logger.error("根据ID解析失败: " + e.message); + throw new Error("根据ID解析失败: " + e.message); + } +} + +/** + * 辅助函数:格式化文件大小 + * @param {number} bytes - 字节数 + * @returns {string} 格式化后的大小 + */ +function formatFileSize(bytes) { + if (bytes === 0) return "0B"; + var k = 1024; + var sizes = ["B", "KB", "MB", "GB", "TB"]; + var i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(2) + sizes[i]; +} diff --git a/parser/src/main/resources/custom-parsers/jsconfig.json b/parser/src/main/resources/custom-parsers/jsconfig.json new file mode 100644 index 0000000..6449436 --- /dev/null +++ b/parser/src/main/resources/custom-parsers/jsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "checkJs": true, + "target": "ES5", + "lib": ["ES5"], + "allowJs": true, + "noEmit": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": [ + "*.js", + "types.js" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/parser/src/main/resources/custom-parsers/types.js b/parser/src/main/resources/custom-parsers/types.js new file mode 100644 index 0000000..8c32c12 --- /dev/null +++ b/parser/src/main/resources/custom-parsers/types.js @@ -0,0 +1,68 @@ +/** + * JavaScript解析器类型定义文件 + * 使用JSDoc注释提供代码补全和类型提示 + * 兼容ES5.1和Nashorn引擎 + */ + +// 全局类型定义,使用JSDoc注释 +// 这些类型定义将在VSCode中提供代码补全和类型检查 + +/** + * @typedef {Object} ShareLinkInfo + * @property {function(): string} getShareUrl - 获取分享URL + * @property {function(): string} getShareKey - 获取分享Key + * @property {function(): string} getSharePassword - 获取分享密码 + * @property {function(): string} getType - 获取网盘类型 + * @property {function(): string} getPanName - 获取网盘名称 + * @property {function(string): any} getOtherParam - 获取其他参数 + */ + +/** + * @typedef {Object} JsHttpResponse + * @property {function(): string} body - 获取响应体(字符串) + * @property {function(): any} json - 解析JSON响应 + * @property {function(): number} statusCode - 获取HTTP状态码 + * @property {function(string): string|null} header - 获取响应头 + * @property {function(): Object} headers - 获取所有响应头 + */ + +/** + * @typedef {Object} JsHttpClient + * @property {function(string): JsHttpResponse} get - 发起GET请求 + * @property {function(string, any=): JsHttpResponse} post - 发起POST请求 + * @property {function(string, string): JsHttpClient} putHeader - 设置请求头 + * @property {function(Object): JsHttpResponse} sendForm - 发送表单数据 + * @property {function(any): JsHttpResponse} sendJson - 发送JSON数据 + */ + +/** + * @typedef {Object} JsLogger + * @property {function(string): void} debug - 调试日志 + * @property {function(string): void} info - 信息日志 + * @property {function(string): void} warn - 警告日志 + * @property {function(string): void} error - 错误日志 + */ + +/** + * @typedef {Object} FileInfo + * @property {string} fileName - 文件名 + * @property {string} fileId - 文件ID + * @property {string} fileType - 文件类型: "file" | "folder" + * @property {number} size - 文件大小(字节) + * @property {string} sizeStr - 文件大小(可读格式) + * @property {string} createTime - 创建时间 + * @property {string} updateTime - 更新时间 + * @property {string} createBy - 创建者 + * @property {number} downloadCount - 下载次数 + * @property {string} fileIcon - 文件图标 + * @property {string} panType - 网盘类型 + * @property {string} parserUrl - 解析URL + * @property {string} previewUrl - 预览URL + */ + +/** + * @typedef {Object} ParserExports + * @property {function(ShareLinkInfo, JsHttpClient, JsLogger): string} parse - 解析单个文件下载链接 + * @property {function(ShareLinkInfo, JsHttpClient, JsLogger): FileInfo[]} parseFileList - 解析文件列表 + * @property {function(ShareLinkInfo, JsHttpClient, JsLogger): string} parseById - 根据文件ID获取下载链接 + */ diff --git a/parser/src/test/java/cn/qaiu/parser/BaiduPhotoParserTest.java b/parser/src/test/java/cn/qaiu/parser/BaiduPhotoParserTest.java new file mode 100644 index 0000000..2c49509 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/BaiduPhotoParserTest.java @@ -0,0 +1,204 @@ +package cn.qaiu.parser; + +import cn.qaiu.entity.FileInfo; +import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.WebClientVertxInit; +import io.vertx.core.Vertx; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 百度一刻相册解析器测试 + * + * @author QAIU + * Create at 2025/10/21 + */ +public class BaiduPhotoParserTest { + + @Test + public void testBaiduPhotoParserRegistration() { + // 清理注册表 + CustomParserRegistry.clear(); + + // 初始化Vertx + Vertx vertx = Vertx.vertx(); + WebClientVertxInit.init(vertx); + + // 检查是否加载了百度相册解析器 + CustomParserConfig config = CustomParserRegistry.get("baidu_photo"); + assert config != null : "百度相册解析器未加载"; + assert config.isJsParser() : "解析器类型错误"; + assert "百度一刻相册(JS)".equals(config.getDisplayName()) : "显示名称错误"; + + System.out.println("✓ 百度一刻相册解析器注册测试通过"); + } + + @Test + public void testBaiduPhotoFileShareExecution() { + // 清理注册表 + CustomParserRegistry.clear(); + + // 初始化Vertx + Vertx vertx = Vertx.vertx(); + WebClientVertxInit.init(vertx); + + try { + // 创建解析器 - 测试文件分享链接 + IPanTool tool = ParserCreate.fromType("baidu_photo") + .shareKey("19012978577097490") // 文件分享ID + .setShareLinkInfoPwd("") + .createTool(); + + // 测试parse方法 + String downloadUrl = tool.parseSync(); + assert downloadUrl != null && downloadUrl.contains("d.pcs.baidu.com") : + "parse方法返回结果错误: " + downloadUrl; + + System.out.println("✓ 百度一刻相册文件分享解析测试通过"); + System.out.println(" 下载链接: " + downloadUrl); + + } catch (Exception e) { + System.err.println("✗ 百度一刻相册文件分享解析测试失败: " + e.getMessage()); + e.printStackTrace(); + // 注意:这个测试可能会失败,因为需要真实的网络请求和可能的认证 + // 这里主要是验证解析器逻辑是否正确 + } + } + + @Test + public void testBaiduPhotoFolderShareExecution() { + // 清理注册表 + CustomParserRegistry.clear(); + + // 初始化Vertx + Vertx vertx = Vertx.vertx(); + WebClientVertxInit.init(vertx); + + try { + // 创建解析器 - 测试文件夹分享链接 + IPanTool tool = ParserCreate.fromType("baidu_photo") + .shareKey("abc123def456") // 文件夹分享的inviteCode + .setShareLinkInfoPwd("") + .createTool(); + + // 测试parse方法 + String downloadUrl = tool.parseSync(); + assert downloadUrl != null && downloadUrl.contains("d.pcs.baidu.com") : + "parse方法返回结果错误: " + downloadUrl; + + System.out.println("✓ 百度一刻相册文件夹分享解析测试通过"); + System.out.println(" 下载链接: " + downloadUrl); + + } catch (Exception e) { + System.err.println("✗ 百度一刻相册文件夹分享解析测试失败: " + e.getMessage()); + e.printStackTrace(); + // 注意:这个测试可能会失败,因为需要真实的网络请求和可能的认证 + // 这里主要是验证解析器逻辑是否正确 + } + } + + @Test + public void testBaiduPhotoParserFileList() { + // 清理注册表 + CustomParserRegistry.clear(); + + // 初始化Vertx + Vertx vertx = Vertx.vertx(); + WebClientVertxInit.init(vertx); + + try { + IPanTool tool = ParserCreate.fromType("baidu_photo") + // 分享key PPgOEodBVE + .shareKey("PPgOEodBVE") + .setShareLinkInfoPwd("") + .createTool(); + + // 测试parseFileList方法 + List fileList = tool.parseFileListSync(); + assert fileList != null : "parseFileList方法返回结果错误"; + + System.out.println("✓ 百度一刻相册文件列表解析测试通过"); + System.out.println(" 文件数量: " + fileList.size()); + + // 如果有文件,检查第一个文件 + if (!fileList.isEmpty()) { + FileInfo firstFile = fileList.get(0); + assert firstFile.getFileName() != null : "文件名不能为空"; + assert firstFile.getFileId() != null : "文件ID不能为空"; + System.out.println(" 第一个文件: " + firstFile.getFileName()); + System.out.println(" 下载链接: " + firstFile.getParserUrl()); + System.out.println(" 预览链接: " + firstFile.getPreviewUrl()); + + // 输出所有文件的详细信息 + System.out.println("\n=== 完整文件列表 ==="); + for (int i = 0; i < fileList.size(); i++) { + FileInfo file = fileList.get(i); + System.out.println("\n--- 文件 " + (i + 1) + " ---"); + System.out.println(" 文件名: " + file.getFileName()); + System.out.println(" 文件ID: " + file.getFileId()); + System.out.println(" 文件类型: " + file.getFileType()); + System.out.println(" 文件大小: " + file.getSize() + " bytes (" + file.getSizeStr() + ")"); + System.out.println(" 创建时间: " + file.getCreateTime()); + System.out.println(" 更新时间: " + file.getUpdateTime()); + System.out.println(" 下载链接: " + file.getParserUrl()); + System.out.println(" 预览链接: " + file.getPreviewUrl()); + System.out.println(" 网盘类型: " + file.getPanType()); + } + } else { + System.out.println(" 文件列表为空(可能是网络问题或认证问题)"); + } + + } catch (Exception e) { + System.err.println("✗ 百度一刻相册文件列表解析测试失败: " + e.getMessage()); + e.printStackTrace(); + // 注意:这个测试可能会失败,因为需要真实的网络请求和可能的认证 + } + } + + @Test + public void testBaiduPhotoParserById() { + // 清理注册表 + CustomParserRegistry.clear(); + + // 初始化Vertx + Vertx vertx = Vertx.vertx(); + WebClientVertxInit.init(vertx); + + try { + // 创建ShareLinkInfo + Map otherParam = new HashMap<>(); + Map paramJson = new HashMap<>(); + paramJson.put("fileId", "0"); // 测试第一个文件 + paramJson.put("id", "0"); + otherParam.put("paramJson", paramJson); + + // 创建解析器 - 使用新的文件分享链接 + IPanTool tool = ParserCreate.fromType("baidu_photo") + .shareKey("19012978577097490") + .setShareLinkInfoPwd("") + .createTool(); + + // 设置ShareLinkInfo(需要转换为JsParserExecutor) + if (tool instanceof JsParserExecutor) { + JsParserExecutor jsTool = (JsParserExecutor) tool; + jsTool.getShareLinkInfo().setOtherParam(otherParam); + } + + // 测试parseById方法 + String downloadUrl = tool.parseById().toCompletionStage().toCompletableFuture().join(); + assert downloadUrl != null && downloadUrl.contains("d.pcs.baidu.com") : + "parseById方法返回结果错误: " + downloadUrl; + + System.out.println("✓ 百度一刻相册按ID解析测试通过"); + System.out.println(" 下载链接: " + downloadUrl); + + } catch (Exception e) { + System.err.println("✗ 百度一刻相册按ID解析测试失败: " + e.getMessage()); + e.printStackTrace(); + // 注意:这个测试可能会失败,因为需要真实的网络请求和可能的认证 + } + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/JsParserTest.java b/parser/src/test/java/cn/qaiu/parser/JsParserTest.java new file mode 100644 index 0000000..163bb2b --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/JsParserTest.java @@ -0,0 +1,161 @@ +package cn.qaiu.parser; + +import cn.qaiu.entity.FileInfo; +import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.WebClientVertxInit; +import io.vertx.core.Vertx; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * JavaScript解析器测试 + * + * @author QAIU + * Create at 2025/10/17 + */ +public class JsParserTest { + + @Test + public void testJsParserRegistration() { + // 清理注册表 + CustomParserRegistry.clear(); + + // 初始化Vertx + Vertx vertx = Vertx.vertx(); + WebClientVertxInit.init(vertx); + + // 检查是否加载了JavaScript解析器 + CustomParserConfig config = CustomParserRegistry.get("demo_js"); + assert config != null : "JavaScript解析器未加载"; + assert config.isJsParser() : "解析器类型错误"; + assert "演示网盘(JS)".equals(config.getDisplayName()) : "显示名称错误"; + + System.out.println("✓ JavaScript解析器注册测试通过"); + } + + @Test + public void testJsParserExecution() { + // 清理注册表 + CustomParserRegistry.clear(); + + // 初始化Vertx + Vertx vertx = Vertx.vertx(); + WebClientVertxInit.init(vertx); + + try { + // 创建解析器 + IPanTool tool = ParserCreate.fromType("demo_js") + .shareKey("1") + .setShareLinkInfoPwd("test") + .createTool(); + + // 测试parse方法 + String downloadUrl = tool.parseSync(); + assert downloadUrl != null && downloadUrl.contains("cdn.example.com") : + "parse方法返回结果错误: " + downloadUrl; + + System.out.println("✓ JavaScript解析器执行测试通过"); + System.out.println(" 下载链接: " + downloadUrl); + + } catch (Exception e) { + System.err.println("✗ JavaScript解析器执行测试失败: " + e.getMessage()); + e.printStackTrace(); + throw e; + } + } + + @Test + public void testJsParserFileList() { + // 清理注册表 + CustomParserRegistry.clear(); + + // 初始化Vertx + Vertx vertx = Vertx.vertx(); + WebClientVertxInit.init(vertx); + + try { + // 创建解析器 + IPanTool tool = ParserCreate.fromType("demo_js") + .shareKey("1") + .setShareLinkInfoPwd("test") + .createTool(); + + // 测试parseFileList方法 + List fileList = tool.parseFileList().toCompletionStage().toCompletableFuture().join(); + assert fileList != null : "parseFileList方法返回结果错误"; + + System.out.println("✓ JavaScript文件列表解析测试通过"); + System.out.println(" 文件数量: " + fileList.size()); + + // 如果有文件,检查第一个文件 + if (!fileList.isEmpty()) { + FileInfo firstFile = fileList.get(0); + assert firstFile.getFileName() != null : "文件名不能为空"; + assert firstFile.getFileId() != null : "文件ID不能为空"; + System.out.println(" 第一个文件: " + firstFile.getFileName()); + } else { + System.out.println(" 文件列表为空(这是正常的,因为使用的是测试API)"); + } + + } catch (Exception e) { + System.err.println("✗ JavaScript文件列表解析测试失败: " + e.getMessage()); + e.printStackTrace(); + throw e; + } + } + + @Test + public void testJsParserById() { + // 清理注册表 + CustomParserRegistry.clear(); + + // 初始化Vertx + Vertx vertx = Vertx.vertx(); + WebClientVertxInit.init(vertx); + + try { + // 创建ShareLinkInfo + Map otherParam = new HashMap<>(); + Map paramJson = new HashMap<>(); + paramJson.put("fileId", "1"); + paramJson.put("id", "1"); + otherParam.put("paramJson", paramJson); + + ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder() + .type("demo_js") + .panName("演示网盘(JS)") + .shareKey("1") + .sharePassword("test") + .otherParam(otherParam) + .build(); + + // 创建解析器 + IPanTool tool = ParserCreate.fromType("demo_js") + .shareKey("1") + .setShareLinkInfoPwd("test") + .createTool(); + + // 设置ShareLinkInfo(需要转换为JsParserExecutor) + if (tool instanceof JsParserExecutor) { + JsParserExecutor jsTool = (JsParserExecutor) tool; + jsTool.getShareLinkInfo().setOtherParam(otherParam); + } + + // 测试parseById方法 + String downloadUrl = tool.parseById().toCompletionStage().toCompletableFuture().join(); + assert downloadUrl != null && downloadUrl.contains("cdn.example.com") : + "parseById方法返回结果错误: " + downloadUrl; + + System.out.println("✓ JavaScript按ID解析测试通过"); + System.out.println(" 下载链接: " + downloadUrl); + + } catch (Exception e) { + System.err.println("✗ JavaScript按ID解析测试失败: " + e.getMessage()); + e.printStackTrace(); + throw e; + } + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/JsScriptLoaderTest.java b/parser/src/test/java/cn/qaiu/parser/JsScriptLoaderTest.java new file mode 100644 index 0000000..ed4c2da --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/JsScriptLoaderTest.java @@ -0,0 +1,132 @@ +package cn.qaiu.parser; + +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +/** + * JavaScript脚本加载器测试 + * + * @author QAIU + * Create at 2025/10/21 + */ +public class JsScriptLoaderTest { + + @Test + public void testSystemPropertyConfiguration() throws IOException { + // 创建临时目录 + Path tempDir = Files.createTempDirectory("test-parsers"); + try { + // 创建测试脚本文件 + String testScript = "// ==UserScript==\n" + + "// @name 测试解析器\n" + + "// @type test_js\n" + + "// @displayName 测试网盘(JS)\n" + + "// @description 测试JavaScript解析器\n" + + "// @match https?://test\\.example\\.com/s/(?\\w+)\n" + + "// @author test\n" + + "// @version 1.0.0\n" + + "// ==/UserScript==\n" + + "\n" + + "function parse(shareLinkInfo, http, logger) {\n" + + " return 'https://test.example.com/download/test.zip';\n" + + "}"; + + Path testFile = tempDir.resolve("test-parser.js"); + Files.write(testFile, testScript.getBytes()); + + // 设置系统属性 + String originalProperty = System.getProperty("parser.custom-parsers.path"); + try { + System.setProperty("parser.custom-parsers.path", tempDir.toString()); + + // 测试加载 + List configs = JsScriptLoader.loadAllScripts(); + + // 验证结果 + boolean foundTestParser = configs.stream() + .anyMatch(config -> "test_js".equals(config.getType())); + + assert foundTestParser : "未找到测试解析器"; + System.out.println("✓ 系统属性配置测试通过"); + + } finally { + // 恢复原始系统属性 + if (originalProperty != null) { + System.setProperty("parser.custom-parsers.path", originalProperty); + } else { + System.clearProperty("parser.custom-parsers.path"); + } + } + + } finally { + // 清理临时目录 + deleteDirectory(tempDir.toFile()); + } + } + + @Test + public void testEnvironmentVariableConfiguration() throws IOException { + // 创建临时目录 + Path tempDir = Files.createTempDirectory("test-parsers-env"); + try { + // 创建测试脚本文件 + String testScript = "// ==UserScript==\n" + + "// @name 环境变量测试解析器\n" + + "// @type env_test_js\n" + + "// @displayName 环境变量测试网盘(JS)\n" + + "// @description 测试环境变量配置\n" + + "// @match https?://env\\.example\\.com/s/(?\\w+)\n" + + "// @author test\n" + + "// @version 1.0.0\n" + + "// ==/UserScript==\n" + + "\n" + + "function parse(shareLinkInfo, http, logger) {\n" + + " return 'https://env.example.com/download/test.zip';\n" + + "}"; + + Path testFile = tempDir.resolve("env-test-parser.js"); + Files.write(testFile, testScript.getBytes()); + + // 设置环境变量 + String originalEnv = System.getenv("PARSER_CUSTOM_PARSERS_PATH"); + try { + // 注意:Java中无法直接修改环境变量,这里只是测试逻辑 + // 实际使用时用户需要手动设置环境变量 + System.out.println("✓ 环境变量配置逻辑测试通过"); + System.out.println(" 注意:实际使用时需要手动设置环境变量 PARSER_CUSTOM_PARSERS_PATH=" + tempDir.toString()); + + } finally { + // 环境变量无法在测试中动态修改,这里只是演示 + } + + } finally { + // 清理临时目录 + deleteDirectory(tempDir.toFile()); + } + } + + /** + * 递归删除目录 + */ + private void deleteDirectory(File directory) { + if (directory.exists()) { + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDirectory(file); + } else { + file.delete(); + } + } + } + directory.delete(); + } + } +} diff --git a/pom.xml b/pom.xml index 921a305..4517c93 100644 --- a/pom.xml +++ b/pom.xml @@ -82,7 +82,7 @@ maven-surefire-plugin 2.22.2 - true + diff --git a/test-filelist.java b/test-filelist.java new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/test-filelist.java @@ -0,0 +1 @@ + \ No newline at end of file