From 4fc4ed8640befa976d10fbbae84002de09852278 Mon Sep 17 00:00:00 2001 From: q Date: Mon, 20 Oct 2025 13:33:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20WPS=20=E4=BA=91?= =?UTF-8?q?=E6=96=87=E6=A1=A3/WPS=20=E4=BA=91=E7=9B=98=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20(closes=20#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PwpsTool 解析器,支持 WPS 云文档直链获取 - 调用 WPS API: https://www.kdocs.cn/api/office/file/{shareKey}/download - 前端添加 kdocs.cn 链接识别规则 - 前端预览功能优化:WPS 云文档直接使用原分享链接预览 - 后端预览接口特殊处理:判断 shareKey 以 pwps: 开头自动重定向 - 支持提取文件名和有效期信息 - 更新 README 文档,添加 WPS 云文档支持说明 Parser 模块设计: - 遵循开放封闭原则,易于扩展新网盘 - 只需实现 IPanTool 接口和注册枚举即可 - 支持自定义域名解析和责任链模式 技术特性: - 免登录获取下载直链 - 支持在线预览(利用 WPS 原生功能) - 文件大小限制:10M(免费版)/2G(会员版) - 初始空间:5G(免费版) --- README.md | 4 + parser/README.md | 1 - .../cn/qaiu/parser/PanDomainTemplate.java | 6 + .../java/cn/qaiu/parser/impl/PwpsTool.java | 63 +++++++ .../java/cn/qaiu/parser/impl/WpsPanTest.java | 156 ++++++++++++++++++ web-front/src/parserUrl1.js | 5 + web-front/src/views/Home.vue | 14 +- web-front/src/views/ShowFile.vue | 16 +- .../cn/qaiu/lz/web/controller/ParserApi.java | 33 ++++ 9 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 parser/src/main/java/cn/qaiu/parser/impl/PwpsTool.java create mode 100644 parser/src/test/java/cn/qaiu/parser/impl/WpsPanTest.java diff --git a/README.md b/README.md index 5449e6c..4df994c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu. ``` +**解析器模块文档:** [parser/README.md](parser/README.md) + ## 预览地址 [预览地址1](https://lz.qaiu.top) [预览地址2](http://www.722shop.top:6401) @@ -75,6 +77,7 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/ - [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve) - ~[微雨云存储-pvvy](https://www.vyuyun.com/)~ - [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com) +- [WPS云文档-pwps](https://www.kdocs.cn/) - Google云盘-pgd - Onedrive-pod - Dropbox-pdp @@ -258,6 +261,7 @@ GET http://127.0.0.1:6400/json/fc/e5079007dc31226096628870c7@QAIU | 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 | | 123云盘 | √ | √ | 2T | 100G(>100M需要登录) | | 文叔叔 | √ | √ | 10G | 5GB | +| WPS云文档 | √ | X | 1G(免费) | 10M(免费)/2G(会员) | | 夸克网盘 | x | √ | 10G | 不限大小 | | UC网盘 | x | √ | 10G | 不限大小 | diff --git a/parser/README.md b/parser/README.md index 1f3b786..c6f19db 100644 --- a/parser/README.md +++ b/parser/README.md @@ -7,7 +7,6 @@ NFD 解析器模块:聚合各类网盘/分享页解析,统一输出文件列 - 模块版本:10.1.17 ## 依赖(Maven Central) -- Maven(无需额外仓库配置): ```xml cn.qaiu diff --git a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java index 0af332f..f35d83c 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java +++ b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java @@ -266,6 +266,12 @@ public enum PanDomainTemplate { compile("https://pan-yz\\.cldisk\\.com/external/m/file/(?\\w+)"), "https://pan-yz.cldisk.com/external/m/file/{shareKey}", PcxTool.class), + // WPS:分享格式:https://www.kdocs.cn/l/ck0azivLlDi3 ;API格式:https://www.kdocs.cn/api/office/file/{shareKey}/download + // 响应:{download_url: "https://hwc-bj.ag.kdocs.cn/api/xx",url: "",fize: 0,fver: 0,store: ""} + PWPS("WPS", + compile("https://www\\.kdocs\\.cn/l/(?.+)"), + "https://www.kdocs.cn/l/{shareKey}", + PwpsTool.class), // =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)========================== // http://163cn.tv/xxx MNES("网易云音乐分享", diff --git a/parser/src/main/java/cn/qaiu/parser/impl/PwpsTool.java b/parser/src/main/java/cn/qaiu/parser/impl/PwpsTool.java new file mode 100644 index 0000000..afbe179 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/impl/PwpsTool.java @@ -0,0 +1,63 @@ +package cn.qaiu.parser.impl; + +import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.parser.PanBase; +import io.vertx.core.Future; +import io.vertx.core.json.JsonObject; + +/** + * WPS云文档 + * 分享格式:https://www.kdocs.cn/l/ck0azivLlDi3 + * API格式:https://www.kdocs.cn/api/office/file/{shareKey}/download + * 响应:{download_url: "https://hwc-bj.ag.kdocs.cn/api/xx",url: "",fize: 0,fver: 0,store: ""} + */ +public class PwpsTool extends PanBase { + private static final String API_URL_TEMPLATE = "https://www.kdocs.cn/api/office/file/%s/download"; + + public PwpsTool(ShareLinkInfo shareLinkInfo) { + super(shareLinkInfo); + } + + @Override + public Future parse() { + final String shareKey = shareLinkInfo.getShareKey(); + + // 构建API URL + String apiUrl = String.format(API_URL_TEMPLATE, shareKey); + + // 发送GET请求到WPS API + client.getAbs(apiUrl) + .send() + .onSuccess(res -> { + try { + JsonObject resJson = asJson(res); + + // 检查响应是否包含download_url字段 + if (resJson.containsKey("download_url")) { + String downloadUrl = resJson.getString("download_url"); + + if (downloadUrl != null && !downloadUrl.isEmpty()) { + log.info("WPS云文档解析成功: shareKey={}, downloadUrl={}", shareKey, downloadUrl); + promise.complete(downloadUrl); + } else { + fail("download_url字段为空"); + } + } else { + // 检查是否有错误信息 + if (resJson.containsKey("error") || resJson.containsKey("msg")) { + String errorMsg = resJson.getString("error", resJson.getString("msg", "未知错误")); + fail("API返回错误: {}", errorMsg); + } else { + fail("响应中未找到download_url字段, 响应内容: {}", resJson.encodePrettily()); + } + } + } catch (Exception e) { + fail(e, "解析响应JSON失败"); + } + }) + .onFailure(handleFail(apiUrl)); + + return promise.future(); + } +} + diff --git a/parser/src/test/java/cn/qaiu/parser/impl/WpsPanTest.java b/parser/src/test/java/cn/qaiu/parser/impl/WpsPanTest.java new file mode 100644 index 0000000..b9a5b15 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/impl/WpsPanTest.java @@ -0,0 +1,156 @@ +package cn.qaiu.parser.impl; + +import org.junit.Test; + +import cn.qaiu.parser.ParserCreate; +import io.vertx.core.json.JsonObject; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * WPS 云文档解析测试 + */ +public class WpsPanTest { + + @Test + public void testWpsDownload() throws InterruptedException { + System.out.println("======= WPS 云文档解析测试 ======="); + + // 测试链接:reset_navicat_mac + String wpsUrl = "https://www.kdocs.cn/l/ck0azivLlDi3"; + + System.out.println("测试链接: " + wpsUrl); + System.out.println("文件名称: reset_navicat_mac"); + System.out.println(); + + // 使用 ParserCreate 方式创建解析器 + ParserCreate parserCreate = ParserCreate.fromShareUrl(wpsUrl); + + System.out.println("解析器类型: " + parserCreate.getShareLinkInfo().getType()); + System.out.println("网盘名称: " + parserCreate.getShareLinkInfo().getPanName()); + System.out.println("分享Key: " + parserCreate.getShareLinkInfo().getShareKey()); + System.out.println("标准URL: " + parserCreate.getShareLinkInfo().getStandardUrl()); + System.out.println(); + + System.out.println("开始解析下载链接..."); + + // 创建工具并解析 + parserCreate.createTool() + .parse() + .onSuccess(downloadUrl -> { + System.out.println("✓ 解析成功!"); + System.out.println("下载直链: " + downloadUrl); + System.out.println(); + + // 解析文件信息 + JsonObject fileInfo = getFileInfo(downloadUrl); + System.out.println("文件信息: " + fileInfo.encodePrettily()); + System.out.println(); + }) + .onFailure(error -> { + System.err.println("✗ 解析失败!"); + System.err.println("错误信息: " + error.getMessage()); + error.printStackTrace(); + }); + + // 等待异步结果 + System.out.println("等待解析结果..."); + TimeUnit.SECONDS.sleep(10); + + System.out.println("======= 测试结束 ======="); + } + + @Test + public void testWpsWithShareKey() throws InterruptedException { + System.out.println("======= WPS 云文档解析测试 (使用 shareKey) ======="); + + String shareKey = "ck0azivLlDi3"; + + System.out.println("分享Key: " + shareKey); + System.out.println(); + + // 使用 fromType + shareKey 方式 + ParserCreate parserCreate = ParserCreate.fromType("pwps") + .shareKey(shareKey); + + System.out.println("解析器类型: " + parserCreate.getShareLinkInfo().getType()); + System.out.println("网盘名称: " + parserCreate.getShareLinkInfo().getPanName()); + System.out.println("标准URL: " + parserCreate.getShareLinkInfo().getStandardUrl()); + System.out.println(); + + System.out.println("开始解析下载链接..."); + + // 创建工具并解析 + parserCreate.createTool() + .parse() + .onSuccess(downloadUrl -> { + System.out.println("✓ 解析成功!"); + System.out.println("下载直链: " + downloadUrl); + System.out.println(); + + // 解析文件信息 + JsonObject fileInfo = getFileInfo(downloadUrl); + System.out.println("文件信息: " + fileInfo.encodePrettily()); + System.out.println(); + }) + .onFailure(error -> { + System.err.println("✗ 解析失败!"); + System.err.println("错误信息: " + error.getMessage()); + error.printStackTrace(); + }); + + // 等待异步结果 + System.out.println("等待解析结果..."); + TimeUnit.SECONDS.sleep(10); + + System.out.println("======= 测试结束 ======="); + } + + /** + * 从 WPS 下载直链中提取文件信息 + * 示例链接: https://hwc-bj.ag.kdocs.cn/api/object/xxx/compatible?response-content-disposition=attachment%3Bfilename%2A%3Dutf-8%27%27reset_navicat_mac.sh&AccessKeyId=xxx&Expires=1760928746&Signature=xxx + * + * @param downloadUrl WPS 下载直链 + * @return JSON 格式的文件信息 {fileName: "reset_navicat_mac.sh", expire: "2025-10-20 10:45:46"} + */ + private JsonObject getFileInfo(String downloadUrl) { + String fileName = "未知文件"; + String expireTime = "未知"; + + try { + // 1. 提取文件名 - 从 response-content-disposition 参数中提取 + // 格式: attachment%3Bfilename%2A%3Dutf-8%27%27reset_navicat_mac.sh + // 解码后: attachment;filename*=utf-8''reset_navicat_mac.sh + Pattern fileNamePattern = Pattern.compile("filename[^=]*=(?:utf-8'')?([^&]+)"); + Matcher fileNameMatcher = fileNamePattern.matcher(URLDecoder.decode(downloadUrl, StandardCharsets.UTF_8)); + if (fileNameMatcher.find()) { + fileName = fileNameMatcher.group(1); + // 再次解码(可能被双重编码) + fileName = URLDecoder.decode(fileName, StandardCharsets.UTF_8); + } + + // 2. 提取有效期 - 从 Expires 参数中提取 Unix timestamp + Pattern expiresPattern = Pattern.compile("[?&]Expires=([0-9]+)"); + Matcher expiresMatcher = expiresPattern.matcher(downloadUrl); + if (expiresMatcher.find()) { + long timestamp = Long.parseLong(expiresMatcher.group(1)); + // 转换为日期格式 yyyy-MM-dd HH:mm:ss + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + expireTime = sdf.format(new Date(timestamp * 1000L)); // Unix timestamp 是秒,需要转毫秒 + } + + } catch (Exception e) { + System.err.println("解析文件信息失败: " + e.getMessage()); + e.printStackTrace(); + } + + return JsonObject.of("fileName", fileName, "expire", expireTime); + } +} + diff --git a/web-front/src/parserUrl1.js b/web-front/src/parserUrl1.js index 48ba9e9..624e6dc 100644 --- a/web-front/src/parserUrl1.js +++ b/web-front/src/parserUrl1.js @@ -340,6 +340,11 @@ host: /migu\.cn/, name: '咪咕音乐分享' }, + kdocs: { + reg: /https:\/\/www\.kdocs\.cn\/l\/.+/, + host: /www\.kdocs\.cn/, + name: 'WPS云文档' + }, other: { reg: /https:\/\/([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}\/s\/.+/, host: /.*/, diff --git a/web-front/src/views/Home.vue b/web-front/src/views/Home.vue index 34b7749..54c11c5 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -110,7 +110,7 @@
文件预览: - 点击预览 + 点击预览
文件名:{{ extractFileNameAndExt(downloadUrl).name }} @@ -299,6 +299,18 @@ export default { } }, methods: { + // 生成预览链接(WPS 云文档特殊处理) + getPreviewLink() { + // 判断 shareKey 是否以 pwps: 开头(WPS 云文档) + const shareKey = this.parseResult?.data?.shareKey + if (shareKey && shareKey.startsWith('pwps:')) { + // WPS 云文档直接使用原始分享链接 + return this.link + } + // 其他类型使用默认预览服务 + return this.previewBaseUrl + encodeURIComponent(this.downloadUrl) + }, + // 主题切换 handleThemeChange(isDark) { this.isDarkMode = isDark diff --git a/web-front/src/views/ShowFile.vue b/web-front/src/views/ShowFile.vue index e3340ff..37d8436 100644 --- a/web-front/src/views/ShowFile.vue +++ b/web-front/src/views/ShowFile.vue @@ -20,7 +20,7 @@
@@ -42,11 +42,24 @@ export default { error: '', parseResult: {}, downloadUrl: '', + shareUrl: '', // 添加原始分享链接 fileTypeUtils, previewBaseUrl } }, methods: { + // 生成预览链接(WPS 云文档特殊处理) + getPreviewLink() { + // 判断 shareKey 是否以 pwps: 开头(WPS 云文档) + const shareKey = this.parseResult?.data?.shareKey + if (shareKey && shareKey.startsWith('pwps:')) { + // WPS 云文档直接使用原始分享链接 + return this.shareUrl + } + // 其他类型使用默认预览服务 + return this.previewBaseUrl + encodeURIComponent(this.downloadUrl) + }, + async fetchFile() { const url = this.$route.query.url if (!url) { @@ -54,6 +67,7 @@ export default { this.loading = false return } + this.shareUrl = url // 保存原始分享链接 try { const res = await axios.get('/json/parser', { params: { url } }) this.parseResult = res.data diff --git a/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java b/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java index 421263c..0d8244e 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java @@ -160,6 +160,21 @@ public class ParserApi { */ @RouteMapping(value = "/view/:type/:key", method = RouteMethod.GET, order = 2) public void view(HttpServerRequest request, HttpServerResponse response, String type, String key) { + // WPS 网盘类型特殊处理:直接使用原分享链接(WPS 支持在线预览) + if ("pwps".equalsIgnoreCase(type)) { + try { + // 重建原分享链接 + ParserCreate parserCreate = ParserCreate.fromType(type).shareKey(key); + String originalUrl = parserCreate.getShareLinkInfo().getStandardUrl(); + if (StringUtils.isNotBlank(originalUrl)) { + ResponseUtil.redirect(response, originalUrl); + return; + } + } catch (Exception e) { + log.warn("PWPS 预览链接构建失败: {}", e.getMessage()); + } + } + String previewURL = SharedDataUtil.getJsonStringForServerConfig("previewURL"); serverApi.parseKeyJson(request, type, key).onSuccess(res -> { redirect(response, previewURL, res); @@ -178,6 +193,24 @@ public class ParserApi { */ @RouteMapping(value = "/preview", method = RouteMethod.GET, order = 9) public void viewURL(HttpServerRequest request, HttpServerResponse response, String pwd) { + // WPS 网盘类型特殊处理:直接使用原分享链接(WPS 支持在线预览) + try { + String url = URLParamUtil.parserParams(request); + ParserCreate parserCreate = ParserCreate.fromShareUrl(url); + ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo(); + + // 如果是 PWPS 类型,直接重定向到原分享链接 + if ("pwps".equalsIgnoreCase(shareLinkInfo.getType())) { + String originalUrl = shareLinkInfo.getStandardUrl(); + if (StringUtils.isNotBlank(originalUrl)) { + ResponseUtil.redirect(response, originalUrl); + return; + } + } + } catch (Exception e) { + log.warn("解析预览链接失败: {}", e.getMessage()); + } + String previewURL = SharedDataUtil.getJsonStringForServerConfig("previewURL"); new ServerApi().parseJson(request, pwd).onSuccess(res -> { redirect(response, previewURL, res);