mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2025-12-15 20:03:02 +00:00
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:
@@ -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 | 不限大小 |
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ NFD 解析器模块:聚合各类网盘/分享页解析,统一输出文件列
|
||||
- 模块版本:10.1.17
|
||||
|
||||
## 依赖(Maven Central)
|
||||
- Maven(无需额外仓库配置):
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>cn.qaiu</groupId>
|
||||
|
||||
@@ -266,6 +266,12 @@ public enum PanDomainTemplate {
|
||||
compile("https://pan-yz\\.cldisk\\.com/external/m/file/(?<KEY>\\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/(?<KEY>.+)"),
|
||||
"https://www.kdocs.cn/l/{shareKey}",
|
||||
PwpsTool.class),
|
||||
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
|
||||
// http://163cn.tv/xxx
|
||||
MNES("网易云音乐分享",
|
||||
|
||||
63
parser/src/main/java/cn/qaiu/parser/impl/PwpsTool.java
Normal file
63
parser/src/main/java/cn/qaiu/parser/impl/PwpsTool.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
156
parser/src/test/java/cn/qaiu/parser/impl/WpsPanTest.java
Normal file
156
parser/src/test/java/cn/qaiu/parser/impl/WpsPanTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: /.*/,
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
</div>
|
||||
<div class="file-meta-row">
|
||||
<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 class="file-meta-row">
|
||||
<span class="file-meta-label">文件名:</span>{{ 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
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="file-meta-row">
|
||||
<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>
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user