feat: 添加 WPS 云文档/WPS 云盘解析支持 (closes #133)

- 新增 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(免费版)
This commit is contained in:
q
2025-10-20 13:33:53 +08:00
parent abde7841ac
commit 4fc4ed8640
9 changed files with 295 additions and 3 deletions

View File

@@ -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) [预览地址1](https://lz.qaiu.top)
[预览地址2](http://www.722shop.top:6401) [预览地址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) - [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~ - ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com) - [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
- [WPS云文档-pwps](https://www.kdocs.cn/)
- Google云盘-pgd - Google云盘-pgd
- Onedrive-pod - Onedrive-pod
- Dropbox-pdp - Dropbox-pdp
@@ -258,6 +261,7 @@ GET http://127.0.0.1:6400/json/fc/e5079007dc31226096628870c7@QAIU
| 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 | | 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 |
| 123云盘 | √ | √ | 2T | 100G>100M需要登录 | | 123云盘 | √ | √ | 2T | 100G>100M需要登录 |
| 文叔叔 | √ | √ | 10G | 5GB | | 文叔叔 | √ | √ | 10G | 5GB |
| WPS云文档 | √ | X | 1G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 | | 夸克网盘 | x | √ | 10G | 不限大小 |
| UC网盘 | x | √ | 10G | 不限大小 | | UC网盘 | x | √ | 10G | 不限大小 |

View File

@@ -7,7 +7,6 @@ NFD 解析器模块:聚合各类网盘/分享页解析,统一输出文件列
- 模块版本10.1.17 - 模块版本10.1.17
## 依赖Maven Central ## 依赖Maven Central
- Maven无需额外仓库配置
```xml ```xml
<dependency> <dependency>
<groupId>cn.qaiu</groupId> <groupId>cn.qaiu</groupId>

View File

@@ -266,6 +266,12 @@ public enum PanDomainTemplate {
compile("https://pan-yz\\.cldisk\\.com/external/m/file/(?<KEY>\\w+)"), compile("https://pan-yz\\.cldisk\\.com/external/m/file/(?<KEY>\\w+)"),
"https://pan-yz.cldisk.com/external/m/file/{shareKey}", "https://pan-yz.cldisk.com/external/m/file/{shareKey}",
PcxTool.class), 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/(?<KEY>.+)"),
"https://www.kdocs.cn/l/{shareKey}",
PwpsTool.class),
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)========================== // =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
// http://163cn.tv/xxx // http://163cn.tv/xxx
MNES("网易云音乐分享", MNES("网易云音乐分享",

View File

@@ -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;
/**
* <a href="https://www.kdocs.cn/">WPS云文档</a>
* 分享格式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<String> 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();
}
}

View File

@@ -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);
}
}

View File

@@ -340,6 +340,11 @@
host: /migu\.cn/, host: /migu\.cn/,
name: '咪咕音乐分享' name: '咪咕音乐分享'
}, },
kdocs: {
reg: /https:\/\/www\.kdocs\.cn\/l\/.+/,
host: /www\.kdocs\.cn/,
name: 'WPS云文档'
},
other: { other: {
reg: /https:\/\/([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}\/s\/.+/, reg: /https:\/\/([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}\/s\/.+/,
host: /.*/, host: /.*/,

View File

@@ -110,7 +110,7 @@
</div> </div>
<div class="file-meta-row"> <div class="file-meta-row">
<span class="file-meta-label">文件预览</span> <span class="file-meta-label">文件预览</span>
<a :href="previewBaseUrl + encodeURIComponent(downloadUrl)" target="_blank" class="file-meta-link">点击预览</a> <a :href="getPreviewLink()" target="_blank" class="file-meta-link">点击预览</a>
</div> </div>
<div class="file-meta-row"> <div class="file-meta-row">
<span class="file-meta-label">文件名</span>{{ extractFileNameAndExt(downloadUrl).name }} <span class="file-meta-label">文件名</span>{{ extractFileNameAndExt(downloadUrl).name }}
@@ -299,6 +299,18 @@ export default {
} }
}, },
methods: { 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) { handleThemeChange(isDark) {
this.isDarkMode = isDark this.isDarkMode = isDark

View File

@@ -20,7 +20,7 @@
</div> </div>
<div class="file-meta-row"> <div class="file-meta-row">
<span class="file-meta-label">在线预览</span> <span class="file-meta-label">在线预览</span>
<a :href="previewBaseUrl + encodeURIComponent(downloadUrl)" target="_blank" class="preview-btn">点击在线预览</a> <a :href="getPreviewLink()" target="_blank" class="preview-btn">点击在线预览</a>
</div> </div>
</div> </div>
</div> </div>
@@ -42,11 +42,24 @@ export default {
error: '', error: '',
parseResult: {}, parseResult: {},
downloadUrl: '', downloadUrl: '',
shareUrl: '', // 添加原始分享链接
fileTypeUtils, fileTypeUtils,
previewBaseUrl previewBaseUrl
} }
}, },
methods: { 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() { async fetchFile() {
const url = this.$route.query.url const url = this.$route.query.url
if (!url) { if (!url) {
@@ -54,6 +67,7 @@ export default {
this.loading = false this.loading = false
return return
} }
this.shareUrl = url // 保存原始分享链接
try { try {
const res = await axios.get('/json/parser', { params: { url } }) const res = await axios.get('/json/parser', { params: { url } })
this.parseResult = res.data this.parseResult = res.data

View File

@@ -160,6 +160,21 @@ public class ParserApi {
*/ */
@RouteMapping(value = "/view/:type/:key", method = RouteMethod.GET, order = 2) @RouteMapping(value = "/view/:type/:key", method = RouteMethod.GET, order = 2)
public void view(HttpServerRequest request, HttpServerResponse response, String type, String key) { 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"); String previewURL = SharedDataUtil.getJsonStringForServerConfig("previewURL");
serverApi.parseKeyJson(request, type, key).onSuccess(res -> { serverApi.parseKeyJson(request, type, key).onSuccess(res -> {
redirect(response, previewURL, res); redirect(response, previewURL, res);
@@ -178,6 +193,24 @@ public class ParserApi {
*/ */
@RouteMapping(value = "/preview", method = RouteMethod.GET, order = 9) @RouteMapping(value = "/preview", method = RouteMethod.GET, order = 9)
public void viewURL(HttpServerRequest request, HttpServerResponse response, String pwd) { 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"); String previewURL = SharedDataUtil.getJsonStringForServerConfig("previewURL");
new ServerApi().parseJson(request, pwd).onSuccess(res -> { new ServerApi().parseJson(request, pwd).onSuccess(res -> {
redirect(response, previewURL, res); redirect(response, previewURL, res);