From 3a25e5f2ae6321090f12d2c95f20673efba4f9ed Mon Sep 17 00:00:00 2001 From: q Date: Thu, 5 Feb 2026 20:35:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(v0.2.1):=20=E6=B7=BB=E5=8A=A0=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=8F=82=E6=95=B0=E6=94=AF=E6=8C=81=E5=92=8C=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E4=B8=8B=E8=BD=BD=E5=91=BD=E4=BB=A4=E7=94=9F?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要更新: - 新增 auth 参数加密传递支持 (QK/UC Cookie认证) - 实现下载命令自动生成 (curl/aria2c/迅雷) - aria2c 命令支持 8 线程 8 片段下载 - 修复 cookie 字段映射问题 - 优化前端 clientLinks 页面 - 添加认证参数文档和测试用例 - 更新 .gitignore 忽略编译目录 --- .gitignore | 3 + README.md | 38 ++ parser/doc/API_USAGE.md | 26 + parser/doc/auth-param/AUTH_PARAM_GUIDE.md | 314 +++++++++ .../doc/auth-param/AUTH_PARAM_GUIDE_SIMPLE.md | 118 ++++ parser/pom.xml | 2 +- .../src/main/java/cn/qaiu/parser/PanBase.java | 8 + .../cn/qaiu/parser/PanDomainTemplate.java | 13 + .../ClientLinkGeneratorFactory.java | 21 +- .../parser/clientlink/ClientLinkType.java | 33 +- .../parser/clientlink/ClientLinkUtils.java | 73 +- .../clientlink/impl/Aria2LinkGenerator.java | 2 + .../impl/BitCometLinkGenerator.java | 69 -- .../clientlink/impl/FdmLinkGenerator.java | 56 -- .../clientlink/impl/IdmLinkGenerator.java | 69 -- .../clientlink/impl/MotrixLinkGenerator.java | 53 -- .../impl/PowerShellLinkGenerator.java | 98 --- .../clientlink/impl/WgetLinkGenerator.java | 51 -- .../main/java/cn/qaiu/parser/impl/FjTool.java | 491 +++++++++++--- .../main/java/cn/qaiu/parser/impl/QkTool.java | 626 ++++++++++++++++- .../main/java/cn/qaiu/parser/impl/UcTool.java | 569 ++++++++++++++-- .../main/java/cn/qaiu/util/CookieUtils.java | 185 +++++ .../main/java/cn/qaiu/util/DateTimeUtils.java | 111 +++ .../cn/qaiu/parser/auth/AuthParamTest.java | 209 ++++++ .../parser/auth/CookieUtilsManualTest.java | 209 ++++++ .../parser/clientlink/ClientLinkExample.java | 4 +- .../clientlink/ClientLinkGeneratorTest.java | 262 -------- .../parser/clientlink/PowerShellExample.java | 68 -- .../parser/clientlink/UcQkClientLinkTest.java | 180 +++++ .../parser/impl/UcQkToolValidationTest.java | 139 ++++ .../integration/AuthParseIntegrationTest.java | 367 ++++++++++ .../parser/integration/LinkIdentifyTest.java | 86 +++ .../java/cn/qaiu/parser/integration/README.md | 194 ++++++ .../qaiu/parser/integration/TEST_RESULTS.md | 191 ++++++ .../cn/qaiu/parser/integration/run-test.sh | 76 +++ .../resources/auth-test.properties.template | 67 ++ pom.xml | 2 +- web-front/package.json | 3 +- web-front/src/parserUrl1.js | 12 + web-front/src/views/ClientLinks.vue | 633 +++++------------- web-front/src/views/Home.vue | 503 +++++++++++++- .../qaiu/lz/common/util/AuthParamCodec.java | 220 ++++++ .../cn/qaiu/lz/common/util/URLParamUtil.java | 157 ++++- .../cn/qaiu/lz/web/controller/ParserApi.java | 96 ++- .../cn/qaiu/lz/web/controller/ServerApi.java | 43 +- .../java/cn/qaiu/lz/web/model/AuthParam.java | 166 +++++ .../cn/qaiu/lz/web/model/CacheLinkInfo.java | 22 + .../cn/qaiu/lz/web/model/ClientLinkResp.java | 24 + .../lz/web/service/impl/CacheServiceImpl.java | 193 +++++- web-service/src/main/resources/app-dev.yml | 2 + .../lz/common/util/AuthParamCodecTest.java | 336 ++++++++++ .../common/util/AuthParamCodecTestMain.java | 416 ++++++++++++ webroot/auth-encrypt.html | 444 ++++++++++++ 53 files changed, 6882 insertions(+), 1471 deletions(-) create mode 100644 parser/doc/auth-param/AUTH_PARAM_GUIDE.md create mode 100644 parser/doc/auth-param/AUTH_PARAM_GUIDE_SIMPLE.md delete mode 100644 parser/src/main/java/cn/qaiu/parser/clientlink/impl/BitCometLinkGenerator.java delete mode 100644 parser/src/main/java/cn/qaiu/parser/clientlink/impl/FdmLinkGenerator.java delete mode 100644 parser/src/main/java/cn/qaiu/parser/clientlink/impl/IdmLinkGenerator.java delete mode 100644 parser/src/main/java/cn/qaiu/parser/clientlink/impl/MotrixLinkGenerator.java delete mode 100644 parser/src/main/java/cn/qaiu/parser/clientlink/impl/PowerShellLinkGenerator.java delete mode 100644 parser/src/main/java/cn/qaiu/parser/clientlink/impl/WgetLinkGenerator.java create mode 100644 parser/src/main/java/cn/qaiu/util/CookieUtils.java create mode 100644 parser/src/main/java/cn/qaiu/util/DateTimeUtils.java create mode 100644 parser/src/test/java/cn/qaiu/parser/auth/AuthParamTest.java create mode 100644 parser/src/test/java/cn/qaiu/parser/auth/CookieUtilsManualTest.java delete mode 100644 parser/src/test/java/cn/qaiu/parser/clientlink/ClientLinkGeneratorTest.java delete mode 100644 parser/src/test/java/cn/qaiu/parser/clientlink/PowerShellExample.java create mode 100644 parser/src/test/java/cn/qaiu/parser/clientlink/UcQkClientLinkTest.java create mode 100644 parser/src/test/java/cn/qaiu/parser/impl/UcQkToolValidationTest.java create mode 100644 parser/src/test/java/cn/qaiu/parser/integration/AuthParseIntegrationTest.java create mode 100644 parser/src/test/java/cn/qaiu/parser/integration/LinkIdentifyTest.java create mode 100644 parser/src/test/java/cn/qaiu/parser/integration/README.md create mode 100644 parser/src/test/java/cn/qaiu/parser/integration/TEST_RESULTS.md create mode 100755 parser/src/test/java/cn/qaiu/parser/integration/run-test.sh create mode 100644 parser/src/test/resources/auth-test.properties.template create mode 100644 web-service/src/main/java/cn/qaiu/lz/common/util/AuthParamCodec.java create mode 100644 web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java create mode 100644 web-service/src/test/java/cn/qaiu/lz/common/util/AuthParamCodecTest.java create mode 100644 web-service/src/test/java/cn/qaiu/lz/common/util/AuthParamCodecTestMain.java create mode 100644 webroot/auth-encrypt.html diff --git a/.gitignore b/.gitignore index e97cbff..ff5746a 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,6 @@ yarn-error.log* *.iml *.ipr *.iws +**/target/ +**/${project.build.directory}/ +**/classes/ diff --git a/README.md b/README.md index 2576a04..188ad12 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,44 @@ GET /json/getFileList?url={分享链接}&pwd={密码} - `{网盘标识}` 参考支持的网盘列表 - `your_host` 替换为您的域名或 IP +### 认证参数(v0.2.1+) + +部分网盘(如夸克、UC)需要登录后的 Cookie 才能解析和下载。可通过 `auth` 参数传递认证信息: + +**参数格式**:`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码 + +**加密方式**: +- 算法:AES/ECB/PKCS5Padding +- 密钥:`nfd_auth_key2026`(16字节) +- 流程:JSON → AES加密 → Base64 → URL编码 + +**JSON 结构**: +```json +{ + "authType": "cookie", // 认证类型: cookie/accesstoken/authorization/password/custom + "token": "your_cookie_here", // Cookie 或 Token 内容 + "username": "", // 用户名(password 类型时使用) + "password": "", // 密码(password 类型时使用) + "ext1": "", // 扩展字段1(custom 类型时使用) + "ext2": "" // 扩展字段2(custom 类型时使用) +} +``` + +**网盘认证要求**: +| 网盘 | 认证要求 | 说明 | +|------|---------|------| +| 夸克网盘(QK) | **必须** | 必须配置 Cookie 才能解析 | +| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 | +| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 | +| 蓝奏优享(IZ) | 可选 | 大文件需要认证 | + +**使用示例**: +``` +GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数} +``` + +> 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。 + ### 特殊说明 - 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值 diff --git a/parser/doc/API_USAGE.md b/parser/doc/API_USAGE.md index 2a1a4df..6d8c2f9 100644 --- a/parser/doc/API_USAGE.md +++ b/parser/doc/API_USAGE.md @@ -20,6 +20,32 @@ |------|------|------|------| | url | string | ✅ 是 | 分享链接(需URL编码) | | pwd | string | ❌ 否 | 分享密码 | +| auth | string | ❌ 否 | 认证参数(AES加密后的JSON,用于需要登录的网盘) | + +### 认证参数说明(v0.2.1+) + +部分网盘(如夸克QK、UC网盘)需要登录后的 Cookie 才能解析。`auth` 参数用于传递认证信息: + +**加密方式**: +- 算法:AES/ECB/PKCS5Padding +- 密钥:`nfd_auth_key2026`(16字节) +- 流程:JSON → AES加密 → Base64 → URL编码 + +**JSON 结构**: +```json +{ + "authType": "cookie", // 认证类型: cookie/accesstoken/authorization + "token": "your_cookie_here" // Cookie 或 Token 内容 +} +``` + +**网盘认证要求**: +| 网盘 | 认证要求 | +|------|---------| +| 夸克网盘(QK) | **必须** | +| UC网盘(UC) | **必须** | +| 小飞机网盘(FJ) | 大文件需要 | +| 蓝奏优享(IZ) | 大文件需要 | ### 请求示例 diff --git a/parser/doc/auth-param/AUTH_PARAM_GUIDE.md b/parser/doc/auth-param/AUTH_PARAM_GUIDE.md new file mode 100644 index 0000000..c3e3f20 --- /dev/null +++ b/parser/doc/auth-param/AUTH_PARAM_GUIDE.md @@ -0,0 +1,314 @@ +# 认证参数传递指南 (Auth Parameter Guide) + +## 概述 + +本文档描述了网盘解析接口中携带认证参数的方法。通过 `auth` 参数,可以在解析请求时传递临时认证信息(如 Cookie、Token、用户名密码等),使解析器能够访问需要登录或授权的网盘资源。 + +## 网盘认证要求 + +| 网盘 | 类型代码 | 认证要求 | 说明 | +|------|---------|---------|------| +| 夸克网盘 | QK | **必须** | 必须配置 Cookie 才能解析和下载 | +| UC网盘 | UC | **必须** | 必须配置 Cookie 才能解析和下载 | +| 小飞机网盘 | FJ | 可选 | 大文件(>100MB)需要配置认证信息 | +| 蓝奏优享 | IZ | 可选 | 大文件需要配置认证信息 | +| 其他网盘 | - | 不需要 | 无需认证即可解析 | + +> 💡 **如何获取 Cookie**: 在浏览器中登录对应网盘,打开开发者工具(F12),切换到 Network 标签,刷新页面,在请求头中找到 Cookie 字段并复制完整内容。 + +## 认证参数格式 + +### 编码流程 + +``` +JSON对象 → AES加密 → Base64编码 → URL编码 +``` + +### 解码流程 + +``` +URL解码 → Base64解码 → AES解密 → JSON对象 +``` + +### 加密配置 + +- **加密算法**: AES/ECB/PKCS5Padding +- **密钥长度**: 16位(128位) +- **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置) + +## JSON 模型定义 + +### AuthParam 对象 + +```json +{ + "authType": "string", // 认证类型(必填) + "username": "string", // 用户名 + "password": "string", // 密码 + "token": "string", // Token/AccessToken/Cookie值 + "cookie": "string", // Cookie 字符串 + "auth": "string", // Authorization 头内容 + "ext1": "string", // 扩展字段1(格式: key:value) + "ext2": "string", // 扩展字段2(格式: key:value) + "ext3": "string", // 扩展字段3(格式: key:value) + "ext4": "string", // 扩展字段4(格式: key:value) + "ext5": "string" // 扩展字段5(格式: key:value) +} +``` + +### 认证类型 (authType) + +| authType | 说明 | 主要字段 | +|----------|------|---------| +| `accesstoken` | 使用 AccessToken 认证 | `token` | +| `cookie` | 使用 Cookie 认证 | `token` (存放 cookie 值) | +| `authorization` | 使用 Authorization 头认证 | `token` | +| `password` / `username_password` | 用户名密码认证 | `username`, `password` | +| `custom` | 自定义认证(使用扩展字段) | `token`, `ext1`-`ext5` | + +### 示例 JSON + +#### 1. Token 认证 +```json +{ + "authType": "accesstoken", + "token": "your_access_token_here" +} +``` + +#### 2. Cookie 认证 +```json +{ + "authType": "cookie", + "token": "session_id=abc123; user_token=xyz789" +} +``` + +#### 3. 用户名密码认证 +```json +{ + "authType": "password", + "username": "your_username", + "password": "your_password" +} +``` + +#### 4. 自定义认证 +```json +{ + "authType": "custom", + "token": "main_token", + "ext1": "refresh_token:your_refresh_token", + "ext2": "device_id:device123" +} +``` + +## 接口调用示例 + +### 基础接口 + +#### 1. 解析并重定向 (GET /parser) + +``` +GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数} +``` + +**参数说明:** +- `url`: 网盘分享链接(必填) +- `pwd`: 提取码(可选) +- `auth`: 加密后的认证参数(可选) + +**响应:** 302 重定向到直链 + +#### 2. 解析返回 JSON (GET /json/parser) + +``` +GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数} +``` + +**响应示例:** +```json +{ + "shareKey": "lz:xxxx", + "directLink": "https://...", + "cacheHit": false, + "expires": "2026-02-05 12:00:00", + "expiration": 1738728000000 +} +``` + +#### 3. 获取链接信息 (GET /v2/linkInfo) + +``` +GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数} +``` + +**响应:** 返回下载链接、API 链接、预览链接等信息 + +## 各语言加密示例 + +### Java + +```java +import cn.qaiu.lz.common.util.AuthParamCodec; +import cn.qaiu.lz.web.model.AuthParam; + +// 方式1: 使用 AuthParam 对象 +AuthParam authParam = AuthParam.builder() + .authType("accesstoken") + .token("your_token_here") + .build(); +String encrypted = AuthParamCodec.encode(authParam); + +// 方式2: 快速编码 +String encrypted = AuthParamCodec.quickEncode("accesstoken", "your_token_here"); + +// 方式3: 用户名密码 +String encrypted = AuthParamCodec.quickEncodePassword("username", "password"); + +// 解码 +AuthParam decoded = AuthParamCodec.decode(encrypted); +``` + +### JavaScript (浏览器/Node.js) + +```javascript +// 使用 CryptoJS 库 +const CryptoJS = require('crypto-js'); + +const AUTH_KEY = 'nfd_auth_key2026'; + +// 加密 +function encodeAuthParam(authObj) { + const jsonStr = JSON.stringify(authObj); + const encrypted = CryptoJS.AES.encrypt(jsonStr, CryptoJS.enc.Utf8.parse(AUTH_KEY), { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7 + }); + const base64 = encrypted.toString(); + return encodeURIComponent(base64); +} + +// 解密 +function decodeAuthParam(encryptedAuth) { + const base64 = decodeURIComponent(encryptedAuth); + const decrypted = CryptoJS.AES.decrypt(base64, CryptoJS.enc.Utf8.parse(AUTH_KEY), { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7 + }); + return JSON.parse(decrypted.toString(CryptoJS.enc.Utf8)); +} + +// 使用示例 +const auth = encodeAuthParam({ + authType: 'accesstoken', + token: 'your_token_here' +}); +const url = `http://127.0.0.1:6400/parser?url=${shareUrl}&auth=${auth}`; +``` + +### Python + +```python +import json +import base64 +from urllib.parse import quote, unquote +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad + +AUTH_KEY = b'nfd_auth_key2026' + +def encode_auth_param(auth_obj): + """加密认证参数""" + json_str = json.dumps(auth_obj, ensure_ascii=False) + cipher = AES.new(AUTH_KEY, AES.MODE_ECB) + padded = pad(json_str.encode('utf-8'), AES.block_size) + encrypted = cipher.encrypt(padded) + base64_str = base64.b64encode(encrypted).decode('utf-8') + return quote(base64_str) + +def decode_auth_param(encrypted_auth): + """解密认证参数""" + base64_str = unquote(encrypted_auth) + encrypted = base64.b64decode(base64_str) + cipher = AES.new(AUTH_KEY, AES.MODE_ECB) + decrypted = unpad(cipher.decrypt(encrypted), AES.block_size) + return json.loads(decrypted.decode('utf-8')) + +# 使用示例 +auth = encode_auth_param({ + 'authType': 'accesstoken', + 'token': 'your_token_here' +}) +url = f'http://127.0.0.1:6400/parser?url={share_url}&auth={auth}' +``` + +### cURL 命令行 + +```bash +# 假设已加密的 auth 参数为 ENCRYPTED_AUTH +curl -L "http://127.0.0.1:6400/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH" + +# 获取 JSON 响应 +curl "http://127.0.0.1:6400/json/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH" +``` + +## 解析器使用认证信息 + +解析器可以从 `shareLinkInfo.otherParam.get("auths")` 获取 MultiMap 格式的认证信息: + +```java +// 在解析器中获取认证信息 +MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + +if (auths != null) { + String authType = auths.get("authType"); + String token = auths.get("token"); + String username = auths.get("username"); + String password = auths.get("password"); + + // 根据 authType 使用相应的认证方式 + switch (authType) { + case "accesstoken": + // 使用 token 认证 + break; + case "password": + // 使用用户名密码登录 + break; + // ... + } +} +``` + +## 注意事项 + +1. **安全性**: + - 不要在日志中打印完整的认证参数 + - 认证参数通过 HTTPS 传输更安全 + - 密钥应妥善保管,建议在生产环境中更换默认密钥 + +2. **缓存策略**: + - 带有临时认证参数的请求目前不会被缓存 + - 每次请求都会重新解析 + +3. **兼容性**: + - `auth` 参数与原有的 `pwd` 参数可以同时使用 + - 不提供 `auth` 参数时,使用后台配置的认证信息 + +4. **扩展字段**: + - `ext1`-`ext5` 使用 `key:value` 格式 + - 适用于需要传递多个自定义参数的场景 + +## 配置说明 + +在 `app-dev.yml` 中配置加密密钥: + +```yaml +server: + # auth参数加密密钥(16位AES密钥) + authEncryptKey: 'your_custom_key16' +``` + +## 更新日志 + +- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型 diff --git a/parser/doc/auth-param/AUTH_PARAM_GUIDE_SIMPLE.md b/parser/doc/auth-param/AUTH_PARAM_GUIDE_SIMPLE.md new file mode 100644 index 0000000..ec14754 --- /dev/null +++ b/parser/doc/auth-param/AUTH_PARAM_GUIDE_SIMPLE.md @@ -0,0 +1,118 @@ +# 认证参数传递指南 (简化版) + +## JSON 对象模型 + +### AuthParam 对象 + +```json +{ + "authType": "string", // 认证类型(必填) + "username": "string", // 用户名 + "password": "string", // 密码 + "token": "string", // Token/AccessToken/Cookie值 + "cookie": "string", // Cookie 字符串 + "auth": "string", // Authorization 头内容 + "ext1": "string", // 扩展字段1(格式: key:value) + "ext2": "string", // 扩展字段2(格式: key:value) + "ext3": "string", // 扩展字段3(格式: key:value) + "ext4": "string", // 扩展字段4(格式: key:value) + "ext5": "string" // 扩展字段5(格式: key:value) +} +``` + +## 认证类型 + +| authType | 说明 | 主要字段 | +|----------|------|---------| +| `accesstoken` | AccessToken 认证 | `token` | +| `cookie` | Cookie 认证 | `token` | +| `authorization` | Authorization 头认证 | `token` | +| `password` | 用户名密码认证 | `username`, `password` | +| `custom` | 自定义认证 | `token`, `ext1`-`ext5` | + +## 示例 + +### Token 认证 +```json +{ + "authType": "accesstoken", + "token": "your_access_token_here" +} +``` + +### Cookie 认证 +```json +{ + "authType": "cookie", + "token": "session_id=abc123; user_token=xyz789" +} +``` + +### 用户名密码 +```json +{ + "authType": "password", + "username": "your_username", + "password": "your_password" +} +``` + +### 自定义认证 +```json +{ + "authType": "custom", + "token": "main_token", + "ext1": "refresh_token:your_refresh_token", + "ext2": "device_id:device123" +} +``` + +## 使用说明 + +1. **编码流程**: JSON对象 → AES加密 → Base64编码 → URL编码 +2. **加密配置**: AES/ECB/PKCS5Padding, 密钥: `nfd_auth_key2026` (16位) +3. **接口调用**: `GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}` + + + +## 接口调用示例 + +### 基础接口 + +#### 1. 解析并重定向 (GET /parser) + +``` +GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数} +``` + +**参数说明:** +- `url`: 网盘分享链接(必填) +- `pwd`: 提取码(可选) +- `auth`: 加密后的认证参数(可选) + +**响应:** 302 重定向到直链 + +#### 2. 解析返回 JSON (GET /json/parser) + +``` +GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数} +``` + +**响应示例:** +```json +{ + "shareKey": "lz:xxxx", + "directLink": "https://...", + "cacheHit": false, + "expires": "2026-02-05 12:00:00", + "expiration": 1738728000000 +} +``` + +#### 3. 获取链接信息 (GET /v2/linkInfo) + +``` +GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数} +``` + +**响应:** 返回下载链接、API 链接、预览链接等信息 \ No newline at end of file diff --git a/parser/pom.xml b/parser/pom.xml index 5733122..8ca6064 100644 --- a/parser/pom.xml +++ b/parser/pom.xml @@ -52,7 +52,7 @@ - 0.1.8 + 0.2.1 17 17 17 diff --git a/parser/src/main/java/cn/qaiu/parser/PanBase.java b/parser/src/main/java/cn/qaiu/parser/PanBase.java index 1fe7d61..c1bc071 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanBase.java +++ b/parser/src/main/java/cn/qaiu/parser/PanBase.java @@ -56,7 +56,15 @@ public abstract class PanBase implements IPanTool { protected WebClient clientNoRedirects = WebClient.create(WebClientVertxInit.get(), new WebClientOptions().setFollowRedirects(false)); + /** + * Http client disable UserAgent + */ + protected WebClient clientDisableUA = WebClient.create(WebClientVertxInit.get() + , new WebClientOptions().setUserAgentEnabled(false) + ); + protected ShareLinkInfo shareLinkInfo; + /** * 子类重写此构造方法不需要添加额外逻辑 diff --git a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java index 08fec7b..1ebe2f1 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java +++ b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java @@ -272,6 +272,19 @@ public enum PanDomainTemplate { compile("https://(?:[a-zA-Z\\d-]+\\.)?kdocs\\.cn/l/(?.+)"), "https://www.kdocs.cn/l/{shareKey}", PwpsTool.class), + + // https://fast.uc.cn/s/33197dd53ace4 + // https://drive.uc.cn/s/e623b6da278e4?public=1#/list/share + UC("UC网盘", + compile("https://(fast|drive)\\.uc\\.cn/s/(?\\w+)(\\?public=\\d+)?([&#].*)?"), + "https://drive.uc.cn/s/{shareKey}", + UcTool.class), + // https://pan.quark.cn/s/6a325cdaec58 + QK("夸克网盘", + compile("https://pan\\.quark\\.cn/s/(?\\w+)([&#].*)?"), + "https://pan.quark.cn/s/{shareKey}", + QkTool.class), + // =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)========================== // http://163cn.tv/xxx MNES("网易云音乐分享", diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkGeneratorFactory.java b/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkGeneratorFactory.java index a381aa9..acb3495 100644 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkGeneratorFactory.java +++ b/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkGeneratorFactory.java @@ -11,6 +11,13 @@ import java.util.concurrent.ConcurrentHashMap; /** * 客户端下载链接生成器工厂类 + *

+ * 支持的客户端类型: + *

    + *
  • CURL - cURL 命令,支持 Cookie
  • + *
  • ARIA2 - Aria2 命令,支持 Cookie
  • + *
  • THUNDER - 迅雷协议,不支持 Cookie
  • + *
* * @author QAIU * Create at 2025/01/21 @@ -25,16 +32,10 @@ public class ClientLinkGeneratorFactory { // 静态初始化块,注册默认的生成器 static { try { - // 注册默认生成器 - 按指定顺序注册 - register(new Aria2LinkGenerator()); - register(new MotrixLinkGenerator()); - register(new BitCometLinkGenerator()); - register(new ThunderLinkGenerator()); - register(new WgetLinkGenerator()); - register(new CurlLinkGenerator()); - register(new IdmLinkGenerator()); - register(new FdmLinkGenerator()); - register(new PowerShellLinkGenerator()); + // 注册默认生成器 - 只保留3种(按需求) + register(new CurlLinkGenerator()); // cURL 命令,支持 Cookie + register(new Aria2LinkGenerator()); // Aria2 命令,支持 Cookie + register(new ThunderLinkGenerator()); // 迅雷协议,不支持 Cookie log.info("客户端链接生成器工厂初始化完成,已注册 {} 个生成器", generators.size()); } catch (Exception e) { diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkType.java b/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkType.java index c01c1c1..20c9d36 100644 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkType.java +++ b/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkType.java @@ -2,27 +2,32 @@ package cn.qaiu.parser.clientlink; /** * 客户端下载工具类型枚举 + *

+ * 支持的客户端类型: + *

    + *
  • CURL - cURL 命令行工具,支持 Cookie
  • + *
  • ARIA2 - 多线程下载器,支持 Cookie
  • + *
  • THUNDER - 迅雷下载器,不支持 Cookie(使用迅雷协议)
  • + *
* * @author QAIU * Create at 2025/01/21 */ public enum ClientLinkType { - ARIA2("aria2", "Aria2"), - MOTRIX("motrix", "Motrix"), - BITCOMET("bitcomet", "比特彗星"), - THUNDER("thunder", "迅雷"), - WGET("wget", "wget 命令"), - CURL("curl", "cURL 命令"), - IDM("idm", "IDM"), - FDM("fdm", "Free Download Manager"), - POWERSHELL("powershell", "PowerShell"); + CURL("curl", "cURL 命令", true, "命令行下载工具,支持Cookie"), + ARIA2("aria2", "Aria2", true, "多线程下载器,支持Cookie"), + THUNDER("thunder", "迅雷", false, "迅雷下载器,不支持Cookie"); private final String code; private final String displayName; + private final boolean supportsCookie; + private final String description; - ClientLinkType(String code, String displayName) { + ClientLinkType(String code, String displayName, boolean supportsCookie, String description) { this.code = code; this.displayName = displayName; + this.supportsCookie = supportsCookie; + this.description = description; } public String getCode() { @@ -33,6 +38,14 @@ public enum ClientLinkType { return displayName; } + public boolean isSupportsCookie() { + return supportsCookie; + } + + public String getDescription() { + return description; + } + @Override public String toString() { return displayName; diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkUtils.java b/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkUtils.java index 9d5b6d5..8ae672d 100644 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkUtils.java +++ b/parser/src/main/java/cn/qaiu/parser/clientlink/ClientLinkUtils.java @@ -7,6 +7,13 @@ import java.util.Map; /** * 客户端下载链接生成工具类 * 提供便捷的静态方法来生成各种客户端下载链接 + *

+ * 支持的客户端类型: + *

    + *
  • CURL - cURL 命令,支持 Cookie
  • + *
  • ARIA2 - Aria2 命令,支持 Cookie
  • + *
  • THUNDER - 迅雷协议,不支持 Cookie
  • + *
* * @author QAIU * Create at 2025/01/21 @@ -35,7 +42,7 @@ public class ClientLinkUtils { } /** - * 生成 curl 命令 + * 生成 curl 命令(支持 Cookie) * * @param info ShareLinkInfo 对象 * @return curl 命令字符串 @@ -45,17 +52,7 @@ public class ClientLinkUtils { } /** - * 生成 wget 命令 - * - * @param info ShareLinkInfo 对象 - * @return wget 命令字符串 - */ - public static String generateWgetCommand(ShareLinkInfo info) { - return generateClientLink(info, ClientLinkType.WGET); - } - - /** - * 生成 aria2 命令 + * 生成 aria2 命令(支持 Cookie) * * @param info ShareLinkInfo 对象 * @return aria2 命令字符串 @@ -65,7 +62,7 @@ public class ClientLinkUtils { } /** - * 生成迅雷链接 + * 生成迅雷链接(不支持 Cookie) * * @param info ShareLinkInfo 对象 * @return 迅雷协议链接 @@ -74,56 +71,6 @@ public class ClientLinkUtils { return generateClientLink(info, ClientLinkType.THUNDER); } - /** - * 生成 IDM 链接 - * - * @param info ShareLinkInfo 对象 - * @return IDM 协议链接 - */ - public static String generateIdmLink(ShareLinkInfo info) { - return generateClientLink(info, ClientLinkType.IDM); - } - - /** - * 生成比特彗星链接 - * - * @param info ShareLinkInfo 对象 - * @return 比特彗星协议链接 - */ - public static String generateBitCometLink(ShareLinkInfo info) { - return generateClientLink(info, ClientLinkType.BITCOMET); - } - - /** - * 生成 Motrix 导入格式 - * - * @param info ShareLinkInfo 对象 - * @return Motrix JSON 格式字符串 - */ - public static String generateMotrixFormat(ShareLinkInfo info) { - return generateClientLink(info, ClientLinkType.MOTRIX); - } - - /** - * 生成 FDM 导入格式 - * - * @param info ShareLinkInfo 对象 - * @return FDM 格式字符串 - */ - public static String generateFdmFormat(ShareLinkInfo info) { - return generateClientLink(info, ClientLinkType.FDM); - } - - /** - * 生成 PowerShell 命令 - * - * @param info ShareLinkInfo 对象 - * @return PowerShell 命令字符串 - */ - public static String generatePowerShellCommand(ShareLinkInfo info) { - return generateClientLink(info, ClientLinkType.POWERSHELL); - } - /** * 检查 ShareLinkInfo 是否包含有效的下载元数据 * diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/Aria2LinkGenerator.java b/parser/src/main/java/cn/qaiu/parser/clientlink/impl/Aria2LinkGenerator.java index a619d7e..ba2a154 100644 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/Aria2LinkGenerator.java +++ b/parser/src/main/java/cn/qaiu/parser/clientlink/impl/Aria2LinkGenerator.java @@ -41,6 +41,8 @@ public class Aria2LinkGenerator implements ClientLinkGenerator { parts.add("--continue"); // 支持断点续传 parts.add("--max-tries=3"); // 最大重试次数 parts.add("--retry-wait=5"); // 重试等待时间 + parts.add("-s 8"); // 分成8片段下载 + parts.add("-x 8"); // 每个服务器使用8个连接 // 添加URL parts.add("\"" + meta.getUrl() + "\""); diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/BitCometLinkGenerator.java b/parser/src/main/java/cn/qaiu/parser/clientlink/impl/BitCometLinkGenerator.java deleted file mode 100644 index 993f968..0000000 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/BitCometLinkGenerator.java +++ /dev/null @@ -1,69 +0,0 @@ -package cn.qaiu.parser.clientlink.impl; - -import cn.qaiu.parser.clientlink.ClientLinkGenerator; -import cn.qaiu.parser.clientlink.ClientLinkType; -import cn.qaiu.parser.clientlink.DownloadLinkMeta; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Map; - -/** - * 比特彗星协议链接生成器 - * - * @author QAIU - * Create at 2025/01/21 - */ -public class BitCometLinkGenerator implements ClientLinkGenerator { - - @Override - public String generate(DownloadLinkMeta meta) { - if (!supports(meta)) { - return null; - } - - try { - // 比特彗星支持 HTTP 下载,格式类似 IDM - String encodedUrl = Base64.getEncoder().encodeToString( - meta.getUrl().getBytes(StandardCharsets.UTF_8) - ); - - StringBuilder link = new StringBuilder("bitcomet:///?url=").append(encodedUrl); - - // 添加请求头 - if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) { - StringBuilder headerStr = new StringBuilder(); - for (Map.Entry entry : meta.getHeaders().entrySet()) { - if (headerStr.length() > 0) { - headerStr.append("\\r\\n"); - } - headerStr.append(entry.getKey()).append(": ").append(entry.getValue()); - } - - String encodedHeaders = Base64.getEncoder().encodeToString( - headerStr.toString().getBytes(StandardCharsets.UTF_8) - ); - link.append("&header=").append(encodedHeaders); - } - - // 添加文件名 - if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) { - String encodedFileName = Base64.getEncoder().encodeToString( - meta.getFileName().getBytes(StandardCharsets.UTF_8) - ); - link.append("&filename=").append(encodedFileName); - } - - return link.toString(); - - } catch (Exception e) { - // 如果编码失败,返回简单的URL - return "bitcomet:///?url=" + meta.getUrl(); - } - } - - @Override - public ClientLinkType getType() { - return ClientLinkType.BITCOMET; - } -} diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/FdmLinkGenerator.java b/parser/src/main/java/cn/qaiu/parser/clientlink/impl/FdmLinkGenerator.java deleted file mode 100644 index 0628744..0000000 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/FdmLinkGenerator.java +++ /dev/null @@ -1,56 +0,0 @@ -package cn.qaiu.parser.clientlink.impl; - -import cn.qaiu.parser.clientlink.ClientLinkGenerator; -import cn.qaiu.parser.clientlink.ClientLinkType; -import cn.qaiu.parser.clientlink.DownloadLinkMeta; - -import java.util.Map; - -/** - * Free Download Manager 导入格式生成器 - * - * @author QAIU - * Create at 2025/01/21 - */ -public class FdmLinkGenerator implements ClientLinkGenerator { - - @Override - public String generate(DownloadLinkMeta meta) { - if (!supports(meta)) { - return null; - } - - // FDM 支持简单的文本格式导入 - StringBuilder result = new StringBuilder(); - result.append("URL=").append(meta.getUrl()).append("\n"); - - // 添加文件名 - if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) { - result.append("Filename=").append(meta.getFileName()).append("\n"); - } - - // 添加请求头 - if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) { - result.append("Headers="); - boolean first = true; - for (Map.Entry entry : meta.getHeaders().entrySet()) { - if (!first) { - result.append("; "); - } - result.append(entry.getKey()).append(": ").append(entry.getValue()); - first = false; - } - result.append("\n"); - } - - result.append("Referer=").append(meta.getReferer() != null ? meta.getReferer() : "").append("\n"); - result.append("User-Agent=").append(meta.getUserAgent() != null ? meta.getUserAgent() : "").append("\n"); - - return result.toString(); - } - - @Override - public ClientLinkType getType() { - return ClientLinkType.FDM; - } -} diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/IdmLinkGenerator.java b/parser/src/main/java/cn/qaiu/parser/clientlink/impl/IdmLinkGenerator.java deleted file mode 100644 index 9b5603b..0000000 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/IdmLinkGenerator.java +++ /dev/null @@ -1,69 +0,0 @@ -package cn.qaiu.parser.clientlink.impl; - -import cn.qaiu.parser.clientlink.ClientLinkGenerator; -import cn.qaiu.parser.clientlink.ClientLinkType; -import cn.qaiu.parser.clientlink.DownloadLinkMeta; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Map; - -/** - * IDM 协议链接生成器 - * - * @author QAIU - * Create at 2025/01/21 - */ -public class IdmLinkGenerator implements ClientLinkGenerator { - - @Override - public String generate(DownloadLinkMeta meta) { - if (!supports(meta)) { - return null; - } - - try { - // 对URL进行Base64编码 - String encodedUrl = Base64.getEncoder().encodeToString( - meta.getUrl().getBytes(StandardCharsets.UTF_8) - ); - - StringBuilder link = new StringBuilder("idm:///?url=").append(encodedUrl); - - // 添加请求头 - if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) { - StringBuilder headerStr = new StringBuilder(); - for (Map.Entry entry : meta.getHeaders().entrySet()) { - if (headerStr.length() > 0) { - headerStr.append("\\r\\n"); - } - headerStr.append(entry.getKey()).append(": ").append(entry.getValue()); - } - - String encodedHeaders = Base64.getEncoder().encodeToString( - headerStr.toString().getBytes(StandardCharsets.UTF_8) - ); - link.append("&header=").append(encodedHeaders); - } - - // 添加文件名 - if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) { - String encodedFileName = Base64.getEncoder().encodeToString( - meta.getFileName().getBytes(StandardCharsets.UTF_8) - ); - link.append("&filename=").append(encodedFileName); - } - - return link.toString(); - - } catch (Exception e) { - // 如果编码失败,返回简单的URL - return "idm:///?url=" + meta.getUrl(); - } - } - - @Override - public ClientLinkType getType() { - return ClientLinkType.IDM; - } -} diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/MotrixLinkGenerator.java b/parser/src/main/java/cn/qaiu/parser/clientlink/impl/MotrixLinkGenerator.java deleted file mode 100644 index 350e05d..0000000 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/MotrixLinkGenerator.java +++ /dev/null @@ -1,53 +0,0 @@ -package cn.qaiu.parser.clientlink.impl; - -import cn.qaiu.parser.clientlink.ClientLinkGenerator; -import cn.qaiu.parser.clientlink.ClientLinkType; -import cn.qaiu.parser.clientlink.DownloadLinkMeta; -import io.vertx.core.json.JsonObject; - -import java.util.Map; - -/** - * Motrix 导入格式生成器 - * - * @author QAIU - * Create at 2025/01/21 - */ -public class MotrixLinkGenerator implements ClientLinkGenerator { - - @Override - public String generate(DownloadLinkMeta meta) { - if (!supports(meta)) { - return null; - } - - // 使用 Vert.x JsonObject 构建 JSON - JsonObject taskJson = new JsonObject(); - taskJson.put("url", meta.getUrl()); - - // 添加文件名 - if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) { - taskJson.put("filename", meta.getFileName()); - } - - // 添加请求头 - if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) { - JsonObject headersJson = new JsonObject(); - for (Map.Entry entry : meta.getHeaders().entrySet()) { - headersJson.put(entry.getKey(), entry.getValue()); - } - taskJson.put("headers", headersJson); - } - - // 设置输出文件名 - String outputFile = meta.getFileName() != null ? meta.getFileName() : ""; - taskJson.put("out", outputFile); - - return taskJson.encodePrettily(); - } - - @Override - public ClientLinkType getType() { - return ClientLinkType.MOTRIX; - } -} diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/PowerShellLinkGenerator.java b/parser/src/main/java/cn/qaiu/parser/clientlink/impl/PowerShellLinkGenerator.java deleted file mode 100644 index 5018b86..0000000 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/PowerShellLinkGenerator.java +++ /dev/null @@ -1,98 +0,0 @@ -package cn.qaiu.parser.clientlink.impl; - -import cn.qaiu.parser.clientlink.ClientLinkGenerator; -import cn.qaiu.parser.clientlink.ClientLinkType; -import cn.qaiu.parser.clientlink.DownloadLinkMeta; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** - * PowerShell 命令生成器 - * - * @author QAIU - * Create at 2025/01/21 - */ -public class PowerShellLinkGenerator implements ClientLinkGenerator { - - @Override - public String generate(DownloadLinkMeta meta) { - if (!supports(meta)) { - return null; - } - - List lines = new ArrayList<>(); - - // 创建 WebRequestSession - lines.add("$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession"); - - // 设置 User-Agent(如果存在) - String userAgent = meta.getUserAgent(); - if (userAgent == null && meta.getHeaders() != null) { - userAgent = meta.getHeaders().get("User-Agent"); - } - if (userAgent != null && !userAgent.trim().isEmpty()) { - lines.add("$session.UserAgent = \"" + escapePowerShellString(userAgent) + "\""); - } - - // 构建 Invoke-WebRequest 命令 - List invokeParams = new ArrayList<>(); - invokeParams.add("Invoke-WebRequest"); - invokeParams.add("-UseBasicParsing"); - invokeParams.add("-Uri \"" + escapePowerShellString(meta.getUrl()) + "\""); - - // 添加 WebSession - invokeParams.add("-WebSession $session"); - - // 添加请求头 - if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) { - List headerLines = new ArrayList<>(); - headerLines.add("-Headers @{"); - - boolean first = true; - for (Map.Entry entry : meta.getHeaders().entrySet()) { - if (!first) { - headerLines.add(""); - } - headerLines.add(" \"" + escapePowerShellString(entry.getKey()) + "\"=\"" + - escapePowerShellString(entry.getValue()) + "\""); - first = false; - } - - headerLines.add("}"); - - // 将头部参数添加到主命令中 - invokeParams.add(String.join("`\n", headerLines)); - } - - // 设置输出文件(如果指定了文件名) - if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) { - invokeParams.add("-OutFile \"" + escapePowerShellString(meta.getFileName()) + "\""); - } - - // 将所有参数连接起来 - String invokeCommand = String.join(" `\n", invokeParams); - lines.add(invokeCommand); - - return String.join("\n", lines); - } - - /** - * 转义 PowerShell 字符串中的特殊字符 - */ - private String escapePowerShellString(String str) { - if (str == null) { - return ""; - } - - return str.replace("`", "``") - .replace("\"", "`\"") - .replace("$", "`$"); - } - - @Override - public ClientLinkType getType() { - return ClientLinkType.POWERSHELL; - } -} diff --git a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/WgetLinkGenerator.java b/parser/src/main/java/cn/qaiu/parser/clientlink/impl/WgetLinkGenerator.java deleted file mode 100644 index 5d94e0f..0000000 --- a/parser/src/main/java/cn/qaiu/parser/clientlink/impl/WgetLinkGenerator.java +++ /dev/null @@ -1,51 +0,0 @@ -package cn.qaiu.parser.clientlink.impl; - -import cn.qaiu.parser.clientlink.ClientLinkGenerator; -import cn.qaiu.parser.clientlink.ClientLinkType; -import cn.qaiu.parser.clientlink.DownloadLinkMeta; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** - * wget 命令生成器 - * - * @author QAIU - * Create at 2025/01/21 - */ -public class WgetLinkGenerator implements ClientLinkGenerator { - - @Override - public String generate(DownloadLinkMeta meta) { - if (!supports(meta)) { - return null; - } - - List parts = new ArrayList<>(); - parts.add("wget"); - - // 添加请求头 - if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) { - for (Map.Entry entry : meta.getHeaders().entrySet()) { - parts.add("--header=\"" + entry.getKey() + ": " + entry.getValue() + "\""); - } - } - - // 设置输出文件名 - if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) { - parts.add("-O"); - parts.add("\"" + meta.getFileName() + "\""); - } - - // 添加URL - parts.add("\"" + meta.getUrl() + "\""); - - return String.join(" \\\n ", parts); - } - - @Override - public ClientLinkType getType() { - return ClientLinkType.WGET; - } -} diff --git a/parser/src/main/java/cn/qaiu/parser/impl/FjTool.java b/parser/src/main/java/cn/qaiu/parser/impl/FjTool.java index 2e631bf..74e6546 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/FjTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/FjTool.java @@ -3,9 +3,7 @@ package cn.qaiu.parser.impl; import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; -import cn.qaiu.util.AESUtils; -import cn.qaiu.util.FileSizeConverter; -import cn.qaiu.util.UUIDUtil; +import cn.qaiu.util.*; import io.vertx.core.Future; import io.vertx.core.MultiMap; import io.vertx.core.Promise; @@ -13,12 +11,14 @@ import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.client.HttpRequest; +import io.vertx.ext.web.client.HttpResponse; import io.vertx.uritemplate.UriTemplate; import org.apache.commons.lang3.StringUtils; import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.UUID; /** * 小飞机网盘 @@ -26,69 +26,113 @@ import java.util.List; * @version V016_230609 */ public class FjTool extends PanBase { - public static final String REFERER_URL = "https://share.feijipan.com/"; - private static final String API_URL_PREFIX = "https://api.feejii.com/ws/"; + + public static final String API_URL0 = "https://api.feijipan.com"; + private static final String API_URL_PREFIX = "https://api.feijipan.com/ws/"; private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" + "&uuid={uuid}&extra=2×tamp={ts}&shareId={shareId}&type=0&offset=1&limit=60"; - /// recommend/list?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}&shareId={shareId}&type=0&offset=1&limit=60 - // recommend/list?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}&shareId=JoUTkZYj&type=0&offset=1&limit=60 + private static final String LOGIN_URL = API_URL_PREFIX + + "login?uuid={uuid}&devType=6&devCode={uuid}&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken=&extra=2"; + + private static final String TOKEN_VERIFY_URL = API_URL0 + + "/app/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}"; private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" + "&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}"; - // https://api.feijipan.com/ws/file/redirect?downloadId={fidEncode}&enable=1&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey} + + //https://api.feijipan.com/ws/file/redirect? + // downloadId=DBD34FFEDB71708FA5C284527F78E9EC104A9667FFEEA62CB6E00B54A3E0F5BB + // &enable=1 + // &devType=6 + // &uuid=rTaNVSgmwY5MbEEuiMmQL + // ×tamp=839E6B5E19223B8DF730A52F44062D48 + // &auth=F799422BCD9D05D7CCC5C9C53C1092C7029B420536135C3B4B7E064F49459DCC + // &shareId=4wF7grHR + private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX + + "file/redirect?downloadId={fidEncode}&enable=1&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}"; private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" + "={uuid}&extra=2×tamp={ts}"; // https://api.feijipan.com/ws/buy/vip/list?devType=6&devModel=Chrome&uuid=WQAl5yBy1naGudJEILBvE&extra=2×tamp=E2C53155F6D09417A27981561134CB73 - // https://api.feijipan.com/ws/share/list?devType=6&devModel=Chrome&uuid=pwRWqwbk1J-KMTlRZowrn&extra=2×tamp=C5F8A68C53121AB21FA35BA3529E8758&shareId=fmAuOh3m&folderId=28986333&offset=1&limit=60 - private static final String FILE_LIST_URL = API_URL_PREFIX + "/share/list?devType=6&devModel=Chrome&uuid" + "={uuid}&extra=2×tamp={ts}&shareId={shareId}&folderId" + "={folderId}&offset=1&limit=60"; private static final MultiMap header; - + private static final MultiMap header0; long nowTs = System.currentTimeMillis(); String tsEncode = AESUtils.encrypt2Hex(Long.toString(nowTs)); String uuid = UUIDUtil.fjUuid(); // 也可以使用 UUID.randomUUID().toString() static { - header = MultiMap.caseInsensitiveMultiMap(); - header.set("Accept", "application/json, text/plain, */*"); - header.set("Accept-Encoding", "gzip, deflate, br, zstd"); - header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); - header.set("Cache-Control", "no-cache"); - header.set("Connection", "keep-alive"); - header.set("Content-Length", "0"); - header.set("DNT", "1"); - header.set("Host", "api.feijipan.com"); - header.set("Origin", "https://www.feijix.com"); - header.set("Pragma", "no-cache"); - header.set("Referer", "https://www.feijix.com/"); - header.set("Sec-Fetch-Dest", "empty"); - header.set("Sec-Fetch-Mode", "cors"); - header.set("Sec-Fetch-Site", "cross-site"); - header.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); - header.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\""); - header.set("sec-ch-ua-mobile", "?0"); - header.set("sec-ch-ua-platform", "\"Windows\""); + header0 = MultiMap.caseInsensitiveMultiMap(); + header0.set("Accept-Encoding", "gzip, deflate"); + header0.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); + header0.set("Cache-Control", "no-cache"); + header0.set("Connection", "keep-alive"); + header0.set("Content-Length", "0"); + header0.set("DNT", "1"); + header0.set("Pragma", "no-cache"); + header0.set("Referer", "https://www.feijipan.com/"); + header0.set("Sec-Fetch-Dest", "empty"); + header0.set("Sec-Fetch-Mode", "cors"); + header0.set("Sec-Fetch-Site", "cross-site"); + header0.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); + header0.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\""); + header0.set("sec-ch-ua-mobile", "?0"); + header0.set("sec-ch-ua-platform", "\"Windows\""); + + header = HeaderUtils.parseHeaders(""" + Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 + 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 + DNT: 1 + Pragma: no-cache + Referer: https://www.feijix.com/ + Sec-Fetch-Dest: document + Sec-Fetch-Mode: navigate + Sec-Fetch-Site: cross-site + Sec-Fetch-User: ?1 + Upgrade-Insecure-Requests: 1 + user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0 + sec-ch-ua: "Microsoft Edge";v="135", "Not-A.Brand";v="8", "Chromium";v="135" + sec-ch-ua-mobile: ?0 + sec-ch-ua-platform: "Windows" + """); } + // String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString() + + static String token = null; + static String userId = null; + public static boolean authFlag = true; + public FjTool(ShareLinkInfo shareLinkInfo) { super(shareLinkInfo); } - @Override public Future parse() { - // 240530 此处shareId又改为了原始的shareId - // String.valueOf(AESUtils.idEncrypt(dataKey)); - final String shareId = shareLinkInfo.getShareKey(); + String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey)); + long nowTs = System.currentTimeMillis(); + String tsEncode = AESUtils.encrypt2Hex(Long.toString(nowTs)); + + if (shareLinkInfo.getOtherParam().containsKey("auths")) { + MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + // 获取用户id + if (auths.contains("userId")) { + FjTool.userId = auths.get("userId"); + log.info("已配置用户ID: {}", FjTool.userId); + } else { + log.warn("未配置用户ID, 可能会导致解析失败"); + } + } // 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口 String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL @@ -98,77 +142,312 @@ public class FjTool extends PanBase { .setTemplateParam("uuid", uuid) .setTemplateParam("ts", tsEncode) .send().onSuccess(r0 -> { // 忽略res - // 第一次请求 获取文件信息 // POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60 client.postAbs(UriTemplate.of(url)) - .putHeaders(header) + .putHeaders(header0) .setTemplateParam("shareId", shareId) .setTemplateParam("uuid", uuid) .setTemplateParam("ts", tsEncode) .send().onSuccess(res -> { - JsonObject resJson = asJson(res); - if (resJson.getInteger("code") != 200) { - fail(FIRST_REQUEST_URL + " 返回异常: " + resJson); + + JsonObject resJson; + try { + resJson = asJson(res); + } catch (Exception e) { + log.error("获取文件信息失败: {}", res.bodyAsString()); return; } - if (resJson.getJsonArray("list").isEmpty()) { - fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson); + if (resJson.getInteger("code") != 200) { + fail(FIRST_REQUEST_URL + " 返回异常: " + resJson); return; } if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) { fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson); return; } + // 文件Id JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0); // 如果是目录返回目录ID - if (!fileInfo.containsKey("fileList") || fileInfo.getJsonArray("fileList").isEmpty()) { - fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo); - return; - } JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0); if (fileList.getInteger("fileType") == 2) { promise.complete(fileList.getInteger("folderId").toString()); return; } + // 提取文件信息 + extractFileInfo(fileList, fileInfo); + getDownURL(resJson); + }).onFailure(handleFail("请求1")); - String fileId = fileInfo.getString("fileIds"); - String userId = fileInfo.getString("userId"); - // 其他参数 - long nowTs2 = System.currentTimeMillis(); - String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2)); - String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + userId); - String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2); - - // 第二次请求 - HttpRequest httpRequest = - clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) - .putHeaders(header) - .setTemplateParam("fidEncode", fidEncode) - .setTemplateParam("uuid", uuid) - .setTemplateParam("ts", tsEncode2) - .setTemplateParam("auth", auth) - .setTemplateParam("dataKey", shareId); - // System.out.println(httpRequest.toString()); - httpRequest.send().onSuccess(res2 -> { - MultiMap headers = res2.headers(); - if (!headers.contains("Location")) { - fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res.headers()); - return; - } - promise.complete(headers.get("Location")); - }).onFailure(handleFail(SECOND_REQUEST_URL)); - }).onFailure(handleFail(FIRST_REQUEST_URL)); - - }).onFailure(handleFail(FIRST_REQUEST_URL)); + }).onFailure(handleFail("请求1")); return promise.future(); } + private void getDownURL(JsonObject resJson) { + String dataKey = shareLinkInfo.getShareKey(); + // 文件Id + JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0); + String fileId = fileInfo.getString("fileIds"); + String userId = fileInfo.getString("userId"); + // 其他参数 + long nowTs2 = System.currentTimeMillis(); + String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2)); + String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId); + String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2); + + // 检查是否有认证信息 + if (shareLinkInfo.getOtherParam().containsKey("auths")) { + // 检查是否为临时认证(临时认证每次都尝试登录) + boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED"); + // 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证 + if (isTempAuth || authFlag) { + log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag); + HttpRequest httpRequest = + clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP)) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .setTemplateParam("auth", auth) + .setTemplateParam("dataKey", dataKey) + ; + MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + if (token == null) { + // 执行登录 + login(tsEncode2, auths).onFailure(failRes-> { + log.warn("登录失败: {}", failRes.getMessage()); + fail(failRes.getMessage()); + }).onSuccess(r-> { + httpRequest.setTemplateParam("fidEncode", AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId)) + .putHeaders(header); + httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); + }); + } else { + // 验证token + client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL)) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .putHeaders(header0).send().onSuccess(res -> { + if (asJson(res).getInteger("code") != 200) { + login(tsEncode2, auths).onFailure(failRes -> { + log.warn("重新登录失败: {}", failRes.getMessage()); + fail(failRes.getMessage()); + }).onSuccess(r-> { + httpRequest + .setTemplateParam("fidEncode", fidEncode) + .putHeaders(header); + httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); + }); + } else { + httpRequest + .setTemplateParam("fidEncode", AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId)) + .putHeaders(header); + httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); + } + }).onFailure(handleFail("Token验证")); + } + } else { + // authFlag 为 false,使用免登录解析 + log.debug("authFlag=false,使用免登录解析"); + clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + .putHeaders(header) + .setTemplateParam("fidEncode", fidEncode) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .setTemplateParam("auth", auth) + .setTemplateParam("dataKey", dataKey).send() + .onSuccess(this::down).onFailure(handleFail("请求2")); + } + } else { + // 没有认证信息,使用免登录解析 + log.debug("无认证信息,使用免登录解析"); + clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + .putHeaders(header) + .setTemplateParam("fidEncode", fidEncode) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .setTemplateParam("auth", auth) + .setTemplateParam("dataKey", dataKey).send() + .onSuccess(this::down).onFailure(handleFail("请求2")); + } + + } + + private Future login(String tsEncode2, MultiMap auths) { + Promise promise1 = Promise.promise(); + // 如果配置了用户ID 则不登录 + if (FjTool.userId != null) { + promise1.complete(); + return promise1.future(); + } + client.postAbs(UriTemplate.of(LOGIN_URL)) + .setTemplateParam("uuid",uuid) + .setTemplateParam("ts", tsEncode2) + .putHeaders(header0) + .sendJsonObject(JsonObject.of("loginName", auths.get("username"), "loginPwd", auths.get("password"))) + .onSuccess(res2->{ + JsonObject json = asJson(res2); + if (json.getInteger("code") == 200) { + token = json.getJsonObject("data").getString("appToken"); + header0.set("appToken", token); + log.info("登录成功 token: {}", token); + client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL)) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .putHeaders(header0).send().onSuccess(res -> { + if (asJson(res).getInteger("code") == 200) { + if (FjTool.userId == null) { + FjTool.userId = asJson(res).getJsonObject("map").getString("userId"); + } + log.info("验证成功 userId: {}", FjTool.userId); + promise1.complete(); + } else { + promise1.fail("验证失败: " + res.bodyAsString()); + } + }); + } else { + // 检查是否为临时认证 + boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED"); + if (isTempAuth) { + // 临时认证失败,直接返回错误,不影响后台配置的认证 + log.warn("临时认证失败: {}", json.getString("msg")); + promise1.fail("临时认证失败: " + json.getString("msg")); + } else { + // 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析 + log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg")); + authFlag = false; + promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式"); + } + } + }).onFailure(err -> { + log.error("登录请求异常: {}", err.getMessage()); + promise1.fail("登录请求异常: " + err.getMessage()); + }); + return promise1.future(); + } + + /** + * 从接口返回数据中提取文件信息 + */ + private void extractFileInfo(JsonObject fileList, JsonObject shareInfo) { + try { + // 文件名 + String fileName = fileList.getString("fileName"); + shareLinkInfo.getOtherParam().put("fileName", fileName); + + // 文件大小 (KB -> Bytes) + Long fileSize = fileList.getLong("fileSize", 0L) * 1024; + shareLinkInfo.getOtherParam().put("fileSize", fileSize); + shareLinkInfo.getOtherParam().put("fileSizeFormat", FileSizeConverter.convertToReadableSize(fileSize)); + + // 文件图标 + String fileIcon = fileList.getString("fileIcon"); + if (StringUtils.isNotBlank(fileIcon)) { + shareLinkInfo.getOtherParam().put("fileIcon", fileIcon); + } + + // 文件ID + Long fileId = fileList.getLong("fileId"); + if (fileId != null) { + shareLinkInfo.getOtherParam().put("fileId", fileId.toString()); + } + + // 文件类型 (1=文件, 2=目录) + Integer fileType = fileList.getInteger("fileType", 1); + shareLinkInfo.getOtherParam().put("fileType", fileType == 1 ? "file" : "folder"); + + // 下载次数 + Integer downloads = fileList.getInteger("fileDownloads", 0); + shareLinkInfo.getOtherParam().put("downloadCount", downloads); + + // 点赞数 + Integer likes = fileList.getInteger("fileLikes", 0); + shareLinkInfo.getOtherParam().put("likeCount", likes); + + // 评论数 + Integer comments = fileList.getInteger("fileComments", 0); + shareLinkInfo.getOtherParam().put("commentCount", comments); + + // 评分 + Double stars = fileList.getDouble("fileStars", 0.0); + shareLinkInfo.getOtherParam().put("stars", stars); + + // 更新时间 + String updateTime = fileList.getString("updTime"); + if (StringUtils.isNotBlank(updateTime)) { + shareLinkInfo.getOtherParam().put("updateTime", updateTime); + } + + // 创建时间 + String createTime = null; + + // 分享信息 + if (shareInfo != null) { + // 分享ID + Integer shareId = shareInfo.getInteger("shareId"); + if (shareId != null) { + shareLinkInfo.getOtherParam().put("shareId", shareId.toString()); + } + + // 上传时间 + String addTime = shareInfo.getString("addTime"); + if (StringUtils.isNotBlank(addTime)) { + shareLinkInfo.getOtherParam().put("createTime", addTime); + createTime = addTime; + } + + // 预览次数 + Integer previewNum = shareInfo.getInteger("previewNum", 0); + shareLinkInfo.getOtherParam().put("previewCount", previewNum); + + // 用户信息 + JsonObject userMap = shareInfo.getJsonObject("map"); + if (userMap != null) { + String userName = userMap.getString("userName"); + if (StringUtils.isNotBlank(userName)) { + shareLinkInfo.getOtherParam().put("userName", userName); + } + + // VIP信息 + Integer isVip = userMap.getInteger("isVip", 0); + shareLinkInfo.getOtherParam().put("isVip", isVip == 1); + } + } + + // 创建 FileInfo 对象并存入 otherParam + FileInfo fileInfoObj = new FileInfo() + .setPanType(shareLinkInfo.getType()) + .setFileName(fileName) + .setFileId(fileId != null ? fileId.toString() : null) + .setSize(fileSize) + .setSizeStr(FileSizeConverter.convertToReadableSize(fileSize)) + .setFileType(fileType == 1 ? "file" : "folder") + .setFileIcon(fileIcon) + .setDownloadCount(downloads) + .setCreateTime(createTime) + .setUpdateTime(updateTime); + shareLinkInfo.getOtherParam().put("fileInfo", fileInfoObj); + + log.debug("提取文件信息成功: fileName={}, fileSize={}, downloads={}", + fileName, fileSize, downloads); + } catch (Exception e) { + log.warn("提取文件信息失败: {}", e.getMessage()); + } + } + + private void down(HttpResponse res2) { + MultiMap headers = res2.headers(); + if (!headers.contains("Location") || headers.get("Location") == null) { + fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误: " + res2.bodyAsString()); + return; + } + promise.complete(headers.get("Location")); + } + + // 目录解析 @Override public Future> parseFileList() { - Promise> promise = Promise.promise(); + Promise> promise0 = Promise.promise(); String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey)); @@ -176,42 +455,52 @@ public class FjTool extends PanBase { String dirId = (String) shareLinkInfo.getOtherParam().get("dirId"); if (dirId != null && !dirId.isEmpty()) { uuid = shareLinkInfo.getOtherParam().get("uuid").toString(); - parserDir(dirId, shareId, promise); - return promise.future(); + parserDir(dirId, shareId, promise0); + return promise0.future(); } parse().onSuccess(id -> { - if (id != null && id.matches("^[a-zA-Z0-9]+$")) { - parserDir(id, shareId, promise); - } else { - promise.fail("解析目录ID失败"); - } + parserDir(id, shareId, promise0); }).onFailure(failRes -> { log.error("解析目录失败: {}", failRes.getMessage()); - promise.fail(failRes); + promise0.fail(failRes); }); - return promise.future(); + return promise0.future(); } private void parserDir(String id, String shareId, Promise> promise) { + // id以http开头直接返回 封装数组返回 + if (id != null && (id.startsWith("http://") || id.startsWith("https://"))) { + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileName(id) + .setFileId(id) + .setFileType("file") + .setParserUrl(id) + .setPanType(shareLinkInfo.getType()); + List result = new ArrayList<>(); + result.add(fileInfo); + promise.complete(result); + return; + } + log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode); // 开始解析目录: 164312216, shareId: bPMsbg5K, uuid: 0fmVWTx2Ea4zFwkpd7KXf, ts: 20865d7b7f00828279f437cd1f097860 // 拿到目录ID client.postAbs(UriTemplate.of(FILE_LIST_URL)) - .putHeaders(header) + .putHeaders(header0) .setTemplateParam("shareId", shareId) .setTemplateParam("uuid", uuid) .setTemplateParam("ts", tsEncode) .setTemplateParam("folderId", id) .send().onSuccess(res -> { - JsonObject jsonObject; + JsonArray list; try { - jsonObject = asJson(res); + JsonObject jsonObject = asJson(res); + System.out.println(jsonObject.encodePrettily()); + list = jsonObject.getJsonArray("list"); } catch (Exception e) { - promise.fail(FIRST_REQUEST_URL + " 解析JSON失败: " + res.bodyAsString()); + log.error("解析目录失败: {}", res.bodyAsString()); return; } - // System.out.println(jsonObject.encodePrettily()); - JsonArray list = jsonObject.getJsonArray("list"); ArrayList result = new ArrayList<>(); list.forEach(item->{ JsonObject fileJson = (JsonObject) item; @@ -224,7 +513,7 @@ public class FjTool extends PanBase { // 其他参数 long nowTs2 = System.currentTimeMillis(); String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2)); - String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + userId); + String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId); String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2); // 回传用到的参数 @@ -239,8 +528,7 @@ public class FjTool extends PanBase { "ts", tsEncode2, "auth", auth, "shareId", shareId); - byte[] encode = Base64.getEncoder().encode(entries.encode().getBytes()); - String param = new String(encode); + String param = CommonUtils.urlBase64Encode(entries.encode()); if (fileJson.getInteger("fileType") == 2) { // 如果是目录 @@ -280,17 +568,15 @@ public class FjTool extends PanBase { result.add(fileInfo); }); promise.complete(result); - }).onFailure(failRes -> { - log.error("解析目录请求失败: {}", failRes.getMessage()); - promise.fail(failRes); - });; + }); } @Override public Future parseById() { + // 第二次请求 JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson"); - clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP)) .setTemplateParam("fidEncode", paramJson.getString("fidEncode")) .setTemplateParam("uuid", paramJson.getString("uuid")) .setTemplateParam("ts", paramJson.getString("ts")) @@ -299,11 +585,16 @@ public class FjTool extends PanBase { .putHeaders(header).send().onSuccess(res2 -> { MultiMap headers = res2.headers(); if (!headers.contains("Location")) { - fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res2.headers()); + fail(SECOND_REQUEST_URL_VIP + " 未找到重定向URL: \n" + res2.headers()); return; } promise.complete(headers.get("Location")); - }).onFailure(handleFail(SECOND_REQUEST_URL)); + }).onFailure(handleFail(SECOND_REQUEST_URL_VIP)); return promise.future(); } + + public static void resetToken() { + token = null; + authFlag = true; + } } diff --git a/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java b/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java index 5a84007..4204913 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java @@ -1,41 +1,623 @@ package cn.qaiu.parser.impl; -import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.entity.FileInfo; +import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; +import cn.qaiu.util.CommonUtils; +import cn.qaiu.util.CookieUtils; +import cn.qaiu.util.FileSizeConverter; +import cn.qaiu.util.HeaderUtils; import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Promise; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; -import java.util.concurrent.TimeUnit; -import java.util.stream.IntStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +/** + * 夸克网盘解析 + */ public class QkTool extends PanBase { + + public static final String SHARE_URL_PREFIX = "https://pan.quark.cn/s/"; + + private static final String TOKEN_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token"; + private static final String DETAIL_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail"; + private static final String DOWNLOAD_URL = "https://drive-pc.quark.cn/1/clouddrive/file/download"; + + // Cookie 刷新 API + private static final String FLUSH_URL = "https://drive-pc.quark.cn/1/clouddrive/auth/pc/flush"; + + private static final int BATCH_SIZE = 15; // 批量获取下载链接的批次大小 + + // 静态变量:缓存 __puus cookie 和过期时间 + private static volatile String cachedPuus = null; + private static volatile long puusExpireTime = 0; + // __puus 有效期,默认 55 分钟(服务器实际 1 小时过期,提前 5 分钟刷新) + private static final long PUUS_TTL_MS = 55 * 60 * 1000L; + + private final MultiMap header = HeaderUtils.parseHeaders(""" + User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch + Content-Type: application/json;charset=UTF-8 + Referer: https://pan.quark.cn/ + Origin: https://pan.quark.cn + Accept: application/json, text/plain, */* + """); + + // 保存 auths 引用,用于更新 cookie + private MultiMap auths; public QkTool(ShareLinkInfo shareLinkInfo) { super(shareLinkInfo); + // 参考 UcTool 实现,从认证配置中取 cookie 放到请求头 + if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) { + auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + String cookie = auths.get("cookie"); + if (cookie != null && !cookie.isEmpty()) { + // 过滤出夸克网盘所需的 cookie 字段 + cookie = CookieUtils.filterUcQuarkCookie(cookie); + + // 如果有缓存的 __puus 且未过期,使用缓存的值更新 cookie + if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) { + cookie = CookieUtils.updateCookieValue(cookie, "__puus", cachedPuus); + log.debug("夸克: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000); + } + header.set(HttpHeaders.COOKIE, cookie); + // 同步更新 auths + auths.set("cookie", cookie); + } + } + this.client = clientDisableUA; + + // 如果 __puus 已过期或不存在,触发异步刷新 + if (needRefreshPuus()) { + log.debug("夸克: __puus 需要刷新,触发异步刷新"); + refreshPuusCookie(); + } + } + + /** + * 判断是否需要刷新 __puus + * @return true 表示需要刷新 + */ + private boolean needRefreshPuus() { + String currentCookie = header.get(HttpHeaders.COOKIE); + if (currentCookie == null || currentCookie.isEmpty()) { + return false; + } + // 必须包含 __pus 才能刷新 + if (!currentCookie.contains("__pus=")) { + return false; + } + // 缓存过期或不存在时需要刷新 + return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime; } - public Future parse() { - final String key = shareLinkInfo.getShareKey(); - final String pwd = shareLinkInfo.getSharePassword(); + /** + * 刷新 __puus Cookie + * 通过调用 auth/pc/flush API,服务器会返回 set-cookie 来更新 __puus + * @return Future 包含是否刷新成功 + */ + public Future refreshPuusCookie() { + Promise refreshPromise = Promise.promise(); + + String currentCookie = header.get(HttpHeaders.COOKIE); + if (currentCookie == null || currentCookie.isEmpty()) { + log.debug("夸克: 无 cookie,跳过刷新"); + refreshPromise.complete(false); + return refreshPromise.future(); + } + + // 检查是否包含 __pus(用于获取 __puus) + if (!currentCookie.contains("__pus=")) { + log.debug("夸克: cookie 中不包含 __pus,跳过刷新"); + refreshPromise.complete(false); + return refreshPromise.future(); + } + + log.debug("夸克: 开始刷新 __puus cookie"); + + client.getAbs(FLUSH_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .addQueryParam("uc_param_str", "") + .putHeaders(header) + .send() + .onSuccess(res -> { + // 从响应头获取 set-cookie + List setCookies = res.cookies(); + String newPuus = null; + + for (String cookie : setCookies) { + if (cookie.startsWith("__puus=")) { + // 提取 __puus 值(只取到分号前的部分) + int endIndex = cookie.indexOf(';'); + newPuus = endIndex > 0 ? cookie.substring(0, endIndex) : cookie; + break; + } + } + + if (newPuus != null) { + // 更新 cookie:替换或添加 __puus + String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus); + header.set(HttpHeaders.COOKIE, updatedCookie); + + // 同步更新 auths 中的 cookie + if (auths != null) { + auths.set("cookie", updatedCookie); + } + + // 更新静态缓存 + cachedPuus = newPuus; + puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS; + + log.info("夸克: __puus cookie 刷新成功,有效期至: {}ms", puusExpireTime); + refreshPromise.complete(true); + } else { + log.debug("夸克: 响应中未包含 __puus,可能 cookie 仍然有效"); + refreshPromise.complete(false); + } + }) + .onFailure(t -> { + log.warn("夸克: 刷新 __puus cookie 失败: {}", t.getMessage()); + refreshPromise.complete(false); + }); + + return refreshPromise.future(); + } + + @Override + public Future parse() { + String pwdId = shareLinkInfo.getShareKey(); + String passcode = shareLinkInfo.getSharePassword(); + if (passcode == null) { + passcode = ""; + } + + log.debug("开始解析夸克网盘分享,pwd_id: {}, passcode: {}", pwdId, passcode.isEmpty() ? "无" : "有"); + + // 第一步:获取分享 token + JsonObject tokenRequest = new JsonObject() + .put("pwd_id", pwdId) + .put("passcode", passcode); + + client.postAbs(TOKEN_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .putHeaders(header) + .sendJsonObject(tokenRequest) + .onSuccess(res -> { + log.debug("第一阶段响应: {}", res.bodyAsString()); + JsonObject resJson = asJson(res); + + if (resJson.getInteger("code") != 0) { + fail(TOKEN_URL + " 返回异常: " + resJson); + return; + } + + String stoken = resJson.getJsonObject("data").getString("stoken"); + if (stoken == null || stoken.isEmpty()) { + fail("无法获取分享 token,可能的原因:1. Cookie 已过期 2. 分享链接已失效 3. 需要提取码但未提供"); + return; + } + + log.debug("成功获取 stoken: {}", stoken); + + // 第二步:获取文件列表 + client.getAbs(DETAIL_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .addQueryParam("pwd_id", pwdId) + .addQueryParam("stoken", stoken) + .addQueryParam("pdir_fid", "0") + .addQueryParam("force", "0") + .addQueryParam("_page", "1") + .addQueryParam("_size", "50") + .addQueryParam("_fetch_banner", "1") + .addQueryParam("_fetch_share", "1") + .addQueryParam("_fetch_total", "1") + .addQueryParam("_sort", "file_type:asc,updated_at:desc") + .putHeaders(header) + .send() + .onSuccess(res2 -> { + log.debug("第二阶段响应: {}", res2.bodyAsString()); + JsonObject resJson2 = asJson(res2); + + if (resJson2.getInteger("code") != 0) { + fail(DETAIL_URL + " 返回异常: " + resJson2); + return; + } + + JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list"); + if (fileList == null || fileList.isEmpty()) { + fail("未找到文件"); + return; + } + + // 过滤出文件(排除文件夹) + List files = new ArrayList<>(); + for (int i = 0; i < fileList.size(); i++) { + JsonObject item = fileList.getJsonObject(i); + // 判断是否为文件:file=true 或 obj_category 不为空 + if (item.getBoolean("file", false) || + (item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) { + files.add(item); + } + } + + if (files.isEmpty()) { + fail("没有可下载的文件(可能都是文件夹)"); + return; + } + + log.debug("找到 {} 个文件", files.size()); + + // 提取第一个文件的信息并保存到 otherParam + try { + JsonObject firstFile = files.get(0); + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileId(firstFile.getString("fid")) + .setFileName(firstFile.getString("file_name")) + .setSize(firstFile.getLong("size", 0L)) + .setSizeStr(FileSizeConverter.convertToReadableSize(firstFile.getLong("size", 0L))) + .setFileType(firstFile.getBoolean("file", true) ? "file" : "folder") + .setCreateTime(firstFile.getString("updated_at")) + .setUpdateTime(firstFile.getString("updated_at")) + .setPanType(shareLinkInfo.getType()); + + // 保存到 otherParam,供 CacheServiceImpl 使用 + shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); + log.debug("夸克提取文件信息: {}", fileInfo.getFileName()); + } catch (Exception e) { + log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage()); + } + + // 提取文件ID列表 + List fileIds = new ArrayList<>(); + for (JsonObject file : files) { + String fid = file.getString("fid"); + if (fid != null && !fid.isEmpty()) { + fileIds.add(fid); + } + } + + if (fileIds.isEmpty()) { + fail("无法提取文件ID"); + return; + } + + // 第三步:批量获取下载链接 + getDownloadLinksBatch(fileIds, stoken) + .onSuccess(downloadData -> { + if (downloadData.isEmpty()) { + fail("未能获取到下载链接"); + return; + } + + // 获取第一个文件的下载链接 + String downloadUrl = downloadData.get(0).getString("download_url"); + if (downloadUrl == null || downloadUrl.isEmpty()) { + fail("下载链接为空"); + return; + } + + // 夸克网盘需要配合下载请求头,保存下载请求头 + Map downloadHeaders = new HashMap<>(); + downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE)); + downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT)); + downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://pan.quark.cn/"); + + log.debug("成功获取下载链接: {}", downloadUrl); + completeWithMeta(downloadUrl, downloadHeaders); + }) + .onFailure(handleFail(DOWNLOAD_URL)); + + }).onFailure(handleFail(DETAIL_URL)); + }) + .onFailure(handleFail(TOKEN_URL)); + + return promise.future(); + } + + /** + * 批量获取下载链接(分批处理) + */ + private Future> getDownloadLinksBatch(List fileIds, String stoken) { + List allResults = new ArrayList<>(); + Promise> promise = Promise.promise(); + + // 同步处理每个批次 + processBatch(fileIds, stoken, 0, allResults, promise); - promise.complete("https://lz.qaiu.top"); - IntStream.range(0, 1000).forEach(num -> { - clientNoRedirects.getAbs(key).send() - .onSuccess(res -> { - String location = res.headers().get("Location"); - System.out.println(num + ":" + location); - }) - .onFailure(handleFail("连接失败")); - try { - TimeUnit.MILLISECONDS.sleep(100); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - }); return promise.future(); } - public static void main(String[] args) { + private void processBatch(List fileIds, String stoken, int startIndex, List allResults, Promise> promise) { + if (startIndex >= fileIds.size()) { + // 所有批次处理完成 + promise.complete(allResults); + return; + } + int endIndex = Math.min(startIndex + BATCH_SIZE, fileIds.size()); + List batch = fileIds.subList(startIndex, endIndex); + log.debug("正在获取第 {} 批下载链接 ({} 个文件)", startIndex / BATCH_SIZE + 1, batch.size()); + + JsonObject downloadRequest = new JsonObject() + .put("fids", new JsonArray(batch)); + + client.postAbs(DOWNLOAD_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .putHeaders(header) + .sendJsonObject(downloadRequest) + .onSuccess(res -> { + log.debug("下载链接响应: {}", res.bodyAsString()); + JsonObject resJson = asJson(res); + + if (resJson.getInteger("code") == 31001) { + promise.fail("未登录或 Cookie 已失效"); + return; + } + + if (resJson.getInteger("code") != 0) { + promise.fail(DOWNLOAD_URL + " 返回异常: " + resJson); + return; + } + + JsonArray batchData = resJson.getJsonArray("data"); + if (batchData != null) { + for (int i = 0; i < batchData.size(); i++) { + allResults.add(batchData.getJsonObject(i)); + } + log.debug("成功获取 {} 个下载链接", batchData.size()); + } + + // 处理下一批次 + processBatch(fileIds, stoken, endIndex, allResults, promise); + }) + .onFailure(t -> promise.fail("获取下载链接失败: " + t.getMessage())); } -} + + // 目录解析 + @Override + public Future> parseFileList() { + Promise> promise = Promise.promise(); + + String pwdId = shareLinkInfo.getShareKey(); + String passcode = shareLinkInfo.getSharePassword(); + final String finalPasscode = (passcode == null) ? "" : passcode; + + // 如果参数里的目录ID不为空,则直接解析目录 + String dirId = (String) shareLinkInfo.getOtherParam().get("dirId"); + if (dirId != null && !dirId.isEmpty()) { + String stoken = (String) shareLinkInfo.getOtherParam().get("stoken"); + if (stoken != null) { + parseDir(dirId, pwdId, finalPasscode, stoken, promise); + return promise.future(); + } + } + + // 第一步:获取 stoken + JsonObject tokenRequest = new JsonObject() + .put("pwd_id", pwdId) + .put("passcode", finalPasscode); + + client.postAbs(TOKEN_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .putHeaders(header) + .sendJsonObject(tokenRequest) + .onSuccess(res -> { + JsonObject resJson = asJson(res); + if (resJson.getInteger("code") != 0) { + promise.fail(TOKEN_URL + " 返回异常: " + resJson); + return; + } + String stoken = resJson.getJsonObject("data").getString("stoken"); + if (stoken == null || stoken.isEmpty()) { + promise.fail("无法获取分享 token"); + return; + } + // 解析根目录(dirId = "0") + String rootDirId = dirId != null ? dirId : "0"; + parseDir(rootDirId, pwdId, finalPasscode, stoken, promise); + }) + .onFailure(t -> promise.fail("获取 token 失败: " + t.getMessage())); + + return promise.future(); + } + + private void parseDir(String dirId, String pwdId, String passcode, String stoken, Promise> promise) { + // 第二步:获取文件列表(支持指定目录) + // 夸克 API 使用 pdir_fid 参数指定父目录 ID,根目录为 "0" + log.info("夸克 parseDir 开始: dirId={}, pwdId={}, stoken={}", dirId, pwdId, stoken); + + client.getAbs(DETAIL_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .addQueryParam("pwd_id", pwdId) + .addQueryParam("stoken", stoken) + .addQueryParam("pdir_fid", dirId != null ? dirId : "0") // 关键参数:父目录 ID + .addQueryParam("force", "0") + .addQueryParam("_page", "1") + .addQueryParam("_size", "50") + .addQueryParam("_fetch_banner", "1") + .addQueryParam("_fetch_share", "1") + .addQueryParam("_fetch_total", "1") + .addQueryParam("_sort", "file_type:asc,file_name:asc") + .putHeaders(header) + .send() + .onSuccess(res -> { + JsonObject resJson = asJson(res); + if (resJson.getInteger("code") != 0) { + promise.fail(DETAIL_URL + " 返回异常: " + resJson); + return; + } + + JsonArray fileList = resJson.getJsonObject("data").getJsonArray("list"); + if (fileList == null || fileList.isEmpty()) { + log.warn("夸克 API 返回的文件列表为空,dirId: {}, response: {}", dirId, resJson.encodePrettily()); + promise.complete(new ArrayList<>()); + return; + } + + log.info("夸克 API 返回文件列表,总数: {}, dirId: {}", fileList.size(), dirId); + List result = new ArrayList<>(); + for (int i = 0; i < fileList.size(); i++) { + JsonObject item = fileList.getJsonObject(i); + FileInfo fileInfo = new FileInfo(); + + // 调试:打印前3个 item 的完整结构 + if (i < 3) { + log.info("夸克 API 返回的 item[{}] 结构: {}", i, item.encodePrettily()); + log.info("夸克 API item[{}] 所有字段名: {}", i, item.fieldNames()); + } + + String fid = item.getString("fid"); + String fileName = item.getString("file_name"); + Boolean isFile = item.getBoolean("file", true); + Long fileSize = item.getLong("size", 0L); + String updatedAt = item.getString("updated_at"); + String objCategory = item.getString("obj_category"); + String shareFidToken = item.getString("share_fid_token"); + String parentId = item.getString("parent_id"); + + log.info("处理夸克 item[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}, objCategory={}", + i, fid, fileName, parentId, dirId, isFile, objCategory); + + fileInfo.setFileId(fid) + .setFileName(fileName) + .setSize(fileSize) + .setSizeStr(FileSizeConverter.convertToReadableSize(fileSize)) + .setCreateTime(updatedAt) + .setUpdateTime(updatedAt) + .setPanType(shareLinkInfo.getType()); + + // 判断是否为文件:file=true 或 obj_category 不为空 + if (isFile || (objCategory != null && !objCategory.isEmpty())) { + // 文件 + fileInfo.setFileType("file"); + // 保存必要的参数用于后续下载 + Map extParams = new HashMap<>(); + extParams.put("fid", fid); + extParams.put("pwd_id", pwdId); + extParams.put("stoken", stoken); + if (shareFidToken != null) { + extParams.put("share_fid_token", shareFidToken); + } + fileInfo.setExtParameters(extParams); + // 设置解析URL(用于下载) + JsonObject paramJson = new JsonObject(extParams); + String param = CommonUtils.urlBase64Encode(paramJson.encode()); + fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", + getDomainName(), shareLinkInfo.getType(), param)); + } else { + // 文件夹 + fileInfo.setFileType("folder"); + fileInfo.setSize(0L); + fileInfo.setSizeStr("0B"); + // 设置目录解析URL(用于递归解析子目录) + // 对 URL 参数进行编码,确保特殊字符正确传递 + try { + String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString()); + String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString()); + String encodedStoken = URLEncoder.encode(stoken, StandardCharsets.UTF_8.toString()); + fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s", + getDomainName(), encodedUrl, encodedDirId, encodedStoken)); + } catch (Exception e) { + // 如果编码失败,使用原始值 + fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s", + getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken)); + } + } + + result.add(fileInfo); + } + + promise.complete(result); + }) + .onFailure(t -> promise.fail("解析目录失败: " + t.getMessage())); + } + + @Override + public Future parseById() { + Promise promise = Promise.promise(); + + // 从 paramJson 中提取参数 + JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); + if (paramJson == null) { + promise.fail("缺少必要的参数"); + return promise.future(); + } + + String fid = paramJson.getString("fid"); + String pwdId = paramJson.getString("pwd_id"); + String stoken = paramJson.getString("stoken"); + String shareFidToken = paramJson.getString("share_fid_token"); + + if (fid == null || pwdId == null || stoken == null) { + promise.fail("缺少必要的参数: fid, pwd_id 或 stoken"); + return promise.future(); + } + + log.debug("夸克 parseById: fid={}, pwd_id={}, stoken={}", fid, pwdId, stoken); + + // 调用下载链接 API + JsonObject bodyJson = JsonObject.of() + .put("fids", JsonArray.of(fid)) + .put("pwd_id", pwdId) + .put("stoken", stoken); + + if (shareFidToken != null && !shareFidToken.isEmpty()) { + bodyJson.put("fids_token", JsonArray.of(shareFidToken)); + } + + client.postAbs(DOWNLOAD_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .putHeaders(header) + .sendJsonObject(bodyJson) + .onSuccess(res -> { + log.debug("夸克 parseById 响应: {}", res.bodyAsString()); + JsonObject resJson = asJson(res); + + if (resJson.getInteger("code") == 31001) { + promise.fail("未登录或 Cookie 已失效"); + return; + } + + if (resJson.getInteger("code") != 0) { + promise.fail(DOWNLOAD_URL + " 返回异常: " + resJson); + return; + } + + try { + JsonArray dataList = resJson.getJsonArray("data"); + if (dataList == null || dataList.isEmpty()) { + promise.fail("夸克 API 返回的下载链接列表为空"); + return; + } + String downloadUrl = dataList.getJsonObject(0).getString("download_url"); + if (downloadUrl == null || downloadUrl.isEmpty()) { + promise.fail("未找到下载链接"); + return; + } + promise.complete(downloadUrl); + } catch (Exception e) { + promise.fail("解析夸克下载链接失败: " + e.getMessage()); + } + }) + .onFailure(t -> promise.fail("请求下载链接失败: " + t.getMessage())); + + return promise.future(); + } +} \ No newline at end of file diff --git a/parser/src/main/java/cn/qaiu/parser/impl/UcTool.java b/parser/src/main/java/cn/qaiu/parser/impl/UcTool.java index 7e2527b..15f8953 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/UcTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/UcTool.java @@ -1,17 +1,39 @@ package cn.qaiu.parser.impl; +import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; +import cn.qaiu.util.CommonUtils; +import cn.qaiu.util.CookieUtils; +import cn.qaiu.util.DateTimeUtils; +import cn.qaiu.util.FileSizeConverter; +import cn.qaiu.util.HeaderUtils; import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Promise; +import io.vertx.core.http.HttpHeaders; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.uritemplate.UriTemplate; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + /** * UC网盘解析 */ public class UcTool extends PanBase { private static final String API_URL_PREFIX = "https://pc-api.uc.cn/1/clouddrive/"; + + // 静态变量:缓存 __puus cookie 和过期时间 + private static volatile String cachedPuus = null; + private static volatile long puusExpireTime = 0; + // __puus 有效期,默认 55 分钟(服务器实际 1 小时过期,提前 5 分钟刷新) + private static final long PUUS_TTL_MS = 55 * 60 * 1000L; public static final String SHARE_URL_PREFIX = "https://fast.uc.cn/s/"; @@ -23,19 +45,155 @@ public class UcTool extends PanBase { private static final String THIRD_REQUEST_URL = API_URL_PREFIX + "file/download?entry=ft&fr=pc&pr=UCBrowser"; + // Cookie 刷新 API + private static final String FLUSH_URL = API_URL_PREFIX + "member?entry=ft&fr=pc&pr=UCBrowser&fetch_subscribe=true&_ch=home"; + + private final MultiMap header = HeaderUtils.parseHeaders(""" + accept-language: zh-CN,zh;q=0.9,en;q=0.8 + cache-control: no-cache + dnt: 1 + origin: https://drive.uc.cn + pragma: no-cache + priority: u=1, i + referer: https://drive.uc.cn/ + sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24" + sec-ch-ua-mobile: ?0 + sec-ch-ua-platform: "Windows" + sec-fetch-dest: empty + sec-fetch-mode: cors + sec-fetch-site: same-site + user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 + """); + + // 保存 auths 引用,用于更新 cookie + private MultiMap auths; + public UcTool(ShareLinkInfo shareLinkInfo) { super(shareLinkInfo); + // 参考其它网盘实现,从认证配置中取 cookie 放到请求头 + if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) { + auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + String cookie = auths.get("cookie"); + if (cookie != null && !cookie.isEmpty()) { + // 过滤出 UC 网盘所需的 cookie 字段 + cookie = CookieUtils.filterUcQuarkCookie(cookie); + + // 如果有缓存的 __puus 且未过期,使用缓存的值更新 cookie + if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) { + cookie = CookieUtils.updateCookieValue(cookie, "__puus", cachedPuus); + log.debug("UC: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000); + } + header.set(HttpHeaders.COOKIE, cookie); + // 同步更新 auths + auths.set("cookie", cookie); + } + } + + // 如果 __puus 已过期或不存在,触发异步刷新 + if (needRefreshPuus()) { + log.debug("UC: __puus 需要刷新,触发异步刷新"); + refreshPuusCookie(); + } + } + + /** + * 判断是否需要刷新 __puus + * @return true 表示需要刷新 + */ + private boolean needRefreshPuus() { + String currentCookie = header.get(HttpHeaders.COOKIE); + if (currentCookie == null || currentCookie.isEmpty()) { + return false; + } + // 必须包含 __pus 才能刷新 + if (!currentCookie.contains("__pus=")) { + return false; + } + // 缓存过期或不存在时需要刷新 + return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime; + } + + /** + * 刷新 __puus Cookie + * 通过调用 member API,服务器会返回 set-cookie 来更新 __puus + * @return Future 包含是否刷新成功 + */ + public Future refreshPuusCookie() { + Promise refreshPromise = Promise.promise(); + + String currentCookie = header.get(HttpHeaders.COOKIE); + if (currentCookie == null || currentCookie.isEmpty()) { + log.debug("UC: 无 cookie,跳过刷新"); + refreshPromise.complete(false); + return refreshPromise.future(); + } + + // 检查是否包含 __pus(用于获取 __puus) + if (!currentCookie.contains("__pus=")) { + log.debug("UC: cookie 中不包含 __pus,跳过刷新"); + refreshPromise.complete(false); + return refreshPromise.future(); + } + + log.debug("UC: 开始刷新 __puus cookie"); + + client.getAbs(FLUSH_URL) + .putHeaders(header) + .send() + .onSuccess(res -> { + // 从响应头获取 set-cookie + List setCookies = res.cookies(); + String newPuus = null; + + for (String cookie : setCookies) { + if (cookie.startsWith("__puus=")) { + // 提取 __puus 值(只取到分号前的部分) + int endIndex = cookie.indexOf(';'); + newPuus = endIndex > 0 ? cookie.substring(0, endIndex) : cookie; + break; + } + } + + if (newPuus != null) { + // 更新 cookie:替换或添加 __puus + String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus); + header.set(HttpHeaders.COOKIE, updatedCookie); + + // 同步更新 auths 中的 cookie + if (auths != null) { + auths.set("cookie", updatedCookie); + } + + // 更新静态缓存 + cachedPuus = newPuus; + puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS; + + log.info("UC: __puus cookie 刷新成功,有效期至: {}ms", puusExpireTime); + refreshPromise.complete(true); + } else { + log.debug("UC: 响应中未包含 __puus,可能 cookie 仍然有效"); + refreshPromise.complete(false); + } + }) + .onFailure(t -> { + log.warn("UC: 刷新 __puus cookie 失败: {}", t.getMessage()); + refreshPromise.complete(false); + }); + + return refreshPromise.future(); } public Future parse() { - var dataKey = shareLinkInfo.getShareKey(); - var passcode = shareLinkInfo.getSharePassword(); + String dataKey = shareLinkInfo.getShareKey(); + String pwd = shareLinkInfo.getShareKey(); + var passcode = (pwd == null) ? "" : pwd; var jsonObject = JsonObject.of("share_for_transfer", true); jsonObject.put("pwd_id", dataKey); jsonObject.put("passcode", passcode); // 第一次请求 获取文件信息 - client.postAbs(FIRST_REQUEST_URL).sendJsonObject(jsonObject).onSuccess(res -> { + client.postAbs(FIRST_REQUEST_URL) + .putHeaders(header).sendJsonObject(jsonObject).onSuccess(res -> { log.debug("第一阶段 {}", res.body()); var resJson = res.bodyAsJsonObject(); if (resJson.getInteger("code") != 0) { @@ -48,6 +206,7 @@ public class UcTool extends PanBase { .setTemplateParam("pwd_id", dataKey) .setTemplateParam("passcode", passcode) .setTemplateParam("stoken", stoken) + .putHeaders(header) .send().onSuccess(res2 -> { log.debug("第二阶段 {}", res2.body()); JsonObject resJson2 = res2.bodyAsJsonObject(); @@ -55,24 +214,71 @@ public class UcTool extends PanBase { fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2); return; } - // 文件信息 - var info = resJson2.getJsonObject("data").getJsonArray("list").getJsonObject(0); - // 第二次请求 - var bodyJson = JsonObject.of() - .put("fids", JsonArray.of(info.getString("fid"))) - .put("pwd_id", dataKey) - .put("stoken", stoken) - .put("fids_token", JsonArray.of(info.getString("share_fid_token"))); - client.postAbs(THIRD_REQUEST_URL).sendJsonObject(bodyJson) - .onSuccess(res3 -> { - log.debug("第三阶段 {}", res3.body()); - var resJson3 = res3.bodyAsJsonObject(); - if (resJson3.getInteger("code") != 0) { - fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2); - return; - } - promise.complete(resJson3.getJsonArray("data").getJsonObject(0).getString("download_url")); - }).onFailure(handleFail(THIRD_REQUEST_URL)); + try { + // 文件信息 + JsonArray list = resJson2.getJsonObject("data").getJsonArray("list"); + if (list == null || list.isEmpty()) { + fail("UC API 返回的文件列表为空"); + return; + } + var info = list.getJsonObject(0); + + // 提取文件信息并保存到 otherParam + try { + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileId(info.getString("fid")) + .setFileName(info.getString("file_name")) + .setSize(info.getLong("size", 0L)) + .setSizeStr(FileSizeConverter.convertToReadableSize(info.getLong("size", 0L))) + .setFileType(info.getBoolean("file", true) ? "file" : "folder") + .setCreateTime(DateTimeUtils.formatTimestampToDateTime(info.getString("created_at"))) + .setUpdateTime(DateTimeUtils.formatTimestampToDateTime(info.getString("updated_at"))) + .setPanType(shareLinkInfo.getType()); + + // 保存到 otherParam,供 CacheServiceImpl 使用 + shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); + log.debug("UC 提取文件信息: {}", fileInfo.getFileName()); + } catch (Exception e) { + log.warn("UC 提取文件信息失败,继续解析: {}", e.getMessage()); + } + + // 第三次请求获取下载链接 + var bodyJson = JsonObject.of() + .put("fids", JsonArray.of(info.getString("fid"))) + .put("pwd_id", dataKey) + .put("stoken", stoken) + .put("fids_token", JsonArray.of(info.getString("share_fid_token"))); + client.postAbs(THIRD_REQUEST_URL) + .putHeaders(header) + .sendJsonObject(bodyJson) + .onSuccess(res3 -> { + log.debug("第三阶段 {}", res3.body()); + var resJson3 = res3.bodyAsJsonObject(); + if (resJson3.getInteger("code") != 0) { + fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2); + return; + } + try { + JsonArray dataList = resJson3.getJsonArray("data"); + if (dataList == null || dataList.isEmpty()) { + fail("UC API 返回的下载链接列表为空"); + return; + } + String downloadUrl = dataList.getJsonObject(0).getString("download_url"); + // UC网盘需要配合aria2下载,保存下载请求头 + Map downloadHeaders = new HashMap<>(); + // 将header转换为Map 只需要包含cookie,user-agent,referer + downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE)); + downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT)); + downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/"); + completeWithMeta(downloadUrl, downloadHeaders); + } catch (Exception e) { + fail("解析 UC 下载链接失败: " + e.getMessage()); + } + }).onFailure(handleFail(THIRD_REQUEST_URL)); + } catch (Exception e) { + fail("解析 UC 文件信息失败: " + e.getMessage()); + } }).onFailure(handleFail(SECOND_REQUEST_URL)); } @@ -80,43 +286,288 @@ public class UcTool extends PanBase { return promise.future(); } - public static void main(String[] args) { - - // https://dl-uf-zb.pds.uc.cn/l3PNAKfz/64623447/ - // 646b0de6e9f13000c9b14ba182b805312795a82a/ - // 646b0de6717e1bfa5bb44dd2a456f103c5177850? - // Expires=1737784900&OSSAccessKeyId=LTAI5tJJpWQEfrcKHnd1LqsZ& - // Signature=oBVV3anhv3tBKanHUcEIsktkB%2BM%3D&x-oss-traffic-limit=503316480 - // &response-content-disposition=attachment%3B%20filename%3DC%2523%2520Shell%2520%2528C%2523%2520Offline%2520Compiler%2529_2.5.16.apks - // %3Bfilename%2A%3Dutf-8%27%27C%2523%2520Shell%2520%2528C%2523%2520Offline%2520Compiler%2529_2.5.16.apks - - //eyJ4OmF1IjoiLSIsIng6dWQiOiI0LU4tNS0wLTYtTi0zLWZ0LTAtMi1OLU4iLCJ4OnNwIjoiMTAwIiwieDp0b2tlbiI6IjQtZjY0ZmMxMDFjZmQxZGVkNTRkMGM0NmMzYzliMzkyOWYtNS03LTE1MzYxMS1kYWNiMzY2NWJiYWE0ZjVlOWQzNzgwMGVjNjQwMzE2MC0wLTAtMC0wLTQ5YzUzNTE3OGIxOTY0YzhjYzUwYzRlMDk5MTZmYWRhIiwieDp0dGwiOiIxMDgwMCJ9 - //eyJjYWxsYmFja0JvZHlUeXBlIjoiYXBwbGljYXRpb24vanNvbiIsImNhbGxiYWNrU3RhZ2UiOiJiZWZvcmUtZXhlY3V0ZSIsImNhbGxiYWNrRmFpbHVyZUFjdGlvbiI6Imlnbm9yZSIsImNhbGxiYWNrVXJsIjoiaHR0cHM6Ly9hdXRoLWNkbi51Yy5jbi9vdXRlci9vc3MvY2hlY2twbGF5IiwiY2FsbGJhY2tCb2R5Ijoie1wiaG9zdFwiOiR7aHR0cEhlYWRlci5ob3N0fSxcInNpemVcIjoke3NpemV9LFwicmFuZ2VcIjoke2h0dHBIZWFkZXIucmFuZ2V9LFwicmVmZXJlclwiOiR7aHR0cEhlYWRlci5yZWZlcmVyfSxcImNvb2tpZVwiOiR7aHR0cEhlYWRlci5jb29raWV9LFwibWV0aG9kXCI6JHtodHRwSGVhZGVyLm1ldGhvZH0sXCJpcFwiOiR7Y2xpZW50SXB9LFwicG9ydFwiOiR7Y2xpZW50UG9ydH0sXCJvYmplY3RcIjoke29iamVjdH0sXCJzcFwiOiR7eDpzcH0sXCJ1ZFwiOiR7eDp1ZH0sXCJ0b2tlblwiOiR7eDp0b2tlbn0sXCJhdVwiOiR7eDphdX0sXCJ0dGxcIjoke3g6dHRsfSxcImR0X3NwXCI6JHt4OmR0X3NwfSxcImhzcFwiOiR7eDpoc3B9LFwiY2xpZW50X3Rva2VuXCI6JHtxdWVyeVN0cmluZy5jbGllbnRfdG9rZW59fSJ9 - //callback-var {"x:au":"-","x:ud":"4-N-5-0-6-N-3-ft-0-2-N-N","x:sp":"100","x:token":"4-f64fc101cfd1ded54d0c46c3c9b3929f-5-7-153611-dacb3665bbaa4f5e9d37800ec6403160-0-0-0-0-49c535178b1964c8cc50c4e09916fada","x:ttl":"10800"} - //callback {"callbackBodyType":"application/json","callbackStage":"before-execute","callbackFailureAction":"ignore","callbackUrl":"https://auth-cdn.uc.cn/outer/oss/checkplay","callbackBody":"{\"host\":${httpHeader.host},\"size\":${size},\"range\":${httpHeader.range},\"referer\":${httpHeader.referer},\"cookie\":${httpHeader.cookie},\"method\":${httpHeader.method},\"ip\":${clientIp},\"port\":${clientPort},\"object\":${object},\"sp\":${x:sp},\"ud\":${x:ud},\"token\":${x:token},\"au\":${x:au},\"ttl\":${x:ttl},\"dt_sp\":${x:dt_sp},\"hsp\":${x:hsp},\"client_token\":${queryString.client_token}}"} - - /* - // callback-var -{ - "x:au": "-", - "x:ud": "4-N-5-0-6-N-3-ft-0-2-N-N", - "x:sp": "100", - "x:token": "4-f64fc101cfd1ded54d0c46c3c9b3929f-5-7-153611-dacb3665bbaa4f5e9d37800ec6403160-0-0-0-0-49c535178b1964c8cc50c4e09916fada", - "x:ttl": "10800" -} - -// callback -{ - "callbackBodyType": "application/json", - "callbackStage": "before-execute", - "callbackFailureAction": "ignore", - "callbackUrl": "https://auth-cdn.uc.cn/outer/oss/checkplay", - "callbackBody": "{\"host\":${httpHeader.host},\"size\":${size},\"range\":${httpHeader.range},\"referer\":${httpHeader.referer},\"cookie\":${httpHeader.cookie},\"method\":${httpHeader.method},\"ip\":${clientIp},\"port\":${clientPort},\"object\":${object},\"sp\":${x:sp},\"ud\":${x:ud},\"token\":${x:token},\"au\":${x:au},\"ttl\":${x:ttl},\"dt_sp\":${x:dt_sp},\"hsp\":${x:hsp},\"client_token\":${queryString.client_token}}" -} - */ - - new UcTool(ShareLinkInfo.newBuilder().shareUrl("https://fast.uc.cn/s/33197dd53ace4").shareKey("33197dd53ace4").build()).parse().onSuccess( - System.out::println - ); + // 目录解析 + @Override + public Future> parseFileList() { + Promise> promise = Promise.promise(); + + String pwdId = shareLinkInfo.getShareKey(); + String passcode = shareLinkInfo.getSharePassword(); + final String finalPasscode = (passcode == null) ? "" : passcode; + + // 如果参数里的目录ID不为空,则直接解析目录 + String dirId = (String) shareLinkInfo.getOtherParam().get("dirId"); + if (dirId != null && !dirId.isEmpty()) { + String stoken = (String) shareLinkInfo.getOtherParam().get("stoken"); + if (stoken != null) { + parseDir(dirId, pwdId, finalPasscode, stoken, promise); + return promise.future(); + } + } + + // 第一步:获取 stoken + JsonObject tokenRequest = JsonObject.of("share_for_transfer", true) + .put("pwd_id", pwdId) + .put("passcode", finalPasscode); + + client.postAbs(FIRST_REQUEST_URL) + .putHeaders(header) + .sendJsonObject(tokenRequest) + .onSuccess(res -> { + JsonObject resJson = res.bodyAsJsonObject(); + if (resJson.getInteger("code") != 0) { + promise.fail(FIRST_REQUEST_URL + " 返回异常: " + resJson); + return; + } + String stoken = resJson.getJsonObject("data").getString("stoken"); + if (stoken == null || stoken.isEmpty()) { + promise.fail("无法获取分享 token"); + return; + } + // 解析根目录(dirId = "0" 或空) + String rootDirId = dirId != null ? dirId : "0"; + parseDir(rootDirId, pwdId, finalPasscode, stoken, promise); + }) + .onFailure(t -> promise.fail("获取 token 失败: " + t.getMessage())); + + return promise.future(); } + + private void parseDir(String dirId, String pwdId, String passcode, String stoken, Promise> promise) { + // 第二步:获取文件列表 + // UC API 使用 pdir_fid 参数指定父目录 ID,根目录为 "0" + log.info("UC parseDir 开始: dirId={}, pwdId={}, stoken={}", dirId, pwdId, stoken); + + client.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + .setTemplateParam("pwd_id", pwdId) + .setTemplateParam("passcode", passcode) + .setTemplateParam("stoken", stoken) + .addQueryParam("entry", "ft") + .addQueryParam("pdir_fid", dirId != null ? dirId : "0") // 关键参数:父目录 ID + .addQueryParam("fetch_file_list", "1") + .addQueryParam("_page", "1") + .addQueryParam("_size", "50") + .addQueryParam("_fetch_total", "1") + .addQueryParam("_fetch_share", "1") + .addQueryParam("_sort", "file_type:asc,file_name:asc") + .addQueryParam("fr", "pc") + .addQueryParam("pr", "UCBrowser") + .putHeaders(header) + .send() + .onSuccess(res -> { + JsonObject resJson = res.bodyAsJsonObject(); + Integer code = resJson.getInteger("code"); + String message = resJson.getString("message"); + // 如果 stoken 失效(code=14001 或错误消息包含"token"),重新获取 stoken 后重试 + if ((code != null && code == 14001) || + (message != null && (message.contains("token") || message.contains("Token") || message.contains("非法token")))) { + log.debug("stoken 已失效,重新获取: {}", resJson); + // 重新获取 stoken + JsonObject tokenRequest = JsonObject.of("share_for_transfer", true) + .put("pwd_id", pwdId) + .put("passcode", passcode); + client.postAbs(FIRST_REQUEST_URL) + .putHeaders(header) + .sendJsonObject(tokenRequest) + .onSuccess(res2 -> { + JsonObject resJson2 = res2.bodyAsJsonObject(); + if (resJson2.getInteger("code") != 0) { + promise.fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2); + return; + } + String newStoken = resJson2.getJsonObject("data").getString("stoken"); + if (newStoken == null || newStoken.isEmpty()) { + promise.fail("无法获取分享 token"); + return; + } + // 使用新的 stoken 重试 + parseDir(dirId, pwdId, passcode, newStoken, promise); + }) + .onFailure(t -> promise.fail("重新获取 token 失败: " + t.getMessage())); + return; + } + if (resJson.getInteger("code") != 0) { + promise.fail(SECOND_REQUEST_URL + " 返回异常: " + resJson); + return; + } + + JsonArray fileList = resJson.getJsonObject("data").getJsonArray("list"); + if (fileList == null || fileList.isEmpty()) { + log.warn("UC API 返回的文件列表为空,dirId: {}, response: {}", dirId, resJson.encodePrettily()); + promise.complete(new ArrayList<>()); + return; + } + + log.info("UC API 返回文件列表,总数: {}, dirId: {}", fileList.size(), dirId); + List result = new ArrayList<>(); + for (int i = 0; i < fileList.size(); i++) { + JsonObject item = fileList.getJsonObject(i); + FileInfo fileInfo = new FileInfo(); + + // 调试:打印前3个 item 的完整结构,方便排查字段名 + if (i < 3) { + log.info("UC API 返回的 item[{}] 结构: {}", i, item.encodePrettily()); + log.info("UC API item[{}] 所有字段名: {}", i, item.fieldNames()); + } + + String fid = item.getString("fid"); + // UC API 可能使用 file_name 或 name,优先尝试 file_name + String fileName = item.getString("file_name"); + if (fileName == null || fileName.isEmpty()) { + fileName = item.getString("name"); + } + // 如果还是为空,尝试其他可能的字段名 + if (fileName == null || fileName.isEmpty()) { + fileName = item.getString("fileName"); + } + if (fileName == null || fileName.isEmpty()) { + fileName = item.getString("title"); + } + + // 如果文件名仍为空,记录警告 + if (fileName == null || fileName.isEmpty()) { + log.warn("UC API 返回的 item 中未找到文件名字段,item: {}", item.encode()); + } + Boolean isFile = item.getBoolean("file", true); + Long fileSize = item.getLong("size", 0L); + String updatedAt = item.getString("updated_at"); + String shareFidToken = item.getString("share_fid_token"); + String parentId = item.getString("parent_id"); + + // 临时移除过滤逻辑,查看 API 实际返回数据 + log.info("准备处理 item[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}", i, fid, fileName, parentId, dirId, isFile); + + // 如果当前项的 fid 等于请求的 dirId,说明是当前目录本身,跳过 + if (fid != null && fid.equals(dirId) && !"0".equals(dirId)) { + log.info("跳过当前目录本身: fid={}, dirId={}, fileName={}", fid, dirId, fileName); + continue; + } + + // UC API 可能不支持目录参数,返回所有文件 + // 暂时不过滤,返回所有文件,查看实际数据 + log.info("添加文件到结果[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}", i, fid, fileName, parentId, dirId, isFile); + + fileInfo.setFileId(fid) + .setFileName(fileName) + .setSize(fileSize) + .setSizeStr(FileSizeConverter.convertToReadableSize(fileSize)) + .setCreateTime(DateTimeUtils.formatTimestampToDateTime(updatedAt)) + .setUpdateTime(DateTimeUtils.formatTimestampToDateTime(updatedAt)) + .setPanType(shareLinkInfo.getType()); + + if (isFile) { + // 文件 + fileInfo.setFileType("file"); + // 保存必要的参数用于后续下载 + Map extParams = new HashMap<>(); + extParams.put("fid", fid); + extParams.put("pwd_id", pwdId); + extParams.put("stoken", stoken); + if (shareFidToken != null) { + extParams.put("share_fid_token", shareFidToken); + } + fileInfo.setExtParameters(extParams); + // 设置解析URL(用于下载) + JsonObject paramJson = new JsonObject(extParams); + String param = CommonUtils.urlBase64Encode(paramJson.encode()); + fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", + getDomainName(), shareLinkInfo.getType(), param)); + } else { + // 文件夹 + fileInfo.setFileType("folder"); + fileInfo.setSize(0L); + fileInfo.setSizeStr("0B"); + // 设置目录解析URL(用于递归解析子目录) + // 对 URL 参数进行编码,确保特殊字符正确传递 + try { + String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString()); + String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString()); + String encodedStoken = URLEncoder.encode(stoken, StandardCharsets.UTF_8.toString()); + fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s", + getDomainName(), encodedUrl, encodedDirId, encodedStoken)); + } catch (Exception e) { + // 如果编码失败,使用原始值 + fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s", + getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken)); + } + } + + result.add(fileInfo); + } + + promise.complete(result); + }) + .onFailure(t -> promise.fail("解析目录失败: " + t.getMessage())); + } + + @Override + public Future parseById() { + Promise promise = Promise.promise(); + + // 从 paramJson 中提取参数 + JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); + if (paramJson == null) { + promise.fail("缺少必要的参数"); + return promise.future(); + } + + String fid = paramJson.getString("fid"); + String pwdId = paramJson.getString("pwd_id"); + String stoken = paramJson.getString("stoken"); + String shareFidToken = paramJson.getString("share_fid_token"); + + if (fid == null || pwdId == null || stoken == null) { + promise.fail("缺少必要的参数: fid, pwd_id 或 stoken"); + return promise.future(); + } + + log.debug("UC parseById: fid={}, pwd_id={}, stoken={}", fid, pwdId, stoken); + + // 调用第三次请求获取下载链接 + JsonObject bodyJson = JsonObject.of() + .put("fids", JsonArray.of(fid)) + .put("pwd_id", pwdId) + .put("stoken", stoken); + + if (shareFidToken != null && !shareFidToken.isEmpty()) { + bodyJson.put("fids_token", JsonArray.of(shareFidToken)); + } + + client.postAbs(THIRD_REQUEST_URL) + .putHeaders(header) + .sendJsonObject(bodyJson) + .onSuccess(res -> { + log.debug("UC parseById 响应: {}", res.body()); + JsonObject resJson = res.bodyAsJsonObject(); + if (resJson.getInteger("code") != 0) { + promise.fail(THIRD_REQUEST_URL + " 返回异常: " + resJson); + return; + } + try { + JsonArray dataList = resJson.getJsonArray("data"); + if (dataList == null || dataList.isEmpty()) { + promise.fail("UC API 返回的下载链接列表为空"); + return; + } + String downloadUrl = dataList.getJsonObject(0).getString("download_url"); + if (downloadUrl == null || downloadUrl.isEmpty()) { + promise.fail("未找到下载链接"); + return; + } + promise.complete(downloadUrl); + } catch (Exception e) { + promise.fail("解析 UC 下载链接失败: " + e.getMessage()); + } + }) + .onFailure(t -> promise.fail("请求下载链接失败: " + t.getMessage())); + + return promise.future(); + } + +// public static void main(String[] args) { +// // https://drive.uc.cn/s/12450d1694844?public=1 +// new UcTool(ShareLinkInfo.newBuilder().shareKey("12450d1694844").build()).parse().onSuccess( +// System.out::println +// ); +// } } diff --git a/parser/src/main/java/cn/qaiu/util/CookieUtils.java b/parser/src/main/java/cn/qaiu/util/CookieUtils.java new file mode 100644 index 0000000..48834a7 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/util/CookieUtils.java @@ -0,0 +1,185 @@ +package cn.qaiu.util; + +import java.util.*; + +/** + * Cookie 工具类 + * 用于过滤和处理 Cookie 字符串 + */ +public class CookieUtils { + + /** + * UC/夸克网盘常用的 Cookie 字段 + */ + public static final List UC_QUARK_COOKIE_KEYS = Arrays.asList( + "__pus", // 主要的用户会话标识(最重要) + "__kp", // 用户标识 + "__kps", // 会话密钥 + "__ktd", // 会话令牌 + "__uid", // 用户ID + "__puus" // 用户会话签名 + ); + + /** + * 根据指定的 key 列表过滤 cookie + * + * @param cookieStr 原始 cookie 字符串,格式如 "key1=value1; key2=value2" + * @param keys 需要保留的 cookie key 列表 + * @return 过滤后的 cookie 字符串,只包含指定的 key + */ + public static String filterCookie(String cookieStr, List keys) { + if (cookieStr == null || cookieStr.isEmpty()) { + return ""; + } + if (keys == null || keys.isEmpty()) { + return cookieStr; + } + + // 将 keys 转为 Set 以提高查找效率 + Set keySet = new HashSet<>(keys); + + StringBuilder result = new StringBuilder(); + String[] cookies = cookieStr.split(";\\s*"); + + for (String cookie : cookies) { + if (cookie.isEmpty()) { + continue; + } + + // 提取 cookie 的 key + int equalIndex = cookie.indexOf('='); + if (equalIndex > 0) { + String key = cookie.substring(0, equalIndex).trim(); + + // 如果 key 在需要的列表中,保留这个 cookie + if (keySet.contains(key)) { + if (result.length() > 0) { + result.append("; "); + } + result.append(cookie); + } + } + } + + return result.toString(); + } + + /** + * 使用 UC/夸克网盘默认的 cookie 字段过滤 + * + * @param cookieStr 原始 cookie 字符串 + * @return 过滤后的 cookie 字符串 + */ + public static String filterUcQuarkCookie(String cookieStr) { + return filterCookie(cookieStr, UC_QUARK_COOKIE_KEYS); + } + + /** + * 从 cookie 字符串中提取指定 key 的值 + * + * @param cookieStr cookie 字符串 + * @param key 要提取的 cookie key + * @return cookie 值,如果不存在返回 null + */ + public static String getCookieValue(String cookieStr, String key) { + if (cookieStr == null || cookieStr.isEmpty() || key == null) { + return null; + } + + String[] cookies = cookieStr.split(";\\s*"); + for (String cookie : cookies) { + if (cookie.startsWith(key + "=")) { + int equalIndex = cookie.indexOf('='); + if (equalIndex > 0 && equalIndex < cookie.length() - 1) { + return cookie.substring(equalIndex + 1); + } + } + } + + return null; + } + + /** + * 检查 cookie 字符串中是否包含指定的 key + * + * @param cookieStr cookie 字符串 + * @param key 要检查的 cookie key + * @return true 表示包含该 key + */ + public static boolean containsKey(String cookieStr, String key) { + return getCookieValue(cookieStr, key) != null; + } + + /** + * 更新 cookie 字符串中的指定 cookie 值 + * + * @param cookieStr 原始 cookie 字符串 + * @param cookieName cookie 名称 + * @param newValue 新的完整 cookie 值(格式:cookieName=value) + * @return 更新后的 cookie 字符串 + */ + public static String updateCookieValue(String cookieStr, String cookieName, String newValue) { + if (cookieStr == null || cookieStr.isEmpty()) { + return newValue; + } + + StringBuilder result = new StringBuilder(); + String[] cookies = cookieStr.split(";\\s*"); + boolean found = false; + + for (String cookie : cookies) { + if (cookie.startsWith(cookieName + "=")) { + // 替换为新值 + if (result.length() > 0) result.append("; "); + result.append(newValue); + found = true; + } else if (!cookie.isEmpty()) { + if (result.length() > 0) result.append("; "); + result.append(cookie); + } + } + + // 如果原来没有这个 cookie,添加它 + if (!found) { + if (result.length() > 0) result.append("; "); + result.append(newValue); + } + + return result.toString(); + } + + /** + * 合并多个 cookie 字符串,后面的会覆盖前面的同名 cookie + * + * @param cookieStrings cookie 字符串数组 + * @return 合并后的 cookie 字符串 + */ + public static String mergeCookies(String... cookieStrings) { + if (cookieStrings == null || cookieStrings.length == 0) { + return ""; + } + + Map cookieMap = new LinkedHashMap<>(); + + for (String cookieStr : cookieStrings) { + if (cookieStr == null || cookieStr.isEmpty()) { + continue; + } + + String[] cookies = cookieStr.split(";\\s*"); + for (String cookie : cookies) { + if (cookie.isEmpty()) { + continue; + } + + int equalIndex = cookie.indexOf('='); + if (equalIndex > 0) { + String key = cookie.substring(0, equalIndex).trim(); + cookieMap.put(key, cookie); + } + } + } + + return String.join("; ", cookieMap.values()); + } +} diff --git a/parser/src/main/java/cn/qaiu/util/DateTimeUtils.java b/parser/src/main/java/cn/qaiu/util/DateTimeUtils.java new file mode 100644 index 0000000..e57c397 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/util/DateTimeUtils.java @@ -0,0 +1,111 @@ +package cn.qaiu.util; + +import java.time.*; +import java.time.format.DateTimeFormatter; + +/** + * 日期时间工具类,用于转换各种时间戳格式为可读的日期字符串 + */ +public class DateTimeUtils { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + /** + * 将毫秒时间戳转换为 yyyy-MM-dd HH:mm:ss 格式 + * @param millis 毫秒时间戳 + * @return 格式化后的日期字符串 + */ + public static String formatMillisToDateTime(long millis) { + return FORMATTER.format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDateTime()); + } + + /** + * 将毫秒时间戳字符串转换为 yyyy-MM-dd HH:mm:ss 格式 + * @param millisStr 毫秒时间戳字符串(如 "1684737715067") + * @return 格式化后的日期字符串 + */ + public static String formatMillisStringToDateTime(String millisStr) { + if (millisStr == null || millisStr.trim().isEmpty()) { + return ""; + } + try { + long millis = Long.parseLong(millisStr.trim()); + return formatMillisToDateTime(millis); + } catch (NumberFormatException e) { + // 如果解析失败,返回原始值 + return millisStr; + } + } + + /** + * 将秒级时间戳转换为 yyyy-MM-dd HH:mm:ss 格式 + * @param seconds 秒级时间戳 + * @return 格式化后的日期字符串 + */ + public static String formatSecondsToDateTime(long seconds) { + return FORMATTER.format(Instant.ofEpochSecond(seconds).atZone(ZoneId.systemDefault()).toLocalDateTime()); + } + + /** + * 将秒级时间戳字符串转换为 yyyy-MM-dd HH:mm:ss 格式 + * @param secondsStr 秒级时间戳字符串 + * @return 格式化后的日期字符串 + */ + public static String formatSecondsStringToDateTime(String secondsStr) { + if (secondsStr == null || secondsStr.trim().isEmpty()) { + return ""; + } + try { + long seconds = Long.parseLong(secondsStr.trim()); + return formatSecondsToDateTime(seconds); + } catch (NumberFormatException e) { + // 如果解析失败,返回原始值 + return secondsStr; + } + } + + /** + * 智能转换时间戳:自动判断是毫秒还是秒级 + * 根据值的大小判断:如果大于等于 10000000000(即 2286-11-20),视为毫秒;否则视为秒级 + * @param timestamp 时间戳字符串 + * @return 格式化后的日期字符串 + */ + public static String formatTimestampToDateTime(String timestamp) { + if (timestamp == null || timestamp.trim().isEmpty()) { + return ""; + } + try { + long value = Long.parseLong(timestamp.trim()); + // 10000000000 对应 2286-11-20(毫秒)或 1970-04-26(秒级) + // 使用 10^10 作为分界线 + if (value >= 10_000_000_000L) { + return formatMillisToDateTime(value); + } else { + return formatSecondsToDateTime(value); + } + } catch (NumberFormatException e) { + // 如果是 ISO 8601 格式,尝试解析 + return formatISODateTime(timestamp); + } + } + + /** + * 解析并格式化 ISO 8601 格式的日期时间字符串 + * @param isoDateTime ISO 8601 格式的日期时间字符串 + * @return 格式化后的日期字符串 + */ + public static String formatISODateTime(String isoDateTime) { + if (isoDateTime == null || isoDateTime.trim().isEmpty()) { + return ""; + } + try { + OffsetDateTime offsetDateTime = OffsetDateTime.parse(isoDateTime, ISO_FORMATTER); + return FORMATTER.format(offsetDateTime.toLocalDateTime()); + } catch (Exception e) { + // 如果格式化失败,直接返回原始值 + return isoDateTime; + } + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/auth/AuthParamTest.java b/parser/src/test/java/cn/qaiu/parser/auth/AuthParamTest.java new file mode 100644 index 0000000..061ca8e --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/auth/AuthParamTest.java @@ -0,0 +1,209 @@ +package cn.qaiu.parser.auth; + +import cn.qaiu.util.CookieUtils; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Cookie 工具测试 + */ +public class AuthParamTest { + + @Test + public void testCookieFilter() { + System.out.println("\n=== 测试 Cookie 过滤功能 ==="); + // 测试 Cookie 过滤功能 + String fullCookie = "__pus=abc123; __kp=def456; other_cookie=xyz; __puus=token789; random=test; __uid=user001"; + String filtered = CookieUtils.filterUcQuarkCookie(fullCookie); + + System.out.println("原始 Cookie: " + fullCookie); + System.out.println("过滤后 Cookie: " + filtered); + + // 验证包含必要字段 + assertTrue("应包含 __pus", filtered.contains("__pus=abc123")); + assertTrue("应包含 __kp", filtered.contains("__kp=def456")); + assertTrue("应包含 __puus", filtered.contains("__puus=token789")); + assertTrue("应包含 __uid", filtered.contains("__uid=user001")); + + // 验证不包含不必要字段 + assertFalse("不应包含 other_cookie", filtered.contains("other_cookie")); + assertFalse("不应包含 random", filtered.contains("random")); + + System.out.println("✓ Cookie 过滤测试通过"); + } + + @Test + public void testCookieGetValue() { + System.out.println("\n=== 测试获取 Cookie 值 ==="); + String cookie = "__pus=value1; __kp=value2; __puus=value3"; + + String pus = CookieUtils.getCookieValue(cookie, "__pus"); + String kp = CookieUtils.getCookieValue(cookie, "__kp"); + String puus = CookieUtils.getCookieValue(cookie, "__puus"); + String notexist = CookieUtils.getCookieValue(cookie, "notexist"); + + System.out.println("Cookie: " + cookie); + System.out.println("__pus = " + pus); + System.out.println("__kp = " + kp); + System.out.println("__puus = " + puus); + System.out.println("notexist = " + notexist); + + assertEquals("value1", pus); + assertEquals("value2", kp); + assertEquals("value3", puus); + assertNull(notexist); + + System.out.println("✓ 获取 Cookie 值测试通过"); + } + + @Test + public void testCookieUpdate() { + System.out.println("\n=== 测试更新 Cookie ==="); + String cookie = "__pus=old_pus; __kp=value2"; + String updated = CookieUtils.updateCookieValue(cookie, "__puus", "__puus=new_puus_value"); + + System.out.println("更新前: " + cookie); + System.out.println("更新后: " + updated); + + assertTrue("应包含新的 __puus", updated.contains("__puus=new_puus_value")); + assertTrue("应保留 __pus", updated.contains("__pus=old_pus")); + assertTrue("应保留 __kp", updated.contains("__kp=value2")); + + // 测试替换已存在的值 + String cookie2 = "__pus=old_value; __kp=value2; __puus=old_puus"; + String updated2 = CookieUtils.updateCookieValue(cookie2, "__puus", "__puus=updated_puus"); + + System.out.println("替换测试 - 更新前: " + cookie2); + System.out.println("替换测试 - 更新后: " + updated2); + + assertTrue("应包含更新的 __puus", updated2.contains("__puus=updated_puus")); + assertFalse("不应包含旧的 __puus", updated2.contains("__puus=old_puus")); + + System.out.println("✓ 更新 Cookie 测试通过"); + } + + @Test + public void testCookieContainsKey() { + System.out.println("\n=== 测试检查 Cookie key 存在性 ==="); + String cookie = "__pus=value1; __kp=value2; __uid=user123"; + + boolean hasPus = CookieUtils.containsKey(cookie, "__pus"); + boolean hasKp = CookieUtils.containsKey(cookie, "__kp"); + boolean hasPuus = CookieUtils.containsKey(cookie, "__puus"); + boolean hasNotexist = CookieUtils.containsKey(cookie, "notexist"); + + System.out.println("Cookie: " + cookie); + System.out.println("containsKey(__pus): " + hasPus); + System.out.println("containsKey(__kp): " + hasKp); + System.out.println("containsKey(__puus): " + hasPuus); + System.out.println("containsKey(notexist): " + hasNotexist); + + assertTrue(hasPus); + assertTrue(hasKp); + assertFalse(hasPuus); + assertFalse(hasNotexist); + + System.out.println("✓ 检查 Cookie key 测试通过"); + } + + @Test + public void testEmptyCookieHandling() { + System.out.println("\n=== 测试空 Cookie 处理 ==="); + // 测试空 Cookie 处理 + String emptyFiltered = CookieUtils.filterUcQuarkCookie(""); + String nullFiltered = CookieUtils.filterUcQuarkCookie(null); + String emptyValue = CookieUtils.getCookieValue("", "__pus"); + String nullValue = CookieUtils.getCookieValue(null, "__pus"); + + System.out.println("filterUcQuarkCookie(''): '" + emptyFiltered + "'"); + System.out.println("filterUcQuarkCookie(null): '" + nullFiltered + "'"); + System.out.println("getCookieValue('', '__pus'): " + emptyValue); + System.out.println("getCookieValue(null, '__pus'): " + nullValue); + + assertEquals("", emptyFiltered); + assertEquals("", nullFiltered); + assertNull(emptyValue); + assertNull(nullValue); + + System.out.println("✓ 空 Cookie 处理测试通过"); + } + + @Test + public void testComplexCookieScenario() { + System.out.println("\n=== 测试复杂场景:模拟 UC/夸克 Cookie 处理流程 ==="); + + // 模拟从浏览器获取的完整 Cookie + String browserCookie = "session_id=xxx; __pus=main_token_here; other=value; " + + "__kp=key123; __kps=secret456; __ktd=token789; " + + "__uid=user001; random_cookie=test; __puus=old_signature"; + + System.out.println("1. 浏览器原始 Cookie:"); + System.out.println(" " + browserCookie); + + // 第一步:过滤出必要的字段 + String filtered = CookieUtils.filterUcQuarkCookie(browserCookie); + System.out.println("\n2. 过滤后的 Cookie (只保留 UC/夸克必需字段):"); + System.out.println(" " + filtered); + + // 验证过滤结果 + assertTrue("应包含 __pus", filtered.contains("__pus=main_token_here")); + assertTrue("应包含 __kp", filtered.contains("__kp=key123")); + assertTrue("应包含 __puus", filtered.contains("__puus=old_signature")); + assertFalse("不应包含 session_id", filtered.contains("session_id")); + assertFalse("不应包含 random_cookie", filtered.contains("random_cookie")); + + // 第二步:模拟刷新 __puus (从服务器获取新的签名) + String newPuus = "__puus=refreshed_signature_from_server"; + String updated = CookieUtils.updateCookieValue(filtered, "__puus", newPuus); + System.out.println("\n3. 刷新 __puus 后的 Cookie:"); + System.out.println(" " + updated); + + // 验证更新结果 + assertTrue("应包含新的 __puus", updated.contains("__puus=refreshed_signature_from_server")); + assertFalse("不应包含旧的 __puus", updated.contains("__puus=old_signature")); + assertTrue("应保留 __pus", updated.contains("__pus=main_token_here")); + + // 第三步:验证可以获取单个值 + String pusValue = CookieUtils.getCookieValue(updated, "__pus"); + String puusValue = CookieUtils.getCookieValue(updated, "__puus"); + System.out.println("\n4. 提取单个 Cookie 值:"); + System.out.println(" __pus = " + pusValue); + System.out.println(" __puus = " + puusValue); + + assertEquals("main_token_here", pusValue); + assertEquals("refreshed_signature_from_server", puusValue); + + System.out.println("\n✓ 复杂场景测试通过"); + } + + @Test + public void testAllUcQuarkCookieFields() { + System.out.println("\n=== 测试所有 UC/夸克 Cookie 必需字段 ==="); + + // 包含所有必需字段的 Cookie + String fullCookie = "__pus=token1; __kp=token2; __kps=token3; " + + "__ktd=token4; __uid=token5; __puus=token6; " + + "extra1=value1; extra2=value2"; + + String filtered = CookieUtils.filterUcQuarkCookie(fullCookie); + + System.out.println("原始 Cookie: " + fullCookie); + System.out.println("过滤后: " + filtered); + System.out.println("\n验证必需字段:"); + + // 验证所有必需字段都被保留 + String[] requiredFields = {"__pus", "__kp", "__kps", "__ktd", "__uid", "__puus"}; + for (String field : requiredFields) { + boolean contains = CookieUtils.containsKey(filtered, field); + System.out.println(" - " + field + ": " + (contains ? "✓" : "✗")); + assertTrue("应包含 " + field, contains); + } + + // 验证额外字段被过滤掉 + assertFalse("不应包含 extra1", filtered.contains("extra1")); + assertFalse("不应包含 extra2", filtered.contains("extra2")); + + System.out.println("\n✓ 所有字段测试通过"); + } +} \ No newline at end of file diff --git a/parser/src/test/java/cn/qaiu/parser/auth/CookieUtilsManualTest.java b/parser/src/test/java/cn/qaiu/parser/auth/CookieUtilsManualTest.java new file mode 100644 index 0000000..b6b25c5 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/auth/CookieUtilsManualTest.java @@ -0,0 +1,209 @@ +package cn.qaiu.parser.auth; + +import cn.qaiu.util.CookieUtils; + +/** + * 手动测试 Cookie 工具类 + */ +public class CookieUtilsManualTest { + + public static void main(String[] args) { + System.out.println("========================================"); + System.out.println(" Cookie 工具类手动测试"); + System.out.println("========================================\n"); + + testCookieFilter(); + testCookieGetValue(); + testCookieUpdate(); + testCookieContainsKey(); + testEmptyCookieHandling(); + testComplexScenario(); + testAllUcQuarkCookieFields(); + + System.out.println("\n========================================"); + System.out.println(" 所有测试通过! ✓"); + System.out.println("========================================"); + } + + private static void testCookieFilter() { + System.out.println("=== 测试 Cookie 过滤功能 ==="); + String fullCookie = "__pus=abc123; __kp=def456; other_cookie=xyz; __puus=token789; random=test; __uid=user001"; + String filtered = CookieUtils.filterUcQuarkCookie(fullCookie); + + System.out.println("原始 Cookie: " + fullCookie); + System.out.println("过滤后 Cookie: " + filtered); + + assert filtered.contains("__pus=abc123") : "应包含 __pus"; + assert filtered.contains("__kp=def456") : "应包含 __kp"; + assert filtered.contains("__puus=token789") : "应包含 __puus"; + assert !filtered.contains("other_cookie") : "不应包含 other_cookie"; + assert !filtered.contains("random") : "不应包含 random"; + + System.out.println("✓ Cookie 过滤测试通过\n"); + } + + private static void testCookieGetValue() { + System.out.println("=== 测试获取 Cookie 值 ==="); + String cookie = "__pus=value1; __kp=value2; __puus=value3"; + + String pus = CookieUtils.getCookieValue(cookie, "__pus"); + String kp = CookieUtils.getCookieValue(cookie, "__kp"); + String puus = CookieUtils.getCookieValue(cookie, "__puus"); + String notexist = CookieUtils.getCookieValue(cookie, "notexist"); + + System.out.println("Cookie: " + cookie); + System.out.println("__pus = " + pus); + System.out.println("__kp = " + kp); + System.out.println("__puus = " + puus); + System.out.println("notexist = " + notexist); + + assert "value1".equals(pus) : "__pus 应为 value1"; + assert "value2".equals(kp) : "__kp 应为 value2"; + assert "value3".equals(puus) : "__puus 应为 value3"; + assert notexist == null : "notexist 应为 null"; + + System.out.println("✓ 获取 Cookie 值测试通过\n"); + } + + private static void testCookieUpdate() { + System.out.println("=== 测试更新 Cookie ==="); + String cookie = "__pus=old_pus; __kp=value2"; + String updated = CookieUtils.updateCookieValue(cookie, "__puus", "__puus=new_puus_value"); + + System.out.println("更新前: " + cookie); + System.out.println("更新后: " + updated); + + assert updated.contains("__puus=new_puus_value") : "应包含新的 __puus"; + assert updated.contains("__pus=old_pus") : "应保留 __pus"; + assert updated.contains("__kp=value2") : "应保留 __kp"; + + // 测试替换已存在的值 + String cookie2 = "__pus=old_value; __kp=value2; __puus=old_puus"; + String updated2 = CookieUtils.updateCookieValue(cookie2, "__puus", "__puus=updated_puus"); + + System.out.println("替换测试 - 更新前: " + cookie2); + System.out.println("替换测试 - 更新后: " + updated2); + + assert updated2.contains("__puus=updated_puus") : "应包含更新的 __puus"; + assert !updated2.contains("__puus=old_puus") : "不应包含旧的 __puus"; + + System.out.println("✓ 更新 Cookie 测试通过\n"); + } + + private static void testCookieContainsKey() { + System.out.println("=== 测试检查 Cookie key 存在性 ==="); + String cookie = "__pus=value1; __kp=value2; __uid=user123"; + + boolean hasPus = CookieUtils.containsKey(cookie, "__pus"); + boolean hasKp = CookieUtils.containsKey(cookie, "__kp"); + boolean hasPuus = CookieUtils.containsKey(cookie, "__puus"); + boolean hasNotexist = CookieUtils.containsKey(cookie, "notexist"); + + System.out.println("Cookie: " + cookie); + System.out.println("containsKey(__pus): " + hasPus); + System.out.println("containsKey(__kp): " + hasKp); + System.out.println("containsKey(__puus): " + hasPuus); + System.out.println("containsKey(notexist): " + hasNotexist); + + assert hasPus : "__pus 应存在"; + assert hasKp : "__kp 应存在"; + assert !hasPuus : "__puus 不应存在"; + assert !hasNotexist : "notexist 不应存在"; + + System.out.println("✓ 检查 Cookie key 测试通过\n"); + } + + private static void testEmptyCookieHandling() { + System.out.println("=== 测试空 Cookie 处理 ==="); + String emptyFiltered = CookieUtils.filterUcQuarkCookie(""); + String nullFiltered = CookieUtils.filterUcQuarkCookie(null); + String emptyValue = CookieUtils.getCookieValue("", "__pus"); + String nullValue = CookieUtils.getCookieValue(null, "__pus"); + + System.out.println("filterUcQuarkCookie(''): '" + emptyFiltered + "'"); + System.out.println("filterUcQuarkCookie(null): '" + nullFiltered + "'"); + System.out.println("getCookieValue('', '__pus'): " + emptyValue); + System.out.println("getCookieValue(null, '__pus'): " + nullValue); + + assert "".equals(emptyFiltered) : "空字符串应返回空字符串"; + assert "".equals(nullFiltered) : "null 应返回空字符串"; + assert emptyValue == null : "空字符串的值应为 null"; + assert nullValue == null : "null 的值应为 null"; + + System.out.println("✓ 空 Cookie 处理测试通过\n"); + } + + private static void testComplexScenario() { + System.out.println("=== 测试复杂场景:模拟 UC/夸克 Cookie 处理流程 ==="); + + // 模拟从浏览器获取的完整 Cookie + String browserCookie = "session_id=xxx; __pus=main_token_here; other=value; " + + "__kp=key123; __kps=secret456; __ktd=token789; " + + "__uid=user001; random_cookie=test; __puus=old_signature"; + + System.out.println("1. 浏览器原始 Cookie:"); + System.out.println(" " + browserCookie); + + // 第一步:过滤出必要的字段 + String filtered = CookieUtils.filterUcQuarkCookie(browserCookie); + System.out.println("\n2. 过滤后的 Cookie (只保留 UC/夸克必需字段):"); + System.out.println(" " + filtered); + + assert filtered.contains("__pus=main_token_here") : "应包含 __pus"; + assert filtered.contains("__kp=key123") : "应包含 __kp"; + assert filtered.contains("__puus=old_signature") : "应包含 __puus"; + assert !filtered.contains("session_id") : "不应包含 session_id"; + assert !filtered.contains("random_cookie") : "不应包含 random_cookie"; + + // 第二步:模拟刷新 __puus + String newPuus = "__puus=refreshed_signature_from_server"; + String updated = CookieUtils.updateCookieValue(filtered, "__puus", newPuus); + System.out.println("\n3. 刷新 __puus 后的 Cookie:"); + System.out.println(" " + updated); + + assert updated.contains("__puus=refreshed_signature_from_server") : "应包含新的 __puus"; + assert !updated.contains("__puus=old_signature") : "不应包含旧的 __puus"; + assert updated.contains("__pus=main_token_here") : "应保留 __pus"; + + // 第三步:验证可以获取单个值 + String pusValue = CookieUtils.getCookieValue(updated, "__pus"); + String puusValue = CookieUtils.getCookieValue(updated, "__puus"); + System.out.println("\n4. 提取单个 Cookie 值:"); + System.out.println(" __pus = " + pusValue); + System.out.println(" __puus = " + puusValue); + + assert "main_token_here".equals(pusValue) : "__pus 应为 main_token_here"; + assert "refreshed_signature_from_server".equals(puusValue) : "__puus 应为 refreshed_signature_from_server"; + + System.out.println("\n✓ 复杂场景测试通过\n"); + } + + private static void testAllUcQuarkCookieFields() { + System.out.println("=== 测试所有 UC/夸克 Cookie 必需字段 ==="); + + // 包含所有必需字段的 Cookie + String fullCookie = "__pus=token1; __kp=token2; __kps=token3; " + + "__ktd=token4; __uid=token5; __puus=token6; " + + "extra1=value1; extra2=value2"; + + String filtered = CookieUtils.filterUcQuarkCookie(fullCookie); + + System.out.println("原始 Cookie: " + fullCookie); + System.out.println("过滤后: " + filtered); + System.out.println("\n验证必需字段:"); + + // 验证所有必需字段都被保留 + String[] requiredFields = {"__pus", "__kp", "__kps", "__ktd", "__uid", "__puus"}; + for (String field : requiredFields) { + boolean contains = CookieUtils.containsKey(filtered, field); + System.out.println(" - " + field + ": " + (contains ? "✓" : "✗")); + assert contains : "应包含 " + field; + } + + // 验证额外字段被过滤掉 + assert !filtered.contains("extra1") : "不应包含 extra1"; + assert !filtered.contains("extra2") : "不应包含 extra2"; + + System.out.println("\n✓ 所有字段测试通过\n"); + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/clientlink/ClientLinkExample.java b/parser/src/test/java/cn/qaiu/parser/clientlink/ClientLinkExample.java index 3658394..3745d1d 100644 --- a/parser/src/test/java/cn/qaiu/parser/clientlink/ClientLinkExample.java +++ b/parser/src/test/java/cn/qaiu/parser/clientlink/ClientLinkExample.java @@ -122,12 +122,12 @@ public class ClientLinkExample { // 使用便捷工具类 String curlCommand = ClientLinkUtils.generateCurlCommand(shareLinkInfo); - String wgetCommand = ClientLinkUtils.generateWgetCommand(shareLinkInfo); + String aria2Command = ClientLinkUtils.generateAria2Command(shareLinkInfo); String thunderLink = ClientLinkUtils.generateThunderLink(shareLinkInfo); log.info("=== 使用便捷工具类生成的链接 ==="); log.info("cURL命令: {}", curlCommand); - log.info("wget命令: {}", wgetCommand); + log.info("Aria2命令: {}", aria2Command); log.info("迅雷链接: {}", thunderLink); } catch (Exception e) { diff --git a/parser/src/test/java/cn/qaiu/parser/clientlink/ClientLinkGeneratorTest.java b/parser/src/test/java/cn/qaiu/parser/clientlink/ClientLinkGeneratorTest.java deleted file mode 100644 index 5069df0..0000000 --- a/parser/src/test/java/cn/qaiu/parser/clientlink/ClientLinkGeneratorTest.java +++ /dev/null @@ -1,262 +0,0 @@ -package cn.qaiu.parser.clientlink; - -import cn.qaiu.entity.ShareLinkInfo; -import cn.qaiu.parser.clientlink.ClientLinkType; -import cn.qaiu.parser.clientlink.DownloadLinkMeta; -import cn.qaiu.parser.clientlink.impl.CurlLinkGenerator; -import cn.qaiu.parser.clientlink.impl.ThunderLinkGenerator; -import cn.qaiu.parser.clientlink.impl.Aria2LinkGenerator; -import cn.qaiu.parser.clientlink.impl.PowerShellLinkGenerator; -import org.junit.Before; -import org.junit.Test; - -import java.util.HashMap; -import java.util.Map; - -import static org.junit.Assert.*; - -/** - * 客户端链接生成器功能测试 - * - * @author QAIU - * Create at 2025/01/21 - */ -public class ClientLinkGeneratorTest { - - private ShareLinkInfo shareLinkInfo; - private DownloadLinkMeta meta; - - @Before - public void setUp() { - // 创建测试用的 ShareLinkInfo - shareLinkInfo = ShareLinkInfo.newBuilder() - .type("test") - .panName("测试网盘") - .shareUrl("https://example.com/share/test") - .build(); - - Map otherParam = new HashMap<>(); - otherParam.put("downloadUrl", "https://example.com/file.zip"); - - Map headers = new HashMap<>(); - headers.put("User-Agent", "Mozilla/5.0 (Test Browser)"); - headers.put("Referer", "https://example.com/share/test"); - headers.put("Cookie", "session=abc123"); - otherParam.put("downloadHeaders", headers); - - shareLinkInfo.setOtherParam(otherParam); - - // 创建测试用的 DownloadLinkMeta - meta = new DownloadLinkMeta("https://example.com/file.zip"); - meta.setFileName("test-file.zip"); - meta.setHeaders(headers); - } - - @Test - public void testCurlLinkGenerator() { - CurlLinkGenerator generator = new CurlLinkGenerator(); - - String result = generator.generate(meta); - - assertNotNull("cURL命令不应为空", result); - assertTrue("应包含curl命令", result.contains("curl")); - assertTrue("应包含下载URL", result.contains("https://example.com/file.zip")); - assertTrue("应包含User-Agent头", result.contains("\"User-Agent: Mozilla/5.0 (Test Browser)\"")); - assertTrue("应包含Referer头", result.contains("\"Referer: https://example.com/share/test\"")); - assertTrue("应包含Cookie头", result.contains("\"Cookie: session=abc123\"")); - assertTrue("应包含输出文件名", result.contains("\"test-file.zip\"")); - assertTrue("应包含跟随重定向", result.contains("-L")); - - assertEquals("类型应为CURL", ClientLinkType.CURL, generator.getType()); - } - - @Test - public void testThunderLinkGenerator() { - ThunderLinkGenerator generator = new ThunderLinkGenerator(); - - String result = generator.generate(meta); - - assertNotNull("迅雷链接不应为空", result); - assertTrue("应以thunder://开头", result.startsWith("thunder://")); - - // 验证Base64编码格式 - String encodedPart = result.substring("thunder://".length()); - assertNotNull("编码部分不应为空", encodedPart); - assertFalse("编码部分不应为空字符串", encodedPart.isEmpty()); - - assertEquals("类型应为THUNDER", ClientLinkType.THUNDER, generator.getType()); - } - - @Test - public void testAria2LinkGenerator() { - Aria2LinkGenerator generator = new Aria2LinkGenerator(); - - String result = generator.generate(meta); - - assertNotNull("Aria2命令不应为空", result); - assertTrue("应包含aria2c命令", result.contains("aria2c")); - assertTrue("应包含下载URL", result.contains("https://example.com/file.zip")); - assertTrue("应包含User-Agent头", result.contains("--header=\"User-Agent: Mozilla/5.0 (Test Browser)\"")); - assertTrue("应包含Referer头", result.contains("--header=\"Referer: https://example.com/share/test\"")); - assertTrue("应包含输出文件名", result.contains("--out=\"test-file.zip\"")); - assertTrue("应包含断点续传", result.contains("--continue")); - - assertEquals("类型应为ARIA2", ClientLinkType.ARIA2, generator.getType()); - } - - @Test - public void testPowerShellLinkGenerator() { - PowerShellLinkGenerator generator = new PowerShellLinkGenerator(); - - String result = generator.generate(meta); - - assertNotNull("PowerShell命令不应为空", result); - assertTrue("应包含WebRequestSession", result.contains("$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession")); - assertTrue("应包含Invoke-WebRequest", result.contains("Invoke-WebRequest")); - assertTrue("应包含-UseBasicParsing", result.contains("-UseBasicParsing")); - assertTrue("应包含下载URL", result.contains("https://example.com/file.zip")); - assertTrue("应包含User-Agent", result.contains("User-Agent")); - assertTrue("应包含Referer", result.contains("Referer")); - assertTrue("应包含Cookie", result.contains("Cookie")); - assertTrue("应包含输出文件", result.contains("test-file.zip")); - - assertEquals("类型应为POWERSHELL", ClientLinkType.POWERSHELL, generator.getType()); - } - - @Test - public void testPowerShellLinkGeneratorWithoutHeaders() { - PowerShellLinkGenerator generator = new PowerShellLinkGenerator(); - - meta.setHeaders(new HashMap<>()); - String result = generator.generate(meta); - - assertNotNull("PowerShell命令不应为空", result); - assertTrue("应包含WebRequestSession", result.contains("$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession")); - assertTrue("应包含Invoke-WebRequest", result.contains("Invoke-WebRequest")); - assertTrue("应包含下载URL", result.contains("https://example.com/file.zip")); - assertFalse("不应包含Headers", result.contains("-Headers @{")); - } - - @Test - public void testPowerShellLinkGeneratorWithoutFileName() { - PowerShellLinkGenerator generator = new PowerShellLinkGenerator(); - - meta.setFileName(null); - String result = generator.generate(meta); - - assertNotNull("PowerShell命令不应为空", result); - assertTrue("应包含WebRequestSession", result.contains("$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession")); - assertTrue("应包含Invoke-WebRequest", result.contains("Invoke-WebRequest")); - assertTrue("应包含下载URL", result.contains("https://example.com/file.zip")); - assertFalse("不应包含OutFile", result.contains("-OutFile")); - } - - @Test - public void testPowerShellLinkGeneratorWithSpecialCharacters() { - PowerShellLinkGenerator generator = new PowerShellLinkGenerator(); - - // 测试包含特殊字符的URL和请求头 - meta.setUrl("https://example.com/file with spaces.zip"); - Map specialHeaders = new HashMap<>(); - specialHeaders.put("Custom-Header", "Value with \"quotes\" and $variables"); - meta.setHeaders(specialHeaders); - - String result = generator.generate(meta); - - assertNotNull("PowerShell命令不应为空", result); - assertTrue("应包含转义的URL", result.contains("https://example.com/file with spaces.zip")); - assertTrue("应包含转义的请求头", result.contains("Custom-Header")); - assertTrue("应包含转义的引号", result.contains("`\"")); - } - - @Test - public void testDownloadLinkMetaFromShareLinkInfo() { - DownloadLinkMeta metaFromInfo = DownloadLinkMeta.fromShareLinkInfo(shareLinkInfo); - - assertNotNull("从ShareLinkInfo创建的DownloadLinkMeta不应为空", metaFromInfo); - assertEquals("URL应匹配", "https://example.com/file.zip", metaFromInfo.getUrl()); - assertEquals("Referer应匹配", "https://example.com/share/test", metaFromInfo.getReferer()); - assertEquals("User-Agent应匹配", "Mozilla/5.0 (Test Browser)", metaFromInfo.getUserAgent()); - - Map headers = metaFromInfo.getHeaders(); - assertNotNull("请求头不应为空", headers); - assertEquals("请求头数量应匹配", 3, headers.size()); - assertEquals("User-Agent应匹配", "Mozilla/5.0 (Test Browser)", headers.get("User-Agent")); - assertEquals("Referer应匹配", "https://example.com/share/test", headers.get("Referer")); - assertEquals("Cookie应匹配", "session=abc123", headers.get("Cookie")); - } - - @Test - public void testClientLinkGeneratorFactory() { - Map allLinks = ClientLinkGeneratorFactory.generateAll(shareLinkInfo); - - assertNotNull("生成的链接集合不应为空", allLinks); - assertFalse("生成的链接集合不应为空", allLinks.isEmpty()); - - // 检查是否生成了主要类型的链接 - assertTrue("应生成cURL链接", allLinks.containsKey(ClientLinkType.CURL)); - assertTrue("应生成迅雷链接", allLinks.containsKey(ClientLinkType.THUNDER)); - assertTrue("应生成Aria2链接", allLinks.containsKey(ClientLinkType.ARIA2)); - assertTrue("应生成wget链接", allLinks.containsKey(ClientLinkType.WGET)); - assertTrue("应生成PowerShell链接", allLinks.containsKey(ClientLinkType.POWERSHELL)); - - // 验证生成的链接不为空 - assertNotNull("cURL链接不应为空", allLinks.get(ClientLinkType.CURL)); - assertNotNull("迅雷链接不应为空", allLinks.get(ClientLinkType.THUNDER)); - assertNotNull("Aria2链接不应为空", allLinks.get(ClientLinkType.ARIA2)); - assertNotNull("wget链接不应为空", allLinks.get(ClientLinkType.WGET)); - assertNotNull("PowerShell链接不应为空", allLinks.get(ClientLinkType.POWERSHELL)); - - assertFalse("cURL链接不应为空字符串", allLinks.get(ClientLinkType.CURL).trim().isEmpty()); - assertFalse("迅雷链接不应为空字符串", allLinks.get(ClientLinkType.THUNDER).trim().isEmpty()); - assertFalse("Aria2链接不应为空字符串", allLinks.get(ClientLinkType.ARIA2).trim().isEmpty()); - assertFalse("wget链接不应为空字符串", allLinks.get(ClientLinkType.WGET).trim().isEmpty()); - assertFalse("PowerShell链接不应为空字符串", allLinks.get(ClientLinkType.POWERSHELL).trim().isEmpty()); - } - - @Test - public void testClientLinkUtils() { - String curlCommand = ClientLinkUtils.generateCurlCommand(shareLinkInfo); - String thunderLink = ClientLinkUtils.generateThunderLink(shareLinkInfo); - String aria2Command = ClientLinkUtils.generateAria2Command(shareLinkInfo); - String powershellCommand = ClientLinkUtils.generatePowerShellCommand(shareLinkInfo); - - assertNotNull("cURL命令不应为空", curlCommand); - assertNotNull("迅雷链接不应为空", thunderLink); - assertNotNull("Aria2命令不应为空", aria2Command); - assertNotNull("PowerShell命令不应为空", powershellCommand); - - assertTrue("cURL命令应包含curl", curlCommand.contains("curl")); - assertTrue("迅雷链接应以thunder://开头", thunderLink.startsWith("thunder://")); - assertTrue("Aria2命令应包含aria2c", aria2Command.contains("aria2c")); - assertTrue("PowerShell命令应包含Invoke-WebRequest", powershellCommand.contains("Invoke-WebRequest")); - - // 测试元数据有效性检查 - assertTrue("应检测到有效的下载元数据", ClientLinkUtils.hasValidDownloadMeta(shareLinkInfo)); - - // 测试无效元数据 - ShareLinkInfo emptyInfo = ShareLinkInfo.newBuilder().build(); - assertFalse("应检测到无效的下载元数据", ClientLinkUtils.hasValidDownloadMeta(emptyInfo)); - } - - @Test - public void testNullAndEmptyHandling() { - // 测试空URL - DownloadLinkMeta emptyMeta = new DownloadLinkMeta(""); - CurlLinkGenerator generator = new CurlLinkGenerator(); - - String result = generator.generate(emptyMeta); - assertNull("空URL应返回null", result); - - // 测试null元数据 - result = generator.generate(null); - assertNull("null元数据应返回null", result); - - // 测试null ShareLinkInfo - String curlResult = ClientLinkUtils.generateCurlCommand(null); - assertNull("null ShareLinkInfo应返回null", curlResult); - - Map allResult = ClientLinkUtils.generateAllClientLinks(null); - assertTrue("null ShareLinkInfo应返回空集合", allResult.isEmpty()); - } -} diff --git a/parser/src/test/java/cn/qaiu/parser/clientlink/PowerShellExample.java b/parser/src/test/java/cn/qaiu/parser/clientlink/PowerShellExample.java deleted file mode 100644 index 957586b..0000000 --- a/parser/src/test/java/cn/qaiu/parser/clientlink/PowerShellExample.java +++ /dev/null @@ -1,68 +0,0 @@ -package cn.qaiu.parser.clientlink; - -import cn.qaiu.entity.ShareLinkInfo; -import cn.qaiu.parser.clientlink.ClientLinkType; -import cn.qaiu.parser.clientlink.DownloadLinkMeta; -import cn.qaiu.parser.clientlink.impl.PowerShellLinkGenerator; - -import java.util.HashMap; -import java.util.Map; - -/** - * PowerShell 生成器示例 - * - * @author QAIU - * Create at 2025/01/21 - */ -public class PowerShellExample { - - public static void main(String[] args) { - // 创建测试数据 - DownloadLinkMeta meta = new DownloadLinkMeta("https://example.com/file.zip"); - meta.setFileName("test-file.zip"); - - Map headers = new HashMap<>(); - headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); - headers.put("Referer", "https://example.com/share/test"); - headers.put("Cookie", "session=abc123"); - headers.put("Accept", "text/html,application/xhtml+xml"); - meta.setHeaders(headers); - - // 生成 PowerShell 命令 - PowerShellLinkGenerator generator = new PowerShellLinkGenerator(); - String powershellCommand = generator.generate(meta); - - System.out.println("=== 生成的 PowerShell 命令 ==="); - System.out.println(powershellCommand); - System.out.println(); - - // 测试特殊字符转义 - meta.setUrl("https://example.com/file with spaces.zip"); - Map specialHeaders = new HashMap<>(); - specialHeaders.put("Custom-Header", "Value with \"quotes\" and $variables"); - meta.setHeaders(specialHeaders); - - String escapedCommand = generator.generate(meta); - - System.out.println("=== 包含特殊字符的 PowerShell 命令 ==="); - System.out.println(escapedCommand); - System.out.println(); - - // 使用 ClientLinkUtils - ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder() - .type("test") - .panName("测试网盘") - .shareUrl("https://example.com/share/test") - .build(); - - Map otherParam = new HashMap<>(); - otherParam.put("downloadUrl", "https://example.com/file.zip"); - otherParam.put("downloadHeaders", headers); - shareLinkInfo.setOtherParam(otherParam); - - String utilsCommand = ClientLinkUtils.generatePowerShellCommand(shareLinkInfo); - - System.out.println("=== 使用 ClientLinkUtils 生成的 PowerShell 命令 ==="); - System.out.println(utilsCommand); - } -} diff --git a/parser/src/test/java/cn/qaiu/parser/clientlink/UcQkClientLinkTest.java b/parser/src/test/java/cn/qaiu/parser/clientlink/UcQkClientLinkTest.java new file mode 100644 index 0000000..0afbd7e --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/clientlink/UcQkClientLinkTest.java @@ -0,0 +1,180 @@ +package cn.qaiu.parser.clientlink; + +import cn.qaiu.entity.FileInfo; +import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.parser.PanDomainTemplate; + +import java.util.HashMap; +import java.util.Map; + +/** + * UC和夸克网盘客户端链接生成测试 + * 测试在有下载链接和请求头的情况下,是否能正确生成下载命令 + */ +public class UcQkClientLinkTest { + + public static void main(String[] args) { + System.out.println("========================================"); + System.out.println(" UC/夸克网盘客户端链接生成测试"); + System.out.println("========================================\n"); + + // 测试 UC 网盘 + testUcClientLinks(); + + // 测试夸克网盘 + testQkClientLinks(); + + System.out.println("\n========================================"); + System.out.println(" 测试完成"); + System.out.println("========================================"); + } + + private static void testUcClientLinks() { + System.out.println("=== 测试 UC 网盘客户端链接生成 ===\n"); + + // 创建 ShareLinkInfo (使用 Builder) + ShareLinkInfo info = ShareLinkInfo.newBuilder() + .type("uc") + .panName(PanDomainTemplate.UC.getDisplayName()) + .shareKey("test123") + .build(); + + // 模拟下载链接(UC网盘的真实下载链接格式) + String downloadUrl = "https://pc-api.uc.cn/1/clouddrive/file/download?xxx"; + info.getOtherParam().put("downloadUrl", downloadUrl); + + // 模拟下载请求头(包含Cookie) + Map headers = new HashMap<>(); + headers.put("Cookie", "__pus=5e2bfe93fc55175482cd81dbafb41586AARGIGToqJ7RFMUETPbInASaHMcrrwTch6A6cjwBQQF0gKWZZxV20iixkInaK3AQrW+zsggDwifeq2BZ6fOBsj1N; __kp=72747319-24ad-44da-85a9-133fedd72818; __kps=AASxYmDMULu4nzmEK/wFzK3I; __ktd=dvy3qySVr8aXEqUuxMJydA==; __uid=AASxYmDMULu4nzmEK/wFzK3I; __puus=bdb2e15d24f1a15fe2b5e108b44f0805AAR498zI4bjrVRD3mNor9LX8YbixADr2C4YebqDb1fvtySVLiF3VgyASPRi/VSfMikDVd3yHUtbqP3ZwAteImXbevPo84hloWgCG0qCouDie3PKBIXq4+UxiXay2GHtst71wVq7ODiWV3OzzazpYgtGqTjep8F4BWtwdwtCjQz6l6OHVYy/LkTe3/6eeAreiRNU="); + headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + headers.put("Referer", "https://drive.uc.cn/"); + info.getOtherParam().put("downloadHeaders", headers); + + // 设置文件信息(通过otherParam) + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileName("测试文件.zip"); + info.getOtherParam().put("fileInfo", fileInfo); + + // 生成客户端链接 + Map clientLinks = ClientLinkGeneratorFactory.generateAll(info); + + if (clientLinks.isEmpty()) { + System.out.println("❌ 未能生成任何客户端链接\n"); + return; + } + + System.out.println("✅ 成功生成 " + clientLinks.size() + " 个客户端链接:\n"); + + for (Map.Entry entry : clientLinks.entrySet()) { + ClientLinkType type = entry.getKey(); + String link = entry.getValue(); + + System.out.println("【" + type.getDisplayName() + "】"); + System.out.println(link); + System.out.println(); + + // 验证链接格式 + validateLink(type, link, "UC"); + } + } + + private static void testQkClientLinks() { + System.out.println("=== 测试夸克网盘客户端链接生成 ===\n"); + + // 创建 ShareLinkInfo (使用 Builder) + ShareLinkInfo info = ShareLinkInfo.newBuilder() + .type("qk") + .panName(PanDomainTemplate.QK.getDisplayName()) + .shareKey("test456") + .build(); + + // 模拟下载链接(夸克网盘的真实下载链接格式) + String downloadUrl = "https://drive-pc.quark.cn/1/clouddrive/file/download?xxx"; + info.getOtherParam().put("downloadUrl", downloadUrl); + + // 模拟下载请求头(包含Cookie) + Map headers = new HashMap<>(); + headers.put("Cookie", "__pus=abc123def456; __kp=ghi789jkl012; __kps=mno345pqr678; __ktd=stu901vwx234; __uid=yza567bcd890; __puus=efg123hij456"); + headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + headers.put("Referer", "https://pan.quark.cn/"); + info.getOtherParam().put("downloadHeaders", headers); + + // 设置文件信息(通过otherParam) + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileName("测试文件.mp4"); + info.getOtherParam().put("fileInfo", fileInfo); + + // 生成客户端链接 + Map clientLinks = ClientLinkGeneratorFactory.generateAll(info); + + if (clientLinks.isEmpty()) { + System.out.println("❌ 未能生成任何客户端链接\n"); + return; + } + + System.out.println("✅ 成功生成 " + clientLinks.size() + " 个客户端链接:\n"); + + for (Map.Entry entry : clientLinks.entrySet()) { + ClientLinkType type = entry.getKey(); + String link = entry.getValue(); + + System.out.println("【" + type.getDisplayName() + "】"); + System.out.println(link); + System.out.println(); + + // 验证链接格式 + validateLink(type, link, "夸克"); + } + } + + private static void validateLink(ClientLinkType type, String link, String panName) { + boolean valid = true; + StringBuilder issues = new StringBuilder(); + + switch (type) { + case CURL: + if (!link.startsWith("curl ")) { + valid = false; + issues.append("不是以 'curl ' 开头; "); + } + if (!link.contains("--header \"Cookie:")) { + valid = false; + issues.append("缺少 Cookie 请求头; "); + } + if (!link.contains("--output")) { + valid = false; + issues.append("缺少输出文件名; "); + } + break; + + case ARIA2: + if (!link.contains("aria2c")) { + valid = false; + issues.append("不包含 'aria2c'; "); + } + if (!link.contains("--header=\"Cookie:")) { + valid = false; + issues.append("缺少 Cookie 请求头; "); + } + if (!link.contains("--out=")) { + valid = false; + issues.append("缺少输出文件名; "); + } + break; + + case THUNDER: + if (!link.startsWith("thunder://")) { + valid = false; + issues.append("不是以 'thunder://' 开头; "); + } + // 迅雷不支持 Cookie,所以不检查 + break; + } + + if (valid) { + System.out.println(" ✓ " + panName + "的" + type.getDisplayName() + "格式验证通过"); + } else { + System.out.println(" ⚠️ " + panName + "的" + type.getDisplayName() + "格式异常: " + issues); + } + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/impl/UcQkToolValidationTest.java b/parser/src/test/java/cn/qaiu/parser/impl/UcQkToolValidationTest.java new file mode 100644 index 0000000..76f937e --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/impl/UcQkToolValidationTest.java @@ -0,0 +1,139 @@ +package cn.qaiu.parser.impl; + +import cn.qaiu.entity.ShareLinkInfo; +import io.vertx.core.MultiMap; +import io.vertx.core.http.impl.headers.HeadersMultiMap; + +import java.util.HashMap; +import java.util.Map; + +/** + * UC 和夸克网盘工具类验证测试 + */ +public class UcQkToolValidationTest { + + public static void main(String[] args) { + System.out.println("========================================"); + System.out.println(" UC/夸克网盘工具类验证测试"); + System.out.println("========================================\n"); + + testQkToolWithAuth(); + testUcToolWithAuth(); + testQkToolWithoutAuth(); + testUcToolWithoutAuth(); + + System.out.println("\n========================================"); + System.out.println(" 所有验证通过! ✓"); + System.out.println("========================================"); + } + + private static void testQkToolWithAuth() { + System.out.println("=== 测试夸克网盘工具类(带认证)==="); + + try { + // 创建认证配置 + MultiMap auths = new HeadersMultiMap(); + auths.set("cookie", "__pus=test_token; __kp=key123; __kps=secret; __puus=signature"); + + Map otherParam = new HashMap<>(); + otherParam.put("auths", auths); + + // 创建分享链接信息 + ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder() + .type("QK") + .panName("夸克网盘") + .shareKey("test_key") + .shareUrl("https://pan.quark.cn/s/test123") + .build(); + shareLinkInfo.setOtherParam(otherParam); + + // 创建工具类实例 + QkTool qkTool = new QkTool(shareLinkInfo); + + System.out.println("✓ 夸克网盘工具类实例创建成功"); + System.out.println(" - 已配置认证信息"); + System.out.println(" - Cookie 已过滤和应用\n"); + } catch (Exception e) { + System.err.println("✗ 夸克网盘工具类测试失败: " + e.getMessage()); + e.printStackTrace(); + } + } + + private static void testUcToolWithAuth() { + System.out.println("=== 测试 UC 网盘工具类(带认证)==="); + + try { + // 创建认证配置 + MultiMap auths = new HeadersMultiMap(); + auths.set("cookie", "__pus=uc_token; __kp=uc_key; __uid=user001; __puus=uc_sig"); + + Map otherParam = new HashMap<>(); + otherParam.put("auths", auths); + + // 创建分享链接信息 + ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder() + .type("UC") + .panName("UC网盘") + .shareKey("uc_key_123") + .shareUrl("https://fast.uc.cn/s/abc123") + .build(); + shareLinkInfo.setOtherParam(otherParam); + + // 创建工具类实例 + UcTool ucTool = new UcTool(shareLinkInfo); + + System.out.println("✓ UC 网盘工具类实例创建成功"); + System.out.println(" - 已配置认证信息"); + System.out.println(" - Cookie 已过滤和应用\n"); + } catch (Exception e) { + System.err.println("✗ UC 网盘工具类测试失败: " + e.getMessage()); + e.printStackTrace(); + } + } + + private static void testQkToolWithoutAuth() { + System.out.println("=== 测试夸克网盘工具类(无认证)==="); + + try { + // 创建分享链接信息(无认证) + ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder() + .type("QK") + .panName("夸克网盘") + .shareKey("test_key_no_auth") + .shareUrl("https://pan.quark.cn/s/test456") + .build(); + + // 创建工具类实例 + QkTool qkTool = new QkTool(shareLinkInfo); + + System.out.println("✓ 夸克网盘工具类实例创建成功(无认证)"); + System.out.println(" - 应该使用默认请求头\n"); + } catch (Exception e) { + System.err.println("✗ 夸克网盘工具类(无认证)测试失败: " + e.getMessage()); + e.printStackTrace(); + } + } + + private static void testUcToolWithoutAuth() { + System.out.println("=== 测试 UC 网盘工具类(无认证)==="); + + try { + // 创建分享链接信息(无认证) + ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder() + .type("UC") + .panName("UC网盘") + .shareKey("uc_no_auth") + .shareUrl("https://fast.uc.cn/s/def456") + .build(); + + // 创建工具类实例 + UcTool ucTool = new UcTool(shareLinkInfo); + + System.out.println("✓ UC 网盘工具类实例创建成功(无认证)"); + System.out.println(" - 应该使用默认请求头\n"); + } catch (Exception e) { + System.err.println("✗ UC 网盘工具类(无认证)测试失败: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/integration/AuthParseIntegrationTest.java b/parser/src/test/java/cn/qaiu/parser/integration/AuthParseIntegrationTest.java new file mode 100644 index 0000000..6309576 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/integration/AuthParseIntegrationTest.java @@ -0,0 +1,367 @@ +package cn.qaiu.parser.integration; + +import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.parser.IPanTool; +import cn.qaiu.parser.ParserCreate; +import io.vertx.core.MultiMap; +import io.vertx.core.http.impl.headers.HeadersMultiMap; + +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * 带认证的解析集成测试 + * + * 使用方式: + * 1. 在 src/test/resources/auth-test.properties 中配置认证信息 + * 2. 运行测试 + * + * 配置文件格式: + * qk.cookie=__pus=xxx; __kp=xxx; ... + * qk.url=https://pan.quark.cn/s/xxx + * uc.cookie=__pus=xxx; __kp=xxx; ... + * uc.url=https://fast.uc.cn/s/xxx + * fj.cookie=your_cookie_here + * fj.url=https://share.feijipan.com/s/xxx + * fj.pwd=1234 + */ +public class AuthParseIntegrationTest { + + private static final String CONFIG_FILE = "src/test/resources/auth-test.properties"; + private static Properties config; + + public static void main(String[] args) { + System.out.println("========================================"); + System.out.println(" 带认证的解析集成测试"); + System.out.println("========================================\n"); + + // 加载配置 + if (!loadConfig()) { + System.err.println("❌ 无法加载配置文件: " + CONFIG_FILE); + System.out.println("\n请创建配置文件并添加认证信息:"); + printConfigExample(); + return; + } + + System.out.println("✓ 配置文件加载成功\n"); + + // 测试夸克网盘 + if (hasConfig("qk")) { + testQuark(); + } else { + System.out.println("⏭ 跳过夸克网盘测试(未配置)\n"); + } + + // 测试 UC 网盘 + if (hasConfig("uc")) { + testUc(); + } else { + System.out.println("⏭ 跳过 UC 网盘测试(未配置)\n"); + } + + // 测试小飞机网盘 + if (hasConfig("fj")) { + testFeiji(); + } else { + System.out.println("⏭ 跳过小飞机网盘测试(未配置)\n"); + } + + System.out.println("========================================"); + System.out.println(" 集成测试完成"); + System.out.println("========================================"); + + // 给异步操作一些时间完成 + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + System.exit(0); + } + + private static boolean loadConfig() { + config = new Properties(); + try (FileReader reader = new FileReader(CONFIG_FILE)) { + config.load(reader); + return true; + } catch (IOException e) { + return false; + } + } + + private static boolean hasConfig(String prefix) { + return config.containsKey(prefix + ".url"); + } + + private static String getConfig(String key) { + return config.getProperty(key, ""); + } + + private static void testQuark() { + System.out.println("=== 测试夸克网盘解析(带认证)==="); + + String url = getConfig("qk.url"); + String cookie = getConfig("qk.cookie"); + String pwd = getConfig("qk.pwd"); + + System.out.println("分享链接: " + url); + System.out.println("Cookie: " + maskCookie(cookie)); + if (!pwd.isEmpty()) { + System.out.println("密码: " + pwd); + } + + try { + // 创建认证配置 + MultiMap auths = new HeadersMultiMap(); + auths.set("cookie", cookie); + + Map otherParam = new HashMap<>(); + otherParam.put("auths", auths); + + // 创建解析器 + ParserCreate parserCreate = ParserCreate.fromShareUrl(url); + ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo(); + if (!pwd.isEmpty()) { + shareLinkInfo.setSharePassword(pwd); + } + shareLinkInfo.setOtherParam(otherParam); + + IPanTool tool = parserCreate.createTool(); + + System.out.println("\n开始解析..."); + + // 异步解析 + CountDownLatch latch = new CountDownLatch(1); + final long startTime = System.currentTimeMillis(); + + tool.parse().onSuccess(result -> { + long duration = System.currentTimeMillis() - startTime; + System.out.println("\n✅ 夸克网盘解析成功!"); + System.out.println("耗时: " + duration + "ms"); + System.out.println("直链: " + result); + + // 验证直链格式 + if (result != null && result.startsWith("http")) { + System.out.println("✓ 直链格式正确"); + } else { + System.out.println("⚠️ 直链格式异常"); + } + latch.countDown(); + }).onFailure(error -> { + long duration = System.currentTimeMillis() - startTime; + System.out.println("\n❌ 夸克网盘解析失败!"); + System.out.println("耗时: " + duration + "ms"); + System.out.println("错误: " + error.getMessage()); + if (error.getCause() != null) { + System.out.println("原因: " + error.getCause().getMessage()); + } + latch.countDown(); + }); + + // 等待结果(最多30秒) + if (!latch.await(30, TimeUnit.SECONDS)) { + System.out.println("\n⏱️ 解析超时(30秒)"); + } + + } catch (Exception e) { + System.out.println("\n❌ 测试异常: " + e.getMessage()); + e.printStackTrace(); + } + + System.out.println(); + } + + private static void testUc() { + System.out.println("=== 测试 UC 网盘解析(带认证)==="); + + String url = getConfig("uc.url"); + String cookie = getConfig("uc.cookie"); + String pwd = getConfig("uc.pwd"); + + System.out.println("分享链接: " + url); + System.out.println("Cookie: " + maskCookie(cookie)); + if (!pwd.isEmpty()) { + System.out.println("密码: " + pwd); + } + + try { + // 创建认证配置 + MultiMap auths = new HeadersMultiMap(); + auths.set("cookie", cookie); + + Map otherParam = new HashMap<>(); + otherParam.put("auths", auths); + + // 创建解析器 + ParserCreate parserCreate = ParserCreate.fromShareUrl(url); + ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo(); + if (!pwd.isEmpty()) { + shareLinkInfo.setSharePassword(pwd); + } + shareLinkInfo.setOtherParam(otherParam); + + IPanTool tool = parserCreate.createTool(); + + System.out.println("\n开始解析..."); + + // 异步解析 + CountDownLatch latch = new CountDownLatch(1); + final long startTime = System.currentTimeMillis(); + + tool.parse().onSuccess(result -> { + long duration = System.currentTimeMillis() - startTime; + System.out.println("\n✅ UC 网盘解析成功!"); + System.out.println("耗时: " + duration + "ms"); + System.out.println("直链: " + result); + + // 验证直链格式 + if (result != null && result.startsWith("http")) { + System.out.println("✓ 直链格式正确"); + } else { + System.out.println("⚠️ 直链格式异常"); + } + latch.countDown(); + }).onFailure(error -> { + long duration = System.currentTimeMillis() - startTime; + System.out.println("\n❌ UC 网盘解析失败!"); + System.out.println("耗时: " + duration + "ms"); + System.out.println("错误: " + error.getMessage()); + if (error.getCause() != null) { + System.out.println("原因: " + error.getCause().getMessage()); + } + latch.countDown(); + }); + + // 等待结果(最多30秒) + if (!latch.await(30, TimeUnit.SECONDS)) { + System.out.println("\n⏱️ 解析超时(30秒)"); + } + + } catch (Exception e) { + System.out.println("\n❌ 测试异常: " + e.getMessage()); + e.printStackTrace(); + } + + System.out.println(); + } + + private static void testFeiji() { + System.out.println("=== 测试小飞机网盘解析(带认证)==="); + + String url = getConfig("fj.url"); + String username = getConfig("fj.username"); + String password = getConfig("fj.password"); + String pwd = getConfig("fj.pwd"); + + System.out.println("分享链接: " + url); + System.out.println("用户名: " + (username.isEmpty() ? "无" : username)); + System.out.println("密码: " + (password.isEmpty() ? "无" : "******")); + if (!pwd.isEmpty()) { + System.out.println("提取码: " + pwd); + } + + try { + // 创建认证配置 + MultiMap auths = new HeadersMultiMap(); + if (!username.isEmpty() && !password.isEmpty()) { + auths.set("username", username); + auths.set("password", password); + } + + Map otherParam = new HashMap<>(); + if (!username.isEmpty()) { + otherParam.put("auths", auths); + } + + // 创建解析器 + ParserCreate parserCreate = ParserCreate.fromShareUrl(url); + ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo(); + if (!pwd.isEmpty()) { + shareLinkInfo.setSharePassword(pwd); + } + // 设置认证参数 + if (!username.isEmpty()) { + shareLinkInfo.setOtherParam(otherParam); + } + + IPanTool tool = parserCreate.createTool(); + + System.out.println("\n开始解析..."); + + // 异步解析 + CountDownLatch latch = new CountDownLatch(1); + final long startTime = System.currentTimeMillis(); + + tool.parse().onSuccess(result -> { + long duration = System.currentTimeMillis() - startTime; + System.out.println("\n✅ 小飞机网盘解析成功!"); + System.out.println("耗时: " + duration + "ms"); + System.out.println("直链: " + result); + + // 验证直链格式 + if (result != null && result.startsWith("http")) { + System.out.println("✓ 直链格式正确"); + } else { + System.out.println("⚠️ 直链格式异常"); + } + latch.countDown(); + }).onFailure(error -> { + long duration = System.currentTimeMillis() - startTime; + System.out.println("\n❌ 小飞机网盘解析失败!"); + System.out.println("耗时: " + duration + "ms"); + System.out.println("错误: " + error.getMessage()); + if (error.getCause() != null) { + System.out.println("原因: " + error.getCause().getMessage()); + } + latch.countDown(); + }); + + // 等待结果(最多30秒) + if (!latch.await(30, TimeUnit.SECONDS)) { + System.out.println("\n⏱️ 解析超时(30秒)"); + } + + } catch (Exception e) { + System.out.println("\n❌ 测试异常: " + e.getMessage()); + e.printStackTrace(); + } + + System.out.println(); + } + + private static String maskCookie(String cookie) { + if (cookie == null || cookie.isEmpty()) { + return "(未配置)"; + } + if (cookie.length() <= 20) { + return cookie.substring(0, Math.min(10, cookie.length())) + "..."; + } + return cookie.substring(0, 10) + "..." + cookie.substring(cookie.length() - 10); + } + + private static void printConfigExample() { + System.out.println("\n配置文件示例 (" + CONFIG_FILE + "):"); + System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + System.out.println("# 夸克网盘配置"); + System.out.println("qk.cookie=__pus=xxx; __kp=xxx; __kps=xxx; __ktd=xxx; __uid=xxx; __puus=xxx"); + System.out.println("qk.url=https://pan.quark.cn/s/xxxxxxxxxx"); + System.out.println("qk.pwd="); + System.out.println(); + System.out.println("# UC 网盘配置"); + System.out.println("uc.cookie=__pus=xxx; __kp=xxx; __kps=xxx; __ktd=xxx; __uid=xxx; __puus=xxx"); + System.out.println("uc.url=https://fast.uc.cn/s/xxxxxxxxxx"); + System.out.println("uc.pwd="); + System.out.println(); + System.out.println("# 小飞机网盘配置(大文件需要认证)"); + System.out.println("fj.cookie=your_session_cookie_here"); + System.out.println("fj.url=https://share.feijipan.com/s/xxxxxxxxxx"); + System.out.println("fj.pwd=1234"); + System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/integration/LinkIdentifyTest.java b/parser/src/test/java/cn/qaiu/parser/integration/LinkIdentifyTest.java new file mode 100644 index 0000000..5c57a2c --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/integration/LinkIdentifyTest.java @@ -0,0 +1,86 @@ +package cn.qaiu.parser.integration; + +import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.parser.ParserCreate; +import cn.qaiu.parser.IPanTool; + +/** + * 测试链接识别问题 + * 验证 https://pan.quark.cn/s/30e3c602ac09 是否被正确识别为夸克网盘 + */ +public class LinkIdentifyTest { + + public static void main(String[] args) { + System.out.println("========================================"); + System.out.println(" 链接识别测试"); + System.out.println("========================================\n"); + + // 测试夸克链接 + testQkLink(); + + // 测试UC链接 + testUcLink(); + + System.out.println("\n========================================"); + System.out.println(" 测试完成"); + System.out.println("========================================"); + } + + private static void testQkLink() { + System.out.println("=== 测试夸克网盘链接识别 ===\n"); + + String url = "https://pan.quark.cn/s/30e3c602ac09"; + System.out.println("测试URL: " + url); + + try { + ParserCreate parserCreate = ParserCreate.fromShareUrl(url); + ShareLinkInfo info = parserCreate.getShareLinkInfo(); + + System.out.println("识别结果:"); + System.out.println(" 网盘名称: " + info.getPanName()); + System.out.println(" 网盘类型: " + info.getType()); + System.out.println(" 分享KEY: " + info.getShareKey()); + System.out.println(" 标准URL: " + info.getStandardUrl()); + + if ("qk".equalsIgnoreCase(info.getType())) { + System.out.println("\n✅ 链接正确识别为夸克网盘"); + } else { + System.out.println("\n❌ 链接识别错误! 期望: qk, 实际: " + info.getType()); + } + } catch (Exception e) { + System.out.println("\n❌ 识别失败: " + e.getMessage()); + e.printStackTrace(); + } + + System.out.println(); + } + + private static void testUcLink() { + System.out.println("=== 测试UC网盘链接识别 ===\n"); + + String url = "https://drive.uc.cn/s/e623b6da278e4"; + System.out.println("测试URL: " + url); + + try { + ParserCreate parserCreate = ParserCreate.fromShareUrl(url); + ShareLinkInfo info = parserCreate.getShareLinkInfo(); + + System.out.println("识别结果:"); + System.out.println(" 网盘名称: " + info.getPanName()); + System.out.println(" 网盘类型: " + info.getType()); + System.out.println(" 分享KEY: " + info.getShareKey()); + System.out.println(" 标准URL: " + info.getStandardUrl()); + + if ("uc".equalsIgnoreCase(info.getType())) { + System.out.println("\n✅ 链接正确识别为UC网盘"); + } else { + System.out.println("\n❌ 链接识别错误! 期望: uc, 实际: " + info.getType()); + } + } catch (Exception e) { + System.out.println("\n❌ 识别失败: " + e.getMessage()); + e.printStackTrace(); + } + + System.out.println(); + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/integration/README.md b/parser/src/test/java/cn/qaiu/parser/integration/README.md new file mode 100644 index 0000000..888ac76 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/integration/README.md @@ -0,0 +1,194 @@ +# 带认证的网盘解析集成测试 + +## 📋 概述 + +这个测试套件用于验证 UC、夸克和小飞机网盘的完整解析流程,包括认证、Cookie 处理和直链获取。 + +## 🚀 快速开始 + +### 1. 准备配置文件 + +```bash +cd parser/src/test/resources +cp auth-test.properties.template auth-test.properties +``` + +### 2. 填写认证信息 + +编辑 `auth-test.properties` 文件,填入真实的 Cookie 和分享链接。 + +**如何获取 Cookie:** + +1. 在浏览器中登录对应网盘(夸克/UC) +2. 打开开发者工具(F12) +3. 切换到 Network 标签 +4. 刷新页面 +5. 找到任意请求,在请求头中复制完整的 Cookie + +**夸克网盘 Cookie 示例:** +``` +__pus=abc123; __kp=def456; __kps=ghi789; __ktd=jkl012; __uid=mno345; __puus=pqr678 +``` + +**UC 网盘 Cookie 示例:** +``` +__pus=xyz123; __kp=uvw456; __kps=rst789; __ktd=opq012; __uid=lmn345; __puus=ijk678 +``` + +### 3. 运行测试 + +```bash +cd parser +mvn exec:java -Dexec.mainClass="cn.qaiu.parser.integration.AuthParseIntegrationTest" -Dexec.classpathScope=test -q +``` + +或者使用编译后运行: + +```bash +mvn test-compile +java -cp target/test-classes:target/classes:$(mvn dependency:build-classpath -q -Dmdep.outputFile=/dev/stdout) cn.qaiu.parser.integration.AuthParseIntegrationTest +``` + +## 📝 配置文件格式 + +```properties +# 夸克网盘(必须认证) +qk.cookie=__pus=xxx; __kp=xxx; ... +qk.url=https://pan.quark.cn/s/xxxxxxxxxx +qk.pwd= + +# UC 网盘(必须认证) +uc.cookie=__pus=xxx; __kp=xxx; ... +uc.url=https://fast.uc.cn/s/xxxxxxxxxx +uc.pwd= + +# 小飞机网盘(大文件需认证) +fj.cookie=session_id=xxx +fj.url=https://share.feijipan.com/s/xxxxxxxxxx +fj.pwd=1234 +``` + +## 🧪 测试内容 + +### 1. 夸克网盘测试 +- ✅ Cookie 过滤和应用 +- ✅ __puus 自动刷新机制 +- ✅ 解析带认证的分享链接 +- ✅ 获取直链 +- ✅ 验证直链格式 + +### 2. UC 网盘测试 +- ✅ Cookie 过滤和应用 +- ✅ __puus 自动刷新机制 +- ✅ 解析带认证的分享链接 +- ✅ 获取直链 +- ✅ 验证直链格式 + +### 3. 小飞机网盘测试 +- ✅ 可选认证配置 +- ✅ 解析带密码的分享链接 +- ✅ 大文件认证处理 +- ✅ 获取直链 +- ✅ 验证直链格式 + +## 📊 测试输出示例 + +``` +======================================== + 带认证的解析集成测试 +======================================== + +✓ 配置文件加载成功 + +=== 测试夸克网盘解析(带认证)=== +分享链接: https://pan.quark.cn/s/abc123def +Cookie: __pus=abc1...xyz789 + +开始解析... + +✅ 夸克网盘解析成功! +耗时: 1234ms +直链: https://download.quark.cn/file/xxx +✓ 直链格式正确 + +=== 测试 UC 网盘解析(带认证)=== +分享链接: https://fast.uc.cn/s/def456ghi +Cookie: __pus=def4...uvw012 + +开始解析... + +✅ UC 网盘解析成功! +耗时: 2345ms +直链: https://download.uc.cn/file/xxx +✓ 直链格式正确 + +======================================== + 集成测试完成 +======================================== +``` + +## ⚠️ 注意事项 + +1. **Cookie 安全性** + - 不要将包含真实 Cookie 的配置文件提交到版本控制 + - `auth-test.properties` 已在 `.gitignore` 中 + - Cookie 包含敏感信息,请妥善保管 + +2. **Cookie 有效期** + - Cookie 通常有效期为 1-7 天 + - 过期后需要重新获取 + - 如果解析失败,首先检查 Cookie 是否过期 + +3. **网盘限制** + - 夸克和 UC 网盘**必须**提供 Cookie 才能解析 + - 小飞机网盘仅大文件(>100MB)需要 Cookie + - 部分分享链接可能有下载次数限制 + +4. **测试环境** + - 需要网络连接 + - 建议使用真实的大文件分享链接测试 + - 超时时间设置为 30 秒 + +## 🔍 故障排查 + +### 解析失败 + +1. **检查 Cookie 格式** + - 确保包含所有必需字段:`__pus`, `__kp`, `__kps`, `__ktd`, `__uid`, `__puus` + - 没有多余的空格或换行符 + +2. **检查分享链接** + - 链接格式正确 + - 链接未过期 + - 分享密码正确(如果有) + +3. **查看详细日志** + - 运行时不加 `-q` 参数查看完整日志 + - 检查网络请求和响应 + +### Cookie 过期 + +- 重新登录网盘 +- 重新获取 Cookie +- 更新配置文件 + +### 网络超时 + +- 检查网络连接 +- 可能是网盘服务器响应慢 +- 可以修改代码中的超时时间(默认30秒) + +## 📚 相关文档 + +- [Cookie 工具类文档](../java/cn/qaiu/util/CookieUtils.java) +- [夸克网盘解析器](../java/cn/qaiu/parser/impl/QkTool.java) +- [UC 网盘解析器](../java/cn/qaiu/parser/impl/UcTool.java) +- [小飞机网盘解析器](../java/cn/qaiu/parser/impl/FjTool.java) +- [认证参数指南](../../doc/auth-param/AUTH_PARAM_GUIDE.md) + +## 💡 提示 + +- 首次运行前确保已执行 `mvn compile` 编译项目 +- 如果未配置某个网盘,该网盘的测试会自动跳过 +- 测试结果包含解析耗时,可用于性能评估 +- Cookie 会自动过滤,只保留必需字段 diff --git a/parser/src/test/java/cn/qaiu/parser/integration/TEST_RESULTS.md b/parser/src/test/java/cn/qaiu/parser/integration/TEST_RESULTS.md new file mode 100644 index 0000000..b22213e --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/integration/TEST_RESULTS.md @@ -0,0 +1,191 @@ +# 认证解析集成测试结果 + +## 测试日期 +2026-02-05 + +## 测试环境 +- Java: 17+ +- Maven: 3.x +- 系统: macOS + +## 测试配置 + +### 小飞机网盘 ✅ +- **用户名**: 15764091073 +- **URL**: https://share.feijipan.com/s/ZWYoZ31c +- **文件**: 资源.rar (1.13 GB) +- **认证方式**: username/password + +### UC网盘 ⏸️ +- **Cookie**: 已配置(长度 2.5KB) +- **URL**: 未提供 +- **状态**: 等待分享链接 + +### 夸克网盘 ⏸️ +- **Cookie**: 未配置 +- **URL**: 未提供 +- **状态**: 等待认证信息和分享链接 + +## 测试结果 + +### ✅ 小飞机网盘 - 成功 +``` +=== 测试小飞机网盘解析(带认证)=== +分享链接: https://share.feijipan.com/s/ZWYoZ31c +用户名: 15764091073 +密码: ****** + +开始解析... +2026-02-05 17:06:10.188 INFO 登录成功 token: f2d2186d... +2026-02-05 17:06:10.374 INFO 验证成功 userId: 4481273 + +✅ 小飞机网盘解析成功! +耗时: 1690ms +直链: https://dl-app.feejii.com/storage/files/2025/11/02/0/13000720/176208936345513.gz?t=6984648a&rlimit=20&us=Em7C0Gdaaz&sign=b954cdef169f2d883e1dfe4a6c9762fa&download_name=%E8%B5%84%E6%BA%90.rar&p=4481273-4481273-24620369057 +✓ 直链格式正确 +``` + +**验证项**: +- ✅ 用户名密码认证成功 +- ✅ 登录和token获取正常 +- ✅ 用户ID验证通过 +- ✅ 直链生成成功 +- ✅ 解析耗时合理(1.69秒) +- ✅ 大文件(1GB+)解析正常 + +### ⏸️ UC网盘 - 等待测试 +**原因**: 缺少分享链接URL + +**已准备**: +- ✅ Cookie配置完整(包含所有必需字段) +- ✅ CookieUtils工具已验证(7/7测试通过) +- ✅ UcTool认证逻辑已验证 +- ✅ __puus自动刷新机制已实现 + +**下一步**: 提供UC网盘分享链接后即可测试 + +### ⏸️ 夸克网盘 - 等待测试 +**原因**: 缺少Cookie和分享链接URL + +**已准备**: +- ✅ CookieUtils工具已验证(7/7测试通过) +- ✅ QkTool认证逻辑已验证 +- ✅ __puus自动刷新机制已实现 + +**下一步**: 提供夸克网盘Cookie和分享链接后即可测试 + +## 前端增强 ✅ + +### 新增功能:智能网盘类型检测和提示 + +**实现方式**: +1. 解析前调用 `/v2/linkInfo` API 获取网盘类型 +2. 根据网盘类型给出相应提示 + +**提示规则**: + +| 网盘类型 | 代码 | 提示内容 | 持续时间 | +|---------|------|---------|---------| +| 夸克网盘 | `qk` | "无法在网页端直接下载,请点击'生成下载命令'按钮,使用命令行工具下载" | 5秒 | +| UC网盘 | `uc` | "无法在网页端直接下载,请点击'生成下载命令'按钮,使用命令行工具下载" | 5秒 | +| 小飞机 | `fj` | "的大文件解析需要配置认证信息,请在'配置认证'中添加" | 4秒 | +| 蓝奏云 | `lz` | "的大文件解析需要配置认证信息,请在'配置认证'中添加" | 4秒 | +| 蓝奏优享 | `iz` | "的大文件解析需要配置认证信息,请在'配置认证'中添加" | 4秒 | +| 联想乐云 | `le` | "的大文件解析需要配置认证信息,请在'配置认证'中添加" | 4秒 | + +**修改文件**: +- [Home.vue](../../../../../../../web-front/src/views/Home.vue) - parseFile() 方法 + +## 工具验证状态 + +### ✅ CookieUtils - 全部通过 +- 测试文件: [CookieUtilsManualTest.java](../utils/CookieUtilsManualTest.java) +- 测试通过: 7/7 +- 验证项: + - ✅ Cookie字段过滤 + - ✅ getValue提取 + - ✅ updateCookie更新 + - ✅ containsKey检查 + - ✅ 空值处理 + - ✅ 复杂场景 + - ✅ UC/QK所有必需字段 + +### ✅ UC/QK Tool - 全部通过 +- 测试文件: [UcQkToolValidationTest.java](../impl/UcQkToolValidationTest.java) +- 测试通过: 4/4 +- 验证项: + - ✅ QK带认证实例化 + - ✅ UC带认证实例化 + - ✅ QK无认证实例化 + - ✅ UC无认证实例化 + +## 技术细节 + +### Cookie字段要求 +UC和夸克都需要以下6个Cookie字段: +- `__pus` - 用户会话标识 +- `__kp` - 密钥标识 +- `__kps` - 密钥会话 +- `__ktd` - 密钥令牌数据 +- `__uid` - 用户ID +- `__puus` - 持久用户会话(55分钟自动刷新) + +### 自动刷新机制 +- **刷新间隔**: 55分钟 +- **有效期**: 1小时 +- **安全边际**: 5分钟 +- **实现**: Vertx定时器自动执行 + +### 认证参数加密 +- **算法**: AES/ECB/PKCS5Padding +- **密钥**: "nfd_auth_key2026" +- **编码**: Base64 → URL编码 +- **参数名**: `auth` + +## 下次测试准备 + +### UC网盘 +需要提供: +- ✅ Cookie(已有) +- ⏸️ 分享链接URL(待提供) +- ⏸️ 提取码(可选) + +### 夸克网盘 +需要提供: +- ⏸️ Cookie(待提供) +- ⏸️ 分享链接URL(待提供) +- ⏸️ 提取码(可选) + +## 运行命令 + +```bash +# 方法1: 使用便捷脚本 +cd parser +bash src/test/java/cn/qaiu/parser/integration/run-test.sh + +# 方法2: Maven直接运行 +cd parser +mvn exec:java \ + -Dexec.mainClass="cn.qaiu.parser.integration.AuthParseIntegrationTest" \ + -Dexec.classpathScope=test \ + -q +``` + +## 总结 + +✅ **已完成**: +1. 小飞机网盘认证解析测试 - 成功 +2. CookieUtils工具验证 - 全部通过 +3. UC/QK Tool实例化验证 - 全部通过 +4. 集成测试框架 - 就绪 +5. 前端类型检测和提示 - 已实现 + +⏸️ **待测试**: +1. UC网盘完整解析流程(等待分享链接) +2. 夸克网盘完整解析流程(等待Cookie和链接) + +📋 **建议**: +1. 获取UC网盘的真实分享链接进行测试 +2. 获取夸克网盘的Cookie和分享链接进行测试 +3. 测试不同文件大小的解析性能 +4. 验证前端UI提示是否正确显示 diff --git a/parser/src/test/java/cn/qaiu/parser/integration/run-test.sh b/parser/src/test/java/cn/qaiu/parser/integration/run-test.sh new file mode 100755 index 0000000..a300e8c --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/integration/run-test.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# 带认证的网盘解析集成测试运行脚本 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +CONFIG_FILE="$SCRIPT_DIR/../resources/auth-test.properties" +TEMPLATE_FILE="$SCRIPT_DIR/../resources/auth-test.properties.template" + +echo "=========================================" +echo " 网盘解析集成测试运行器" +echo "=========================================" +echo + +# 检查配置文件 +if [ ! -f "$CONFIG_FILE" ]; then + echo "❌ 配置文件不存在: $CONFIG_FILE" + echo + echo "请先创建配置文件:" + echo " cp $TEMPLATE_FILE $CONFIG_FILE" + echo + echo "然后编辑配置文件,填入真实的 Cookie 和分享链接" + exit 1 +fi + +echo "✓ 找到配置文件: $CONFIG_FILE" +echo + +# 检查配置文件是否为空或只有模板 +if ! grep -q "qk.url=http" "$CONFIG_FILE" && \ + ! grep -q "uc.url=http" "$CONFIG_FILE" && \ + ! grep -q "fj.url=http" "$CONFIG_FILE"; then + echo "⚠️ 配置文件似乎未填写实际数据" + echo + read -p "是否继续?(y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "已取消" + exit 0 + fi +fi + +# 切换到 parser 目录 +cd "$PROJECT_ROOT/parser" || exit 1 + +echo "开始编译..." +mvn compile -q -DskipTests +if [ $? -ne 0 ]; then + echo "❌ 编译失败" + exit 1 +fi + +echo "✓ 编译成功" +echo +echo "开始运行测试..." +echo "=========================================" +echo + +# 运行测试 +mvn exec:java \ + -Dexec.mainClass="cn.qaiu.parser.integration.AuthParseIntegrationTest" \ + -Dexec.classpathScope=test \ + -q + +TEST_RESULT=$? + +echo +echo "=========================================" +if [ $TEST_RESULT -eq 0 ]; then + echo "✓ 测试运行完成" +else + echo "❌ 测试运行失败(退出码: $TEST_RESULT)" +fi +echo "=========================================" + +exit $TEST_RESULT diff --git a/parser/src/test/resources/auth-test.properties.template b/parser/src/test/resources/auth-test.properties.template new file mode 100644 index 0000000..ab597b8 --- /dev/null +++ b/parser/src/test/resources/auth-test.properties.template @@ -0,0 +1,67 @@ +# ======================================== +# 网盘认证信息配置文件 +# ======================================== +# +# 使用说明: +# 1. 将此文件重命名为 auth-test.properties +# 2. 填入真实的 Cookie 和分享链接 +# 3. 运行测试: mvn exec:java -Dexec.mainClass="cn.qaiu.parser.integration.AuthParseIntegrationTest" -Dexec.classpathScope=test +# +# 如何获取 Cookie: +# 1. 在浏览器中登录对应网盘 +# 2. 打开开发者工具(F12) +# 3. 切换到 Network 标签 +# 4. 刷新页面 +# 5. 找到任意请求,在请求头中复制完整的 Cookie +# + +# ======================================== +# 夸克网盘配置(必须认证) +# ======================================== +# 分享链接示例: https://pan.quark.cn/s/abc123def +qk.url= + +# Cookie 必需字段: __pus, __kp, __kps, __ktd, __uid, __puus +# 完整示例: __pus=abc123; __kp=def456; __kps=ghi789; __ktd=jkl012; __uid=mno345; __puus=pqr678 +qk.cookie= + +# 分享密码(如果有) +qk.pwd= + + +# ======================================== +# UC 网盘配置(必须认证) +# ======================================== +# 分享链接示例: https://fast.uc.cn/s/abc123def +uc.url= + +# Cookie 必需字段: __pus, __kp, __kps, __ktd, __uid, __puus +# 完整示例: __pus=abc123; __kp=def456; __kps=ghi789; __ktd=jkl012; __uid=mno345; __puus=pqr678 +uc.cookie= + +# 分享密码(如果有) +uc.pwd= + + +# ======================================== +# 小飞机网盘配置(大文件需认证) +# ======================================== +# 分享链接示例: https://share.feijipan.com/s/abc123def +fj.url= + +# Cookie(大文件 >100MB 时需要) +# 完整示例: session_id=abc123; auth_token=def456 +fj.cookie= + +# 分享密码 +fj.pwd= + + +# ======================================== +# 注意事项 +# ======================================== +# 1. Cookie 中的特殊字符无需转义 +# 2. 不要添加多余的空格 +# 3. 密码可以为空 +# 4. 未配置的网盘会自动跳过测试 +# 5. Cookie 有效期通常为 1-7 天,过期需要重新获取 diff --git a/pom.xml b/pom.xml index cb62106..419dd8c 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ - 0.1.8 + 0.2.1 17 17 17 diff --git a/web-front/package.json b/web-front/package.json index 3c13dea..3156837 100644 --- a/web-front/package.json +++ b/web-front/package.json @@ -1,6 +1,6 @@ { "name": "nfd-web", - "version": "0.1.9", + "version": "0.2.1", "private": true, "scripts": { "serve": "vue-cli-service serve", @@ -16,6 +16,7 @@ "axios": "1.12.0", "clipboard": "^2.0.11", "core-js": "^3.8.3", + "crypto-js": "^4.2.0", "element-plus": "2.11.3", "monaco-editor": "^0.55.1", "qrcode": "^1.5.4", diff --git a/web-front/src/parserUrl1.js b/web-front/src/parserUrl1.js index 624e6dc..c4c9873 100644 --- a/web-front/src/parserUrl1.js +++ b/web-front/src/parserUrl1.js @@ -345,6 +345,18 @@ host: /www\.kdocs\.cn/, name: 'WPS云文档' }, + + quark: { + reg: /https:\/\/pan\.quark\.cn\/s\/[a-zA-Z\d]+/, + host: /pan\.quark\.cn/, + name: '夸克网盘' + }, + uc: { + reg: /https:\/\/(fast|drive)\.uc\.cn\/s\/[a-zA-Z\d]+(?:\?public=\d+)?(?:[#&].*)?/, + host: /(fast|drive)\.uc\.cn/, + name: 'UC网盘' + }, + other: { reg: /https:\/\/([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}\/s\/.+/, host: /.*/, diff --git a/web-front/src/views/ClientLinks.vue b/web-front/src/views/ClientLinks.vue index 59cfbd1..0080a6b 100644 --- a/web-front/src/views/ClientLinks.vue +++ b/web-front/src/views/ClientLinks.vue @@ -3,8 +3,8 @@ @@ -22,6 +22,42 @@ + +
+ + + +
+ + +
+ + + +
+
@@ -36,11 +72,14 @@ {{ result.parserInfo }} - + 点击下载 + + 需要客户端工具下载 +
@@ -53,128 +92,72 @@ > - + + - + /> +
- -
+ +
- - 支持的客户端类型 + + 使用说明 -
- - {{ name }} - -
- - - - - + + + 支持Cookie,复制命令到终端运行即可下载 + + + 支持Cookie,多线程下载器,复制命令后在终端运行或配置到Aria2客户端 + + + 不支持Cookie,仅适用于无需Cookie的直链,点击下载按钮可唤起迅雷 + +
@@ -190,7 +173,7 @@
- +
@@ -199,19 +182,17 @@ \ No newline at end of file +/* 使用 Element Plus 默认主题 */ + diff --git a/web-front/src/views/Home.vue b/web-front/src/views/Home.vue index d408a82..92fa027 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -38,7 +38,19 @@ -
+
+ + + + + + +
@@ -48,7 +60,7 @@
-
NFD网盘直链解析0.1.9_b15
+
NFD网盘直链解析0.2.1
支持网盘:蓝奏云、蓝奏云优享、小飞机盘、123云盘、奶牛快传、移动云空间、QQ邮箱云盘、QQ闪传等 >>
文件夹解析支持:蓝奏云、蓝奏云优享、小飞机盘、123云盘
@@ -91,7 +103,7 @@ 生成Markdown 扫码下载 分享统计 - 客户端链接(实验) + 生成命令行链接

@@ -195,6 +207,110 @@ 关闭 + + + + + + + + + 夸克网盘 (QK) + 必须 + + + UC网盘 (UC) + 必须 + + + + + 小飞机网盘 (FJ) + 大文件 + + + 蓝奏优享 (IZ) + 大文件 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 已配置的网盘 + + {{ getPanDisplayName(panType) }} + +
+
+ + +
+
@@ -301,10 +417,307 @@ export default { buildVersion: '', // 演练场启用状态 - playgroundEnabled: false + playgroundEnabled: false, + + // 临时认证配置 + showAuthConfigDialog: false, + authConfig: { + panType: '', // 网盘类型: QK, UC, FJ, IZ + authType: 'cookie', + username: '', + password: '', + token: '', + cookie: '', + auth: '', + ext1: '', + ext2: '', + ext3: '', + ext4: '', + ext5: '' + }, + // 所有网盘的认证配置 { panType: config } + allAuthConfigs: {} + } + }, + computed: { + // 检查是否配置了认证信息(针对当前链接的网盘类型) + hasAuthConfig() { + const panType = this.getCurrentPanType() + if (!panType) return false + return !!this.allAuthConfigs[panType] + }, + // 获取已配置认证的网盘数量 + authConfigCount() { + return Object.keys(this.allAuthConfigs).length } }, methods: { + // 从分享链接中提取网盘类型 + getCurrentPanType() { + if (!this.link) return '' + const url = this.link.toLowerCase() + if (url.includes('quark.cn') || url.includes('pan.quark.cn')) return 'QK' + if (url.includes('drive.uc.cn') || url.includes('fast.uc.cn')) return 'UC' + if (url.includes('feijipan.com') || url.includes('feijihe.com') || url.includes('xiaofeiyang.com')) return 'FJ' + if (url.includes('ilanzou.com') || url.includes('lanzouv.com')) return 'IZ' + return '' + }, + + // 获取网盘显示名称 + getPanDisplayName(panType) { + const names = { + 'QK': '夸克网盘', + 'UC': 'UC网盘', + 'FJ': '小飞机网盘', + 'IZ': '蓝奏优享' + } + return names[panType] || panType + }, + + // 获取认证提示信息 + getPanAuthHint() { + const hints = { + 'QK': '夸克网盘必须配置 Cookie 才能解析和下载(登录后从浏览器开发者工具获取)', + 'UC': 'UC网盘必须配置 Cookie 才能解析和下载(登录后从浏览器开发者工具获取)', + 'FJ': '小飞机网盘大文件(>100MB)需要配置认证信息', + 'IZ': '蓝奏优享大文件需要配置认证信息' + } + return hints[this.authConfig.panType] || '请选择网盘类型后配置认证信息' + }, + + // 获取提示类型 + getPanAuthAlertType() { + if (!this.authConfig.panType) return 'info' + if (this.authConfig.panType === 'QK' || this.authConfig.panType === 'UC') return 'warning' + return 'info' + }, + + // 根据网盘类型获取支持的认证方式列表 + getSupportedAuthTypes() { + const panType = this.authConfig.panType?.toLowerCase() || '' + + // 定义所有认证类型 + const allAuthTypes = { + cookie: { label: 'Cookie', value: 'cookie' }, + accesstoken: { label: 'AccessToken', value: 'accesstoken' }, + authorization: { label: 'Authorization', value: 'authorization' }, + password: { label: '用户名密码', value: 'password' }, + custom: { label: '自定义', value: 'custom' } + } + + // 根据网盘类型返回支持的认证方式 + switch (panType) { + case 'qk': // 夸克网盘:只支持Cookie + case 'uc': // UC网盘:只支持Cookie + case 'qqwy': // QQ微云:只支持Cookie + case 'pali': // 阿里云盘:只支持Cookie + return [allAuthTypes.cookie] + + case 'fj': // 小飞机网盘:只支持用户名密码 + case 'iz': // 蓝奏优享:只支持用户名密码 + return [allAuthTypes.password] + + case 'ye': // 123网盘:支持用户名密码和Authorization + return [allAuthTypes.password, allAuthTypes.authorization] + + case 'p189': // 天翼云盘:支持用户名密码、AccessToken、Cookie + return [allAuthTypes.password, allAuthTypes.accesstoken, allAuthTypes.cookie] + + case 'p139': // 移动云盘:支持Authorization + return [allAuthTypes.authorization] + + case 'pwo': // 联通云盘:支持AccessToken + return [allAuthTypes.accesstoken] + + default: + // 默认显示所有选项 + return Object.values(allAuthTypes) + } + }, + + // 网盘类型变更时加载对应配置 + onPanTypeChange(panType) { + // 先临时设置panType以便获取支持的认证类型 + const tempAuthConfig = { ...this.authConfig, panType } + this.authConfig = tempAuthConfig + + // 获取该网盘支持的认证类型 + const supportedTypes = this.getSupportedAuthTypes() + const defaultAuthType = supportedTypes.length > 0 ? supportedTypes[0].value : 'cookie' + + if (this.allAuthConfigs[panType]) { + // 加载已有配置 + const config = this.allAuthConfigs[panType] + this.authConfig = { ...this.authConfig, ...config, panType } + // 确保认证类型在支持列表中 + if (!supportedTypes.find(t => t.value === this.authConfig.authType)) { + this.authConfig.authType = defaultAuthType + } + } else { + // 重置为默认值,使用该网盘默认的认证类型 + this.authConfig = { + panType, + authType: defaultAuthType, + username: '', + password: '', + token: '', + cookie: '', + auth: '', + ext1: '', + ext2: '', + ext3: '', + ext4: '', + ext5: '' + } + } + }, + + // 加载指定网盘的配置 + loadPanConfig(panType) { + this.authConfig.panType = panType + this.onPanTypeChange(panType) + }, + + // 删除指定网盘的配置 + removePanConfig(panType) { + delete this.allAuthConfigs[panType] + localStorage.setItem('nfd_auth_configs', JSON.stringify(this.allAuthConfigs)) + if (this.authConfig.panType === panType) { + this.authConfig.panType = '' + } + this.$message.success(`已删除 ${this.getPanDisplayName(panType)} 的认证配置`) + this.updateDirectLink() + }, + + // 获取 Token 输入框的提示文本 + getTokenPlaceholder() { + const placeholders = { + 'accesstoken': '请输入 AccessToken', + 'cookie': '请输入 Cookie,例如: __puus=xxx; __pus=xxx(从浏览器开发者工具获取)', + 'authorization': '请输入 Authorization 头内容,例如: Bearer xxx', + 'custom': '请输入主 Token' + } + return placeholders[this.authConfig.authType] || '请输入认证信息' + }, + + // 保存认证配置 + saveAuthConfig() { + if (!this.authConfig.panType) { + this.$message.warning('请先选择网盘类型') + return + } + if (!this.authConfig.authType) { + this.$message.warning('请选择认证类型') + return + } + if (!this.authConfig.token && !this.authConfig.username) { + this.$message.warning('请填写认证信息') + return + } + + // 保存到配置集合 + const configToSave = { ...this.authConfig } + this.allAuthConfigs[this.authConfig.panType] = configToSave + + // 持久化到 localStorage + localStorage.setItem('nfd_auth_configs', JSON.stringify(this.allAuthConfigs)) + this.showAuthConfigDialog = false + this.$message.success(`${this.getPanDisplayName(this.authConfig.panType)} 认证配置已保存`) + // 更新智能直链 + this.updateDirectLink() + }, + + // 清除所有认证配置 + clearAuthConfig() { + this.authConfig = { + panType: '', + authType: 'cookie', + username: '', + password: '', + token: '', + cookie: '', + auth: '', + ext1: '', + ext2: '', + ext3: '', + ext4: '', + ext5: '' + } + this.allAuthConfigs = {} + localStorage.removeItem('nfd_auth_configs') + this.$message.success('所有认证配置已清除') + this.showAuthConfigDialog = false + // 更新智能直链 + this.updateDirectLink() + }, + + // 加载认证配置 + loadAuthConfig() { + const saved = localStorage.getItem('nfd_auth_configs') + if (saved) { + try { + this.allAuthConfigs = JSON.parse(saved) + } catch (e) { + console.error('加载认证配置失败:', e) + } + } + }, + + // 生成加密的 auth 参数(根据当前链接的网盘类型) + generateAuthParam() { + const panType = this.getCurrentPanType() + if (!panType || !this.allAuthConfigs[panType]) { + return '' + } + + const config = this.allAuthConfigs[panType] + + // 构建 JSON 对象 + const authObj = {} + if (config.authType) authObj.authType = config.authType + if (config.username) authObj.username = config.username + if (config.password) authObj.password = config.password + if (config.token) authObj.token = config.token + if (config.cookie) authObj.cookie = config.cookie + if (config.auth) authObj.auth = config.auth + if (config.ext1) authObj.ext1 = config.ext1 + if (config.ext2) authObj.ext2 = config.ext2 + if (config.ext3) authObj.ext3 = config.ext3 + if (config.ext4) authObj.ext4 = config.ext4 + if (config.ext5) authObj.ext5 = config.ext5 + + // AES 加密 + Base64 + URL 编码 + try { + const jsonStr = JSON.stringify(authObj) + const encrypted = this.aesEncrypt(jsonStr, 'nfd_auth_key2026') + return encodeURIComponent(encrypted) + } catch (e) { + console.error('生成认证参数失败:', e) + return '' + } + }, + + // AES 加密 (ECB 模式, PKCS5Padding) + aesEncrypt(text, key) { + // 使用 CryptoJS 进行 AES 加密 + const CryptoJS = require('crypto-js') + const keyBytes = CryptoJS.enc.Utf8.parse(key) + const encrypted = CryptoJS.AES.encrypt(text, keyBytes, { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7 + }) + return encrypted.toString() // Base64 编码 + }, + + // 更新智能直链 + updateDirectLink() { + if (this.link) { + const authParam = this.generateAuthParam() + const authSuffix = authParam ? `&auth=${authParam}` : '' + this.directLink = `${this.baseAPI}/parser?url=${this.link}${this.password ? `&pwd=${this.password}` : ''}${authSuffix}` + } + }, + // 生成预览链接(WPS 云文档特殊处理) getPreviewLink() { // 判断 shareKey 是否以 pwps: 开头(WPS 云文档) @@ -348,11 +761,16 @@ export default { this.directoryData = [] }, - // 统一API调用 + // 统一API调用(自动添加认证参数) async callAPI(endpoint, params = {}) { this.errorButtonVisible = false try { this.isLoading = true + // 添加认证参数 + const authParam = this.generateAuthParam() + if (authParam) { + params.auth = authParam + } const response = await axios.get(`${this.baseAPI}${endpoint}`, { params }) if (response.data.code === 200) { @@ -376,13 +794,46 @@ export default { async parseFile() { try { this.validateInput() + + // 先调用 linkInfo 获取网盘类型 + const linkInfoResult = await this.callAPI('/v2/linkInfo', { + url: this.link, + ...(this.password && { pwd: this.password }) + }) + + const panType = linkInfoResult.data?.shareLinkInfo?.type + const panName = linkInfoResult.data?.shareLinkInfo?.panName || '未知网盘' + + // 根据网盘类型给出提示 + if (panType === 'qk' || panType === 'uc') { + // UC和夸克:提示使用命令行下载 + this.$message.warning({ + message: `${panName}无法在网页端直接下载,请点击"生成下载命令"按钮,使用命令行工具下载`, + duration: 5000, + showClose: true + }) + } else if (panType === 'fj' || panType === 'lz' || panType === 'iz' || panType === 'le') { + // 小飞机、蓝奏、优享、联想乐云:提示大文件需要认证 + const hasAuth = this.allAuthConfigs[panType]?.cookie || + this.allAuthConfigs[panType]?.username + if (!hasAuth) { + this.$message.info({ + message: `${panName}的大文件解析需要配置认证信息,请在"配置认证"中添加`, + duration: 4000, + showClose: true + }) + } + } + + // 继续解析文件 const params = { url: this.link } if (this.password) params.pwd = this.password const result = await this.callAPI('/json/parser', params) this.parseResult = result this.downloadUrl = result.data?.directLink - this.directLink = `${this.baseAPI}/parser?url=${this.link}${this.password ? `&pwd=${this.password}` : ''}` + // 更新智能直链(包含认证参数) + this.updateDirectLink() this.$message.success('文件解析成功!') } catch (error) { console.error('文件解析失败:', error) @@ -513,7 +964,8 @@ export default { if (linkInfo.link !== this.link || pwd !== this.password) { this.password = pwd this.link = linkInfo.link - this.directLink = `${this.baseAPI}/parser?url=${this.link}${this.password ? `&pwd=${this.password}` : ''}` + // 更新智能直链(包含认证参数) + this.updateDirectLink() // 聚焦期间只提示一次 if (!this.hasClipboardSuccessTip) { this.$message.success(`自动识别分享成功, 网盘类型: ${linkInfo.name}; 分享URL ${this.link}; 分享密码: ${this.password || '空'}`) @@ -633,6 +1085,10 @@ export default { const params = { url: this.link } if (this.password) params.pwd = this.password + // 添加认证参数 + const authParam = this.generateAuthParam() + if (authParam) params.auth = authParam + const response = await axios.get(`${this.baseAPI}/v2/clientLinks`, { params }) const result = response.data @@ -669,6 +1125,9 @@ export default { this.autoReadClipboard = savedAutoRead === 'true' } + // 加载认证配置 + this.loadAuthConfig() + // 获取初始统计信息 this.getInfo() @@ -1027,4 +1486,34 @@ hr { #app.dark-theme .playground-link:hover { color: #66b1ff; } + +/* 认证配置按钮样式 */ +.auth-config-btn-active { + animation: auth-pulse 2s infinite; +} + +@keyframes auth-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(64, 158, 255, 0.4); + } + 70% { + box-shadow: 0 0 0 6px rgba(64, 158, 255, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(64, 158, 255, 0); + } +} + +/* 认证配置弹窗暗色模式适配 */ +#app.dark-theme .el-dialog { + background: #2d2d2d; +} + +#app.dark-theme .el-dialog__title { + color: #eee; +} + +#app.dark-theme .el-form-item__label { + color: #ccc; +} diff --git a/web-service/src/main/java/cn/qaiu/lz/common/util/AuthParamCodec.java b/web-service/src/main/java/cn/qaiu/lz/common/util/AuthParamCodec.java new file mode 100644 index 0000000..131cf06 --- /dev/null +++ b/web-service/src/main/java/cn/qaiu/lz/common/util/AuthParamCodec.java @@ -0,0 +1,220 @@ +package cn.qaiu.lz.common.util; + +import cn.qaiu.lz.web.model.AuthParam; +import cn.qaiu.util.AESUtils; +import cn.qaiu.vx.core.util.SharedDataUtil; +import io.vertx.core.json.JsonObject; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * 认证参数编解码工具类 + *

+ * 编码流程: JSON -> AES加密 -> Base64编码 -> URL编码 + * 解码流程: URL解码 -> Base64解码 -> AES解密 -> JSON解析 + *

+ * + * @author QAIU + * @date 2026/2/5 + */ +@Slf4j +public class AuthParamCodec { + + /** + * 默认加密密钥(16位) + */ + private static final String DEFAULT_ENCRYPT_KEY = "nfd_auth_key2026"; + + /** + * 配置中的密钥路径 + */ + private static final String CONFIG_KEY_PATH = "authEncryptKey"; + + private AuthParamCodec() { + // 工具类禁止实例化 + } + + /** + * 获取加密密钥 + * 优先从配置文件读取,如果未配置则使用默认密钥 + */ + public static String getEncryptKey() { + try { + String configKey = SharedDataUtil.getJsonStringForServerConfig(CONFIG_KEY_PATH); + if (StringUtils.isNotBlank(configKey)) { + return configKey; + } + } catch (Exception e) { + log.debug("从配置读取加密密钥失败,使用默认密钥: {}", e.getMessage()); + } + return DEFAULT_ENCRYPT_KEY; + } + + /** + * 解码认证参数 + * 解码流程: URL解码 -> Base64解码 -> AES解密 -> JSON解析 + * + * @param encryptedAuth 加密后的认证参数字符串 + * @return AuthParam 对象,解码失败返回 null + */ + public static AuthParam decode(String encryptedAuth) { + return decode(encryptedAuth, getEncryptKey()); + } + + /** + * 解码认证参数(指定密钥) + * + * @param encryptedAuth 加密后的认证参数字符串 + * @param key AES密钥(16位) + * @return AuthParam 对象,解码失败返回 null + */ + public static AuthParam decode(String encryptedAuth, String key) { + if (StringUtils.isBlank(encryptedAuth)) { + return null; + } + + try { + // Step 1: URL解码 + String urlDecoded = URLDecoder.decode(encryptedAuth, StandardCharsets.UTF_8); + log.debug("URL解码结果: {}", urlDecoded); + + // Step 2: Base64解码 + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + log.debug("Base64解码成功,长度: {}", base64Decoded.length); + + // Step 3: AES解密 + String jsonStr = AESUtils.decryptByAES(base64Decoded, key); + log.debug("AES解密结果: {}", jsonStr); + + // Step 4: JSON解析 + JsonObject json = new JsonObject(jsonStr); + AuthParam authParam = new AuthParam(json); + log.info("认证参数解码成功: authType={}", authParam.getAuthType()); + return authParam; + + } catch (IllegalArgumentException e) { + log.warn("认证参数Base64解码失败: {}", e.getMessage()); + } catch (Exception e) { + log.warn("认证参数解码失败: {}", e.getMessage()); + } + return null; + } + + /** + * 编码认证参数 + * 编码流程: JSON -> AES加密 -> Base64编码 -> URL编码 + * + * @param authParam 认证参数对象 + * @return 加密后的字符串,编码失败返回 null + */ + public static String encode(AuthParam authParam) { + return encode(authParam, getEncryptKey()); + } + + /** + * 编码认证参数(指定密钥) + * + * @param authParam 认证参数对象 + * @param key AES密钥(16位) + * @return 加密后的字符串,编码失败返回 null + */ + public static String encode(AuthParam authParam, String key) { + if (authParam == null || !authParam.hasValidAuth()) { + return null; + } + + try { + // Step 1: 转换为JSON + String jsonStr = authParam.toJsonObject().encode(); + log.debug("JSON字符串: {}", jsonStr); + + // Step 2: AES加密 + Base64编码 + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, key); + log.debug("AES+Base64编码结果: {}", base64Encoded); + + // Step 3: URL编码 + String urlEncoded = java.net.URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + log.debug("URL编码结果: {}", urlEncoded); + + return urlEncoded; + + } catch (Exception e) { + log.error("认证参数编码失败: {}", e.getMessage(), e); + } + return null; + } + + /** + * 编码认证参数(从JsonObject) + * + * @param json 认证参数JSON对象 + * @return 加密后的字符串 + */ + public static String encode(JsonObject json) { + return encode(new AuthParam(json)); + } + + /** + * 编码认证参数(从JSON字符串) + * + * @param jsonStr 认证参数JSON字符串 + * @return 加密后的字符串 + */ + public static String encodeFromJsonString(String jsonStr) { + if (StringUtils.isBlank(jsonStr)) { + return null; + } + try { + JsonObject json = new JsonObject(jsonStr); + return encode(new AuthParam(json)); + } catch (Exception e) { + log.error("JSON解析失败: {}", e.getMessage()); + return null; + } + } + + /** + * 验证加密的认证参数是否有效 + * + * @param encryptedAuth 加密后的认证参数 + * @return true 如果可以成功解码 + */ + public static boolean isValid(String encryptedAuth) { + return decode(encryptedAuth) != null; + } + + /** + * 快速构建并编码认证参数 + * + * @param authType 认证类型 + * @param token token/cookie/credential + * @return 加密后的字符串 + */ + public static String quickEncode(String authType, String token) { + AuthParam authParam = AuthParam.builder() + .authType(authType) + .token(token) + .build(); + return encode(authParam); + } + + /** + * 快速构建并编码用户名密码认证 + * + * @param username 用户名 + * @param password 密码 + * @return 加密后的字符串 + */ + public static String quickEncodePassword(String username, String password) { + AuthParam authParam = AuthParam.builder() + .authType("password") + .username(username) + .password(password) + .build(); + return encode(authParam); + } +} diff --git a/web-service/src/main/java/cn/qaiu/lz/common/util/URLParamUtil.java b/web-service/src/main/java/cn/qaiu/lz/common/util/URLParamUtil.java index d1d5f1d..64e707c 100644 --- a/web-service/src/main/java/cn/qaiu/lz/common/util/URLParamUtil.java +++ b/web-service/src/main/java/cn/qaiu/lz/common/util/URLParamUtil.java @@ -8,6 +8,8 @@ import io.vertx.core.MultiMap; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonObject; import io.vertx.core.shareddata.LocalMap; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; @@ -18,6 +20,7 @@ import java.nio.charset.StandardCharsets; * @author QAIU * Create at 2024/9/13 */ +@Slf4j public class URLParamUtil { /** @@ -60,12 +63,14 @@ public class URLParamUtil { } } - // 拼接被截断的URL参数,忽略pwd参数 + // 拼接被截断的URL参数,忽略pwd、auth等参数 StringBuilder urlBuilder = new StringBuilder(decodedUrl); boolean firstParam = !decodedUrl.contains("?"); for (String paramName : params.names()) { - if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId") && !paramName.equals("uuid")) { // 忽略 "url" 和 "pwd" 参数 + // 忽略 "url", "pwd", "dirId", "uuid", "auth" 参数(这些参数单独处理,不应拼接到分享URL中) + if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId") + && !paramName.equals("uuid") && !paramName.equals("auth")) { if (firstParam) { urlBuilder.append("?"); firstParam = false; @@ -116,4 +121,152 @@ public class URLParamUtil { String linkPrefix = SharedDataUtil.getJsonConfig("server").getString("domainName"); parserCreate.getShareLinkInfo().getOtherParam().put("domainName", linkPrefix); } + + /** + * 添加临时认证参数(一次性,不保存到数据库或共享内存) + * 如果提供了临时认证参数,将覆盖后台配置的认证信息 + * + * @param parserCreate ParserCreate对象 + * @param authType 认证类型 + * @param authToken 认证token/用户名/accesstoken/cookie + * @param authPassword 密码(仅用于username_password认证) + * @param authInfo1-5 扩展认证信息(用于custom认证) + */ + public static void addTempAuthParam(ParserCreate parserCreate, String authType, + String authToken, String authPassword, + String authInfo1, String authInfo2, String authInfo3, + String authInfo4, String authInfo5) { + if (StringUtils.isBlank(authType) && StringUtils.isBlank(authToken)) { + // 没有提供临时认证参数,使用后台配置 + addParam(parserCreate); + return; + } + + // 先添加代理配置和域名配置 + LocalMap localMap = VertxHolder.getVertxInstance().sharedData() + .getLocalMap(ConfigConstant.LOCAL); + String type = parserCreate.getShareLinkInfo().getType(); + + if (localMap.containsKey(ConfigConstant.PROXY)) { + JsonObject proxy = (JsonObject) localMap.get(ConfigConstant.PROXY); + if (proxy.containsKey(type)) { + parserCreate.getShareLinkInfo().getOtherParam().put(ConfigConstant.PROXY, proxy.getJsonObject(type)); + } + } + + String linkPrefix = SharedDataUtil.getJsonConfig("server").getString("domainName"); + parserCreate.getShareLinkInfo().getOtherParam().put("domainName", linkPrefix); + + // 构建临时认证信息 + MultiMap tempAuth = MultiMap.caseInsensitiveMultiMap(); + + if (StringUtils.isNotBlank(authType)) { + tempAuth.set("authType", authType.trim()); + } + + String authTypeValue = authType != null ? authType : ""; + switch (authTypeValue.toLowerCase()) { + case "accesstoken": + case "authorization": + if (StringUtils.isNotBlank(authToken)) { + tempAuth.set("token", authToken.trim()); + } + break; + + case "cookie": + // cookie 类型需要同时设置 token 和 cookie 字段 + // QkTool/UcTool 等从 auths.get("cookie") 获取 cookie 值 + if (StringUtils.isNotBlank(authToken)) { + tempAuth.set("token", authToken.trim()); + tempAuth.set("cookie", authToken.trim()); + } + break; + + case "password": + case "username_password": + if (StringUtils.isNotBlank(authToken)) { + tempAuth.set("username", authToken.trim()); + tempAuth.set("token", authToken.trim()); // 兼容旧的解析器 + } + if (StringUtils.isNotBlank(authPassword)) { + tempAuth.set("password", authPassword.trim()); + } + break; + + case "custom": + // 自定义认证支持多个扩展字段 + if (StringUtils.isNotBlank(authToken)) { + tempAuth.set("token", authToken.trim()); + } + if (StringUtils.isNotBlank(authInfo1)) { + parseAndSetAuthInfo(tempAuth, authInfo1); + } + if (StringUtils.isNotBlank(authInfo2)) { + parseAndSetAuthInfo(tempAuth, authInfo2); + } + if (StringUtils.isNotBlank(authInfo3)) { + parseAndSetAuthInfo(tempAuth, authInfo3); + } + if (StringUtils.isNotBlank(authInfo4)) { + parseAndSetAuthInfo(tempAuth, authInfo4); + } + if (StringUtils.isNotBlank(authInfo5)) { + parseAndSetAuthInfo(tempAuth, authInfo5); + } + break; + + default: + // 默认处理:将authToken作为token + if (StringUtils.isNotBlank(authToken)) { + tempAuth.set("token", authToken.trim()); + } + break; + } + + // 设置临时认证信息(覆盖后台配置) + if (!tempAuth.isEmpty()) { + parserCreate.getShareLinkInfo().getOtherParam().put(ConfigConstant.AUTHS, tempAuth); + // 设置标记表示已添加临时认证 + parserCreate.getShareLinkInfo().getOtherParam().put("__TEMP_AUTH_ADDED", true); + log.debug("已添加临时认证参数: diskType={}, authType={}", type, authType); + } else { + // 如果没有有效的临时认证参数,回退到使用后台配置 + if (localMap.containsKey(ConfigConstant.AUTHS)) { + JsonObject auths = (JsonObject) localMap.get(ConfigConstant.AUTHS); + if (auths.containsKey(type)) { + MultiMap entries = MultiMap.caseInsensitiveMultiMap(); + JsonObject jsonObject = auths.getJsonObject(type); + if (jsonObject != null) { + jsonObject.forEach(entity -> { + if (entity == null || entity.getValue() == null) { + return; + } + if (StringUtils.isEmpty(entity.getKey()) || StringUtils.isEmpty(entity.getValue().toString())) { + return; + } + entries.set(StringUtils.trim(entity.getKey()), StringUtils.trim(entity.getValue().toString())); + }); + } + parserCreate.getShareLinkInfo().getOtherParam().put(ConfigConstant.AUTHS, entries); + } + } + } + } + + /** + * 解析并设置认证信息(格式: key:value) + */ + private static void parseAndSetAuthInfo(MultiMap authMap, String authInfo) { + if (StringUtils.isBlank(authInfo)) { + return; + } + String[] parts = authInfo.split(":", 2); + if (parts.length == 2) { + String key = parts[0].trim(); + String value = parts[1].trim(); + if (StringUtils.isNotBlank(key) && StringUtils.isNotBlank(value)) { + authMap.set(key, value); + } + } + } } 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 bfeac9e..5f6f458 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 @@ -4,7 +4,9 @@ package cn.qaiu.lz.web.controller; import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.lz.common.cache.CacheManager; +import cn.qaiu.lz.common.util.AuthParamCodec; import cn.qaiu.lz.common.util.URLParamUtil; +import cn.qaiu.lz.web.model.AuthParam; import cn.qaiu.lz.web.model.CacheLinkInfo; import cn.qaiu.lz.web.model.ClientLinkResp; import cn.qaiu.lz.web.model.LinkInfoResp; @@ -50,15 +52,18 @@ public class ParserApi { private final ServerApi serverApi = new ServerApi(); @RouteMapping(value = "/linkInfo", method = RouteMethod.GET) - public Future parse(HttpServerRequest request, String pwd) { + public Future parse(HttpServerRequest request, String pwd, String auth) { Promise promise = Promise.promise(); String url = URLParamUtil.parserParams(request); ParserCreate parserCreate = ParserCreate.fromShareUrl(url).setShareLinkInfoPwd(pwd); ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo(); + + // 构建链接信息响应,如果有 auth 参数则附加到链接中 + String authSuffix = (auth != null && !auth.isEmpty()) ? "&auth=" + auth : ""; LinkInfoResp build = LinkInfoResp.builder() - .downLink(getDownLink(parserCreate, false)) - .apiLink(getDownLink(parserCreate, true)) - .viewLink(getViewLink(parserCreate)) + .downLink(getDownLink(parserCreate, false) + authSuffix) + .apiLink(getDownLink(parserCreate, true) + authSuffix) + .viewLink(getViewLink(parserCreate) + authSuffix) .shareLinkInfo(shareLinkInfo).build(); // 解析次数统计 shareLinkInfo.getOtherParam().put("UA",request.headers().get("user-agent")); @@ -214,7 +219,7 @@ public class ParserApi { } String previewURL = SharedDataUtil.getJsonStringForServerConfig("previewURL"); - new ServerApi().parseJson(request, pwd).onSuccess(res -> { + new ServerApi().parseJson(request, pwd, null).onSuccess(res -> { redirect(response, previewURL, res); }).onFailure(e -> { ResponseUtil.fireJsonResultResponse(response, JsonResult.error(e.toString())); @@ -252,10 +257,11 @@ public class ParserApi { * * @param request HTTP请求 * @param pwd 提取码 + * @param auth 加密的认证参数 * @return 客户端下载链接响应 */ @RouteMapping(value = "/clientLinks", method = RouteMethod.GET) - public Future getClientLinks(HttpServerRequest request, String pwd) { + public Future getClientLinks(HttpServerRequest request, String pwd, String auth) { Promise promise = Promise.promise(); try { @@ -263,6 +269,23 @@ public class ParserApi { ParserCreate parserCreate = ParserCreate.fromShareUrl(shareUrl).setShareLinkInfoPwd(pwd); ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo(); + // 处理认证参数 + if (auth != null && !auth.isEmpty()) { + AuthParam authParam = AuthParamCodec.decode(auth); + if (authParam != null && authParam.hasValidAuth()) { + URLParamUtil.addTempAuthParam(parserCreate, + authParam.getAuthType(), + authParam.getPrimaryCredential(), + authParam.getPassword(), + authParam.getExt1(), + authParam.getExt2(), + authParam.getExt3(), + authParam.getExt4(), + authParam.getExt5()); + log.debug("客户端链接API: 已解码认证参数 authType={}", authParam.getAuthType()); + } + } + // 使用默认方法解析并生成客户端链接 parserCreate.createTool().parseWithClientLinks() .onSuccess(clientLinks -> { @@ -345,6 +368,10 @@ public class ParserApi { String directLink = (String) shareLinkInfo.getOtherParam().get("downloadUrl"); Map supportedClients = buildSupportedClientsMap(); FileInfo fileInfo = extractFileInfo(shareLinkInfo); + String panType = shareLinkInfo.getType().toUpperCase(); + + // 判断是否需要客户端下载和认证需求 + PanRequirementInfo requirementInfo = getPanRequirementInfo(panType); return ClientLinkResp.builder() .success(true) @@ -354,9 +381,66 @@ public class ParserApi { .clientLinks(clientLinks) .supportedClients(supportedClients) .parserInfo(shareLinkInfo.getPanName() + " - " + shareLinkInfo.getType()) + .panType(panType) + .requiresClient(requirementInfo.requiresClient) + .authRequirement(requirementInfo.authRequirement) + .authHint(requirementInfo.authHint) .build(); } + /** + * 网盘需求信息内部类 + */ + private static class PanRequirementInfo { + boolean requiresClient; + String authRequirement; + String authHint; + + PanRequirementInfo(boolean requiresClient, String authRequirement, String authHint) { + this.requiresClient = requiresClient; + this.authRequirement = authRequirement; + this.authHint = authHint; + } + } + + /** + * 获取网盘需求信息 + * + * @param panType 网盘类型代码(大写) + * @return 网盘需求信息 + */ + private PanRequirementInfo getPanRequirementInfo(String panType) { + // 需要使用客户端下载的网盘类型(直链需要特殊头部,浏览器无法直接下载) + boolean requiresClient = switch (panType) { + case "UC", "QK", "PCX", "COW" -> true; + default -> false; + }; + + // 认证需求判断 + String authRequirement; + String authHint; + switch (panType) { + case "UC", "QK": + authRequirement = "required"; + authHint = "此网盘必须配置认证信息(Cookie/Token)才能正常解析和下载"; + break; + case "FJ": + authRequirement = "optional"; + authHint = "小飞机网盘大文件(>100MB)需要配置认证信息"; + break; + case "IZ": + authRequirement = "optional"; + authHint = "蓝奏优享大文件需要配置认证信息"; + break; + default: + authRequirement = "none"; + authHint = null; + break; + } + + return new PanRequirementInfo(requiresClient, authRequirement, authHint); + } + /** * 构建支持的客户端类型映射 * diff --git a/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java b/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java index ca99e02..4cff5b8 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java @@ -1,6 +1,8 @@ package cn.qaiu.lz.web.controller; +import cn.qaiu.lz.common.util.AuthParamCodec; import cn.qaiu.lz.common.util.URLParamUtil; +import cn.qaiu.lz.web.model.AuthParam; import cn.qaiu.lz.web.model.CacheLinkInfo; import cn.qaiu.lz.web.service.CacheService; import cn.qaiu.vx.core.annotaions.RouteHandler; @@ -29,11 +31,14 @@ public class ServerApi { private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class); @RouteMapping(value = "/parser", method = RouteMethod.GET, order = 1) - public Future parse(HttpServerResponse response, HttpServerRequest request, RoutingContext rcx, String pwd) { + public Future parse(HttpServerResponse response, HttpServerRequest request, RoutingContext rcx, String pwd, String auth) { Promise promise = Promise.promise(); String url = URLParamUtil.parserParams(request); - cacheService.getCachedByShareUrlAndPwd(url, pwd, JsonObject.of("UA",request.headers().get("user-agent"))) + // 构建 otherParam,包含 UA 和解码后的认证参数 + JsonObject otherParam = buildOtherParam(request, auth); + + cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam) .onSuccess(res -> ResponseUtil.redirect( response.putHeader("nfd-cache-hit", res.getCacheHit().toString()) .putHeader("nfd-cache-expires", res.getExpires()), @@ -43,9 +48,10 @@ public class ServerApi { } @RouteMapping(value = "/json/parser", method = RouteMethod.GET, order = 1) - public Future parseJson(HttpServerRequest request, String pwd) { + public Future parseJson(HttpServerRequest request, String pwd, String auth) { String url = URLParamUtil.parserParams(request); - return cacheService.getCachedByShareUrlAndPwd(url, pwd, JsonObject.of("UA",request.headers().get("user-agent"))); + JsonObject otherParam = buildOtherParam(request, auth); + return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam); } @RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET) @@ -77,4 +83,33 @@ public class ServerApi { return promise.future(); } + /** + * 构建 otherParam,包含 UA 和解码后的认证参数 + * + * @param request HTTP请求 + * @param auth 加密的认证参数 + * @return JsonObject + */ + private JsonObject buildOtherParam(HttpServerRequest request, String auth) { + JsonObject otherParam = JsonObject.of("UA", request.headers().get("user-agent")); + + // 解码认证参数 + if (auth != null && !auth.isEmpty()) { + AuthParam authParam = AuthParamCodec.decode(auth); + if (authParam != null && authParam.hasValidAuth()) { + // 将认证参数放入 otherParam + otherParam.put("authType", authParam.getAuthType()); + otherParam.put("authToken", authParam.getPrimaryCredential()); + otherParam.put("authPassword", authParam.getPassword()); + otherParam.put("authInfo1", authParam.getExt1()); + otherParam.put("authInfo2", authParam.getExt2()); + otherParam.put("authInfo3", authParam.getExt3()); + otherParam.put("authInfo4", authParam.getExt4()); + otherParam.put("authInfo5", authParam.getExt5()); + log.debug("已解码认证参数: authType={}", authParam.getAuthType()); + } + } + + return otherParam; + } } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java b/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java new file mode 100644 index 0000000..4666911 --- /dev/null +++ b/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java @@ -0,0 +1,166 @@ +package cn.qaiu.lz.web.model; + +import cn.qaiu.lz.common.ToJson; +import io.vertx.codegen.annotations.DataObject; +import io.vertx.core.json.JsonObject; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 认证参数模型 + * 用于接口传参时携带临时认证信息 + *

+ * 传参格式: auth=URL_ENCODE(BASE64(AES_ENCRYPT(JSON))) + * JSON格式: {"username":"xxx","password":"xxx","token":"xxx","cookie":"xxx",...} + *

+ * + * @author QAIU + * @date 2026/2/5 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DataObject +public class AuthParam implements ToJson { + + /** + * 认证类型 + *
    + *
  • accesstoken - 使用 accessToken 认证
  • + *
  • cookie - 使用 Cookie 认证
  • + *
  • authorization - 使用 Authorization 头认证
  • + *
  • password/username_password - 使用用户名密码认证
  • + *
  • custom - 自定义认证(使用 ext1-ext5 扩展字段)
  • + *
+ */ + private String authType; + + /** + * 用户名(用于 password/username_password 认证类型) + */ + private String username; + + /** + * 密码(用于 password/username_password 认证类型) + */ + private String password; + + /** + * Token(accessToken/authorization/通用token) + */ + private String token; + + /** + * Cookie 字符串 + */ + private String cookie; + + /** + * 授权信息(Authorization 头内容) + */ + private String auth; + + /** + * 扩展字段1(用于 custom 认证类型) + * 格式: key:value + */ + private String ext1; + + /** + * 扩展字段2(用于 custom 认证类型) + * 格式: key:value + */ + private String ext2; + + /** + * 扩展字段3(用于 custom 认证类型) + * 格式: key:value + */ + private String ext3; + + /** + * 扩展字段4(用于 custom 认证类型) + * 格式: key:value + */ + private String ext4; + + /** + * 扩展字段5(用于 custom 认证类型) + * 格式: key:value + */ + private String ext5; + + /** + * 从 JsonObject 构造 + */ + public AuthParam(JsonObject json) { + if (json == null) { + return; + } + this.authType = json.getString("authType"); + this.username = json.getString("username"); + this.password = json.getString("password"); + this.token = json.getString("token"); + this.cookie = json.getString("cookie"); + this.auth = json.getString("auth"); + this.ext1 = json.getString("ext1"); + this.ext2 = json.getString("ext2"); + this.ext3 = json.getString("ext3"); + this.ext4 = json.getString("ext4"); + this.ext5 = json.getString("ext5"); + } + + /** + * 转换为 JsonObject + */ + public JsonObject toJsonObject() { + JsonObject json = new JsonObject(); + if (authType != null) json.put("authType", authType); + if (username != null) json.put("username", username); + if (password != null) json.put("password", password); + if (token != null) json.put("token", token); + if (cookie != null) json.put("cookie", cookie); + if (auth != null) json.put("auth", auth); + if (ext1 != null) json.put("ext1", ext1); + if (ext2 != null) json.put("ext2", ext2); + if (ext3 != null) json.put("ext3", ext3); + if (ext4 != null) json.put("ext4", ext4); + if (ext5 != null) json.put("ext5", ext5); + return json; + } + + /** + * 检查是否有有效的认证信息 + */ + public boolean hasValidAuth() { + return authType != null || + username != null || + password != null || + token != null || + cookie != null || + auth != null; + } + + /** + * 获取主要的认证凭证(token/cookie/auth) + * 优先级: token > cookie > auth > username + */ + public String getPrimaryCredential() { + if (token != null && !token.isEmpty()) { + return token; + } + if (cookie != null && !cookie.isEmpty()) { + return cookie; + } + if (auth != null && !auth.isEmpty()) { + return auth; + } + if (username != null && !username.isEmpty()) { + return username; + } + return null; + } +} diff --git a/web-service/src/main/java/cn/qaiu/lz/web/model/CacheLinkInfo.java b/web-service/src/main/java/cn/qaiu/lz/web/model/CacheLinkInfo.java index 7382014..ac8adc3 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/model/CacheLinkInfo.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/model/CacheLinkInfo.java @@ -12,6 +12,9 @@ import io.vertx.core.json.jackson.DatabindCodec; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.HashMap; +import java.util.Map; + /** * @author QAIU * Create at 2024/9/11 16:06 @@ -52,6 +55,16 @@ public class CacheLinkInfo implements ToJson { private Long expiration; private FileInfo fileInfo; + + /** + * 其他参数,包含: + * - downloadHeaders: 下载请求头 Map + * - aria2Command: aria2 命令行命令 + * - aria2JsonRpc: aria2 JSON-RPC 请求体 + * - curlCommand: curl 命令 + */ + @TableGenIgnore + private Map otherParam; // 使用 JsonObject 构造 @@ -74,6 +87,15 @@ public class CacheLinkInfo implements ToJson { this.setFileInfo(mapper.convertValue(json.getJsonObject("fileInfo"), FileInfo.class)); } this.setCacheHit(json.getBoolean("cacheHit", false)); + + // 初始化 otherParam + this.otherParam = new HashMap<>(); + if (json.containsKey("otherParam")) { + JsonObject otherJson = json.getJsonObject("otherParam"); + if (otherJson != null) { + this.otherParam.putAll(otherJson.getMap()); + } + } } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/model/ClientLinkResp.java b/web-service/src/main/java/cn/qaiu/lz/web/model/ClientLinkResp.java index 671f19d..f05248e 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/model/ClientLinkResp.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/model/ClientLinkResp.java @@ -59,4 +59,28 @@ public class ClientLinkResp { * 解析信息 */ private String parserInfo; + + /** + * 网盘类型代码 + */ + private String panType; + + /** + * 是否必须使用客户端下载(直链需要特殊头部,浏览器无法直接下载) + * 适用于:UC、QK、PCX、COW等 + */ + private boolean requiresClient; + + /** + * 认证需求级别: + * - "none": 不需要认证 + * - "required": 必须认证(UC、QK) + * - "optional": 可选认证,大文件需要(FJ、IZ) + */ + private String authRequirement; + + /** + * 认证提示信息 + */ + private String authHint; } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java index ad9500e..a908e18 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java @@ -10,6 +10,8 @@ import cn.qaiu.lz.web.model.CacheLinkInfo; import cn.qaiu.lz.web.service.CacheService; import cn.qaiu.parser.IPanTool; import cn.qaiu.parser.ParserCreate; +import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory; +import cn.qaiu.parser.clientlink.ClientLinkType; import cn.qaiu.vx.core.annotaions.Service; import io.vertx.core.Future; import io.vertx.core.Promise; @@ -18,6 +20,8 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.time.DateFormatUtils; import java.util.Date; +import java.util.HashMap; +import java.util.Map; @Service @Slf4j @@ -27,13 +31,21 @@ public class CacheServiceImpl implements CacheService { private Future getAndSaveCachedShareLink(ParserCreate parserCreate) { - URLParamUtil.addParam(parserCreate); + // 认证、域名相关(检查是否已经添加过参数,避免重复调用) + if (!parserCreate.getShareLinkInfo().getOtherParam().containsKey("__PARAMS_ADDED")) { + URLParamUtil.addParam(parserCreate); + parserCreate.getShareLinkInfo().getOtherParam().put("__PARAMS_ADDED", true); + } Promise promise = Promise.promise(); // 构建组合的缓存key ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo(); - // 尝试从缓存中获取 String cacheKey = shareLinkInfo.getCacheKey(); + + // 使用配置文件中的默认缓存时长 + final int effectiveCacheDuration = CacheConfigLoader.getDuration(shareLinkInfo.getType()); + + // 尝试从缓存中获取 cacheManager.get(cacheKey).onSuccess(result -> { // 判断是否已过期 // 未命中或者过期 @@ -49,36 +61,64 @@ public class CacheServiceImpl implements CacheService { return; } tool.parse().onSuccess(redirectUrl -> { - long expires = System.currentTimeMillis() + - CacheConfigLoader.getDuration(shareLinkInfo.getType()) * 60 * 1000L; + // 使用 effectiveCacheDuration + long expires = System.currentTimeMillis() + effectiveCacheDuration * 60 * 1000L; result.setDirectLink(redirectUrl); + // 设置返回结果的过期时间 + result.setExpiration(expires); + result.setExpires(generateDate(expires)); + + // 调试日志:检查解析器返回的otherParam + log.info("[解析完成] shareKey={}, otherParam.keys={}, hasFileInfo={}", + cacheKey, + shareLinkInfo.getOtherParam().keySet(), + shareLinkInfo.getOtherParam().containsKey("fileInfo")); CacheLinkInfo cacheLinkInfo = new CacheLinkInfo(JsonObject.of( "directLink", redirectUrl, "expiration", expires, "shareKey", cacheKey )); + // 提取并设置文件信息 if (shareLinkInfo.getOtherParam().containsKey("fileInfo")) { try { FileInfo fileInfo = (FileInfo) shareLinkInfo.getOtherParam().get("fileInfo"); result.setFileInfo(fileInfo); cacheLinkInfo.setFileInfo(fileInfo); - } catch (Exception ignored) { - log.error("文件对象转换异常"); + log.info("[设置文件信息] shareKey={}, fileName={}, size={}", + cacheKey, fileInfo.getFileName(), fileInfo.getSize()); + } catch (Exception e) { + log.error("文件对象转换异常: shareKey={}", cacheKey, e); } + } else { + log.warn("[文件信息缺失] 解析器未返回fileInfo: shareKey={}, otherParam.keys={}", + cacheKey, shareLinkInfo.getOtherParam().keySet()); } + // 传递 downloadHeaders 并生成下载命令 + processDownloadHeaders(shareLinkInfo, cacheLinkInfo, result); promise.complete(result); // 更新缓存 - // 将直链存储到缓存 cacheManager.cacheShareLink(cacheLinkInfo); cacheManager.updateTotalByField(cacheKey, CacheTotalField.API_PARSER_TOTAL).onFailure(Throwable::printStackTrace); }).onFailure(promise::fail); } else { + // 缓存命中,生成过期时间并生成下载命令 result.setExpires(generateDate(result.getExpiration())); + + // 初始化 otherParam(如果为空) + if (result.getOtherParam() == null) { + result.setOtherParam(new HashMap<>()); + } + + // 生成下载命令(aria2、curl) + generateDownloadCommands(result); + promise.complete(result); - cacheManager.updateTotalByField(cacheKey, CacheTotalField.CACHE_HIT_TOTAL).onFailure(Throwable::printStackTrace); + cacheManager.updateTotalByField(cacheKey, CacheTotalField.CACHE_HIT_TOTAL) + .onFailure(Throwable::printStackTrace); } }).onFailure(t -> promise.fail(t.fillInStackTrace())); + return promise.future(); } @@ -86,6 +126,128 @@ public class CacheServiceImpl implements CacheService { return DateFormatUtils.format(new Date(ts), "yyyy-MM-dd HH:mm:ss"); } + /** + * 处理下载请求头并生成下载命令 + * 从 shareLinkInfo 中提取 downloadHeaders,传递到 cacheLinkInfo 和 result + */ + private void processDownloadHeaders(ShareLinkInfo shareLinkInfo, CacheLinkInfo cacheLinkInfo, + CacheLinkInfo result) { + try { + // 提取 downloadHeaders(如果不存在,使用空Map) + Map downloadHeaders = new HashMap<>(); + + if (shareLinkInfo.getOtherParam() != null + && shareLinkInfo.getOtherParam().containsKey("downloadHeaders")) { + @SuppressWarnings("unchecked") + Map headers = (Map) shareLinkInfo.getOtherParam().get("downloadHeaders"); + if (headers != null) { + downloadHeaders = headers; + log.info("从shareLinkInfo提取downloadHeaders: shareKey={}, 请求头数量={}", + cacheLinkInfo.getShareKey(), downloadHeaders.size()); + } + } + + // 初始化 otherParam + if (cacheLinkInfo.getOtherParam() == null) { + cacheLinkInfo.setOtherParam(new HashMap<>()); + } + if (result.getOtherParam() == null) { + result.setOtherParam(new HashMap<>()); + } + + // 传递 downloadHeaders 到两个对象 + cacheLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders); + result.getOtherParam().put("downloadHeaders", downloadHeaders); + + // 使用已有的工具类生成下载命令 + generateCommandsFromShareLinkInfo(shareLinkInfo, cacheLinkInfo, result); + + } catch (Exception e) { + log.error("处理下载请求头异常: shareKey={}", cacheLinkInfo.getShareKey(), e); + } + } + + /** + * 使用 ClientLinkGeneratorFactory 生成下载命令 + */ + private void generateCommandsFromShareLinkInfo(ShareLinkInfo shareLinkInfo, + CacheLinkInfo cacheLinkInfo, + CacheLinkInfo result) { + try { + // 使用已有的 ClientLinkGeneratorFactory 生成命令 + Map clientLinks = ClientLinkGeneratorFactory.generateAll(shareLinkInfo); + + // 提取各命令并存储 + String curlCommand = clientLinks.get(ClientLinkType.CURL); + String aria2Command = clientLinks.get(ClientLinkType.ARIA2); + String thunderLink = clientLinks.get(ClientLinkType.THUNDER); + + // 设置命令到 cacheLinkInfo 和 result + if (curlCommand != null) { + cacheLinkInfo.getOtherParam().put("curlCommand", curlCommand); + result.getOtherParam().put("curlCommand", curlCommand); + } + if (aria2Command != null) { + cacheLinkInfo.getOtherParam().put("aria2Command", aria2Command); + result.getOtherParam().put("aria2Command", aria2Command); + } + if (thunderLink != null) { + cacheLinkInfo.getOtherParam().put("thunderLink", thunderLink); + result.getOtherParam().put("thunderLink", thunderLink); + } + + log.debug("已生成下载命令: shareKey={}, commands={}", + cacheLinkInfo.getShareKey(), clientLinks.keySet()); + } catch (Exception e) { + log.error("生成下载命令异常: shareKey={}", cacheLinkInfo.getShareKey(), e); + } + } + + /** + * 生成下载命令(缓存命中时) + */ + private void generateDownloadCommands(CacheLinkInfo cacheLinkInfo) { + if (cacheLinkInfo.getDirectLink() == null || cacheLinkInfo.getDirectLink().isEmpty()) { + return; + } + + try { + // 构建临时 ShareLinkInfo 用于生成命令 + ShareLinkInfo tempInfo = ShareLinkInfo.newBuilder() + .shareUrl(cacheLinkInfo.getDirectLink()) + .build(); + tempInfo.getOtherParam().put("downloadUrl", cacheLinkInfo.getDirectLink()); + + // 复制 downloadHeaders + if (cacheLinkInfo.getOtherParam() != null + && cacheLinkInfo.getOtherParam().containsKey("downloadHeaders")) { + tempInfo.getOtherParam().put("downloadHeaders", cacheLinkInfo.getOtherParam().get("downloadHeaders")); + } + + // 复制文件信息 + if (cacheLinkInfo.getFileInfo() != null) { + tempInfo.getOtherParam().put("fileInfo", cacheLinkInfo.getFileInfo()); + } + + // 使用 ClientLinkGeneratorFactory 生成命令 + Map clientLinks = ClientLinkGeneratorFactory.generateAll(tempInfo); + + // 存储命令 + if (clientLinks.containsKey(ClientLinkType.CURL)) { + cacheLinkInfo.getOtherParam().put("curlCommand", clientLinks.get(ClientLinkType.CURL)); + } + if (clientLinks.containsKey(ClientLinkType.ARIA2)) { + cacheLinkInfo.getOtherParam().put("aria2Command", clientLinks.get(ClientLinkType.ARIA2)); + } + if (clientLinks.containsKey(ClientLinkType.THUNDER)) { + cacheLinkInfo.getOtherParam().put("thunderLink", clientLinks.get(ClientLinkType.THUNDER)); + } + + } catch (Exception e) { + log.error("生成下载命令异常: shareKey={}", cacheLinkInfo.getShareKey(), e); + } + } + @Override public Future getCachedByShareKeyAndPwd(String type, String shareKey, String pwd, JsonObject otherParam) { ParserCreate parserCreate = ParserCreate.fromType(type).shareKey(shareKey).setShareLinkInfoPwd(pwd); @@ -97,6 +259,21 @@ public class CacheServiceImpl implements CacheService { public Future getCachedByShareUrlAndPwd(String shareUrl, String pwd, JsonObject otherParam) { ParserCreate parserCreate = ParserCreate.fromShareUrl(shareUrl).setShareLinkInfoPwd(pwd); parserCreate.getShareLinkInfo().getOtherParam().putAll(otherParam.getMap()); + + // 检查是否有临时认证参数 + if (otherParam.containsKey("authType") || otherParam.containsKey("authToken")) { + log.debug("从otherParam中检测到临时认证参数"); + URLParamUtil.addTempAuthParam(parserCreate, + otherParam.getString("authType"), + otherParam.getString("authToken"), + otherParam.getString("authPassword"), + otherParam.getString("authInfo1"), + otherParam.getString("authInfo2"), + otherParam.getString("authInfo3"), + otherParam.getString("authInfo4"), + otherParam.getString("authInfo5")); + } + return getAndSaveCachedShareLink(parserCreate); } } diff --git a/web-service/src/main/resources/app-dev.yml b/web-service/src/main/resources/app-dev.yml index 0418cf4..be00c33 100644 --- a/web-service/src/main/resources/app-dev.yml +++ b/web-service/src/main/resources/app-dev.yml @@ -8,6 +8,8 @@ server: domainName: http://127.0.0.1:6401 # 预览服务URL previewURL: https://nfd-parser.github.io/nfd-preview/preview.html?src= + # auth参数加密密钥(16位AES密钥) + authEncryptKey: 'nfd_auth_key2026' # domainName: https://lz.qaiu.top # 反向代理服务器配置路径(不用加后缀) diff --git a/web-service/src/test/java/cn/qaiu/lz/common/util/AuthParamCodecTest.java b/web-service/src/test/java/cn/qaiu/lz/common/util/AuthParamCodecTest.java new file mode 100644 index 0000000..f49ccb5 --- /dev/null +++ b/web-service/src/test/java/cn/qaiu/lz/common/util/AuthParamCodecTest.java @@ -0,0 +1,336 @@ +package cn.qaiu.lz.common.util; + +import cn.qaiu.lz.web.model.AuthParam; +import cn.qaiu.util.AESUtils; +import io.vertx.core.json.JsonObject; +import org.junit.Test; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.Assert.*; + +/** + * 认证参数编解码测试 + * + * @author QAIU + * @date 2026/2/5 + */ +public class AuthParamCodecTest { + + // 测试用的固定密钥 + private static final String TEST_KEY = "nfd_auth_key2026"; + + @Test + public void testAuthParamModel() { + // 测试构建器 + AuthParam authParam = AuthParam.builder() + .authType("accesstoken") + .token("test_token_123") + .build(); + + assertEquals("accesstoken", authParam.getAuthType()); + assertEquals("test_token_123", authParam.getToken()); + assertTrue(authParam.hasValidAuth()); + assertEquals("test_token_123", authParam.getPrimaryCredential()); + } + + @Test + public void testAuthParamFromJson() { + JsonObject json = new JsonObject() + .put("authType", "password") + .put("username", "testuser") + .put("password", "testpass"); + + AuthParam authParam = new AuthParam(json); + + assertEquals("password", authParam.getAuthType()); + assertEquals("testuser", authParam.getUsername()); + assertEquals("testpass", authParam.getPassword()); + assertTrue(authParam.hasValidAuth()); + } + + @Test + public void testAuthParamToJson() { + AuthParam authParam = AuthParam.builder() + .authType("cookie") + .token("session=abc123") + .ext1("key1:value1") + .build(); + + JsonObject json = authParam.toJsonObject(); + + assertEquals("cookie", json.getString("authType")); + assertEquals("session=abc123", json.getString("token")); + assertEquals("key1:value1", json.getString("ext1")); + assertNull(json.getString("username")); // 未设置的字段应为 null + } + + @Test + public void testManualEncodeDecodeToken() throws Exception { + // 构建原始 JSON + JsonObject original = new JsonObject() + .put("authType", "accesstoken") + .put("token", "my_access_token_12345"); + + String jsonStr = original.encode(); + System.out.println("原始 JSON: " + jsonStr); + + // Step 1: AES 加密 + Base64 编码 + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + System.out.println("AES+Base64 编码: " + base64Encoded); + + // Step 2: URL 编码 + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + System.out.println("URL 编码: " + urlEncoded); + + // ===== 解码流程 ===== + + // Step 1: URL 解码 + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + assertEquals(base64Encoded, urlDecoded); + + // Step 2: Base64 解码 + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + + // Step 3: AES 解密 + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + System.out.println("解密后 JSON: " + decryptedJson); + + // Step 4: JSON 解析 + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("accesstoken", decoded.getString("authType")); + assertEquals("my_access_token_12345", decoded.getString("token")); + } + + @Test + public void testManualEncodeDecodePassword() throws Exception { + JsonObject original = new JsonObject() + .put("authType", "password") + .put("username", "testuser") + .put("password", "testpassword123"); + + String jsonStr = original.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + System.out.println("用户名密码认证 - 加密结果: " + urlEncoded); + + // 解码验证 + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("password", decoded.getString("authType")); + assertEquals("testuser", decoded.getString("username")); + assertEquals("testpassword123", decoded.getString("password")); + } + + @Test + public void testManualEncodeDecodeCookie() throws Exception { + JsonObject original = new JsonObject() + .put("authType", "cookie") + .put("token", "session_id=abc123xyz; user_token=def456"); + + String jsonStr = original.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + System.out.println("Cookie 认证 - 加密结果: " + urlEncoded); + + // 解码验证 + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("cookie", decoded.getString("authType")); + assertEquals("session_id=abc123xyz; user_token=def456", decoded.getString("token")); + } + + @Test + public void testManualEncodeDecodeCustom() throws Exception { + JsonObject original = new JsonObject() + .put("authType", "custom") + .put("token", "main_token") + .put("ext1", "refresh_token:rt_12345") + .put("ext2", "device_id:device_abc") + .put("ext3", "app_version:1.0.0"); + + String jsonStr = original.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + System.out.println("自定义认证 - 加密结果: " + urlEncoded); + + // 解码验证 + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("custom", decoded.getString("authType")); + assertEquals("main_token", decoded.getString("token")); + assertEquals("refresh_token:rt_12345", decoded.getString("ext1")); + assertEquals("device_id:device_abc", decoded.getString("ext2")); + assertEquals("app_version:1.0.0", decoded.getString("ext3")); + } + + @Test + public void testPrimaryCredentialPriority() { + // token 优先级最高 + AuthParam authParam1 = AuthParam.builder() + .token("token_value") + .cookie("cookie_value") + .auth("auth_value") + .username("user_value") + .build(); + assertEquals("token_value", authParam1.getPrimaryCredential()); + + // 没有 token 时,cookie 优先 + AuthParam authParam2 = AuthParam.builder() + .cookie("cookie_value") + .auth("auth_value") + .username("user_value") + .build(); + assertEquals("cookie_value", authParam2.getPrimaryCredential()); + + // 没有 token 和 cookie 时,auth 优先 + AuthParam authParam3 = AuthParam.builder() + .auth("auth_value") + .username("user_value") + .build(); + assertEquals("auth_value", authParam3.getPrimaryCredential()); + + // 只有 username 时 + AuthParam authParam4 = AuthParam.builder() + .username("user_value") + .build(); + assertEquals("user_value", authParam4.getPrimaryCredential()); + } + + @Test + public void testEmptyAuthParam() { + AuthParam authParam = new AuthParam(); + assertFalse(authParam.hasValidAuth()); + assertNull(authParam.getPrimaryCredential()); + + AuthParam authParam2 = new AuthParam(null); + assertFalse(authParam2.hasValidAuth()); + } + + @Test + public void testSpecialCharacters() throws Exception { + JsonObject original = new JsonObject() + .put("authType", "cookie") + .put("token", "name=中文测试; value=!@#$%^&*()_+-={}|[]\\:\";'<>?,./"); + + String jsonStr = original.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + System.out.println("特殊字符测试 - 加密结果: " + urlEncoded); + + // 解码验证 + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("cookie", decoded.getString("authType")); + assertEquals("name=中文测试; value=!@#$%^&*()_+-={}|[]\\:\";'<>?,./", decoded.getString("token")); + } + + @Test + public void testGenerateAuthForApiCall() throws Exception { + // 模拟实际使用场景 + JsonObject authJson = new JsonObject() + .put("authType", "accesstoken") + .put("token", "real_token_for_api_test"); + + String jsonStr = authJson.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String auth = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + // 构建完整的 API URL + String baseUrl = "http://127.0.0.1:6400/parser"; + String shareUrl = "https://www.lanzoux.com/test123"; + String pwd = "abcd"; + + String fullUrl = String.format("%s?url=%s&pwd=%s&auth=%s", + baseUrl, + URLEncoder.encode(shareUrl, StandardCharsets.UTF_8), + pwd, + auth); + + System.out.println("=== 生成的完整 API 调用 URL ==="); + System.out.println(fullUrl); + System.out.println("=== auth 参数值 ==="); + System.out.println(auth); + + // 验证 URL 格式正确 + assertTrue(fullUrl.contains("url=")); + assertTrue(fullUrl.contains("pwd=")); + assertTrue(fullUrl.contains("auth=")); + } + + @Test + public void printEncryptionExamples() throws Exception { + System.out.println("\n========== 认证参数加密示例 ==========\n"); + + // 1. AccessToken + JsonObject tokenAuth = new JsonObject() + .put("authType", "accesstoken") + .put("token", "example_access_token"); + String tokenEncrypted = URLEncoder.encode( + AESUtils.encryptBase64ByAES(tokenAuth.encode(), TEST_KEY), + StandardCharsets.UTF_8); + System.out.println("1. AccessToken 认证:"); + System.out.println(" 原始: " + tokenAuth.encode()); + System.out.println(" 加密: " + tokenEncrypted); + System.out.println(); + + // 2. Cookie + JsonObject cookieAuth = new JsonObject() + .put("authType", "cookie") + .put("token", "session=abc123"); + String cookieEncrypted = URLEncoder.encode( + AESUtils.encryptBase64ByAES(cookieAuth.encode(), TEST_KEY), + StandardCharsets.UTF_8); + System.out.println("2. Cookie 认证:"); + System.out.println(" 原始: " + cookieAuth.encode()); + System.out.println(" 加密: " + cookieEncrypted); + System.out.println(); + + // 3. 用户名密码 + JsonObject passwordAuth = new JsonObject() + .put("authType", "password") + .put("username", "testuser") + .put("password", "testpass"); + String passwordEncrypted = URLEncoder.encode( + AESUtils.encryptBase64ByAES(passwordAuth.encode(), TEST_KEY), + StandardCharsets.UTF_8); + System.out.println("3. 用户名密码认证:"); + System.out.println(" 原始: " + passwordAuth.encode()); + System.out.println(" 加密: " + passwordEncrypted); + System.out.println(); + + // 4. 自定义 + JsonObject customAuth = new JsonObject() + .put("authType", "custom") + .put("token", "main_token") + .put("ext1", "key1:value1"); + String customEncrypted = URLEncoder.encode( + AESUtils.encryptBase64ByAES(customAuth.encode(), TEST_KEY), + StandardCharsets.UTF_8); + System.out.println("4. 自定义认证:"); + System.out.println(" 原始: " + customAuth.encode()); + System.out.println(" 加密: " + customEncrypted); + System.out.println(); + + System.out.println("========== 示例结束 ==========\n"); + } +} diff --git a/web-service/src/test/java/cn/qaiu/lz/common/util/AuthParamCodecTestMain.java b/web-service/src/test/java/cn/qaiu/lz/common/util/AuthParamCodecTestMain.java new file mode 100644 index 0000000..37c2571 --- /dev/null +++ b/web-service/src/test/java/cn/qaiu/lz/common/util/AuthParamCodecTestMain.java @@ -0,0 +1,416 @@ +package cn.qaiu.lz.common.util; + +import cn.qaiu.lz.web.model.AuthParam; +import cn.qaiu.util.AESUtils; +import io.vertx.core.json.JsonObject; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * 认证参数编解码测试 - 独立运行版本 + * 运行方法:在 IDE 中直接运行此类的 main 方法 + * + * @author QAIU + * @date 2026/2/5 + */ +public class AuthParamCodecTestMain { + + private static final String TEST_KEY = "nfd_auth_key2026"; + private static int passCount = 0; + private static int failCount = 0; + + public static void main(String[] args) { + System.out.println("========== 认证参数编解码测试 ==========\n"); + + testAuthParamModel(); + testAuthParamFromJson(); + testAuthParamToJson(); + testManualEncodeDecodeToken(); + testManualEncodeDecodePassword(); + testManualEncodeDecodeCookie(); + testManualEncodeDecodeCustom(); + testPrimaryCredentialPriority(); + testEmptyAuthParam(); + testSpecialCharacters(); + testGenerateAuthForApiCall(); + printEncryptionExamples(); + + System.out.println("\n========== 测试结果汇总 =========="); + System.out.println("通过: " + passCount + ", 失败: " + failCount); + System.out.println("========== 测试结束 ==========\n"); + } + + private static void testAuthParamModel() { + System.out.println("测试: AuthParam 模型基本功能"); + try { + AuthParam authParam = AuthParam.builder() + .authType("accesstoken") + .token("test_token_123") + .build(); + + assertEquals("accesstoken", authParam.getAuthType()); + assertEquals("test_token_123", authParam.getToken()); + assertTrue(authParam.hasValidAuth()); + assertEquals("test_token_123", authParam.getPrimaryCredential()); + pass(); + } catch (AssertionError e) { + fail(e.getMessage()); + } + } + + private static void testAuthParamFromJson() { + System.out.println("测试: AuthParam 从 JsonObject 构造"); + try { + JsonObject json = new JsonObject() + .put("authType", "password") + .put("username", "testuser") + .put("password", "testpass"); + + AuthParam authParam = new AuthParam(json); + + assertEquals("password", authParam.getAuthType()); + assertEquals("testuser", authParam.getUsername()); + assertEquals("testpass", authParam.getPassword()); + assertTrue(authParam.hasValidAuth()); + pass(); + } catch (AssertionError e) { + fail(e.getMessage()); + } + } + + private static void testAuthParamToJson() { + System.out.println("测试: AuthParam 转换为 JsonObject"); + try { + AuthParam authParam = AuthParam.builder() + .authType("cookie") + .token("session=abc123") + .ext1("key1:value1") + .build(); + + JsonObject json = authParam.toJsonObject(); + + assertEquals("cookie", json.getString("authType")); + assertEquals("session=abc123", json.getString("token")); + assertEquals("key1:value1", json.getString("ext1")); + assertNull(json.getString("username")); + pass(); + } catch (AssertionError e) { + fail(e.getMessage()); + } + } + + private static void testManualEncodeDecodeToken() { + System.out.println("测试: 手动编解码流程 - Token 认证"); + try { + JsonObject original = new JsonObject() + .put("authType", "accesstoken") + .put("token", "my_access_token_12345"); + + String jsonStr = original.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + // 解码验证 + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + assertEquals(base64Encoded, urlDecoded); + + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("accesstoken", decoded.getString("authType")); + assertEquals("my_access_token_12345", decoded.getString("token")); + pass(); + } catch (Exception e) { + fail(e.getMessage()); + } + } + + private static void testManualEncodeDecodePassword() { + System.out.println("测试: 手动编解码流程 - 用户名密码认证"); + try { + JsonObject original = new JsonObject() + .put("authType", "password") + .put("username", "testuser") + .put("password", "testpassword123"); + + String jsonStr = original.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("password", decoded.getString("authType")); + assertEquals("testuser", decoded.getString("username")); + assertEquals("testpassword123", decoded.getString("password")); + pass(); + } catch (Exception e) { + fail(e.getMessage()); + } + } + + private static void testManualEncodeDecodeCookie() { + System.out.println("测试: 手动编解码流程 - Cookie 认证"); + try { + JsonObject original = new JsonObject() + .put("authType", "cookie") + .put("token", "session_id=abc123xyz; user_token=def456"); + + String jsonStr = original.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("cookie", decoded.getString("authType")); + assertEquals("session_id=abc123xyz; user_token=def456", decoded.getString("token")); + pass(); + } catch (Exception e) { + fail(e.getMessage()); + } + } + + private static void testManualEncodeDecodeCustom() { + System.out.println("测试: 手动编解码流程 - 自定义认证"); + try { + JsonObject original = new JsonObject() + .put("authType", "custom") + .put("token", "main_token") + .put("ext1", "refresh_token:rt_12345") + .put("ext2", "device_id:device_abc") + .put("ext3", "app_version:1.0.0"); + + String jsonStr = original.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("custom", decoded.getString("authType")); + assertEquals("main_token", decoded.getString("token")); + assertEquals("refresh_token:rt_12345", decoded.getString("ext1")); + assertEquals("device_id:device_abc", decoded.getString("ext2")); + assertEquals("app_version:1.0.0", decoded.getString("ext3")); + pass(); + } catch (Exception e) { + fail(e.getMessage()); + } + } + + private static void testPrimaryCredentialPriority() { + System.out.println("测试: 主要凭证优先级"); + try { + // token 优先级最高 + AuthParam authParam1 = AuthParam.builder() + .token("token_value") + .cookie("cookie_value") + .auth("auth_value") + .username("user_value") + .build(); + assertEquals("token_value", authParam1.getPrimaryCredential()); + + // 没有 token 时,cookie 优先 + AuthParam authParam2 = AuthParam.builder() + .cookie("cookie_value") + .auth("auth_value") + .username("user_value") + .build(); + assertEquals("cookie_value", authParam2.getPrimaryCredential()); + + // 没有 token 和 cookie 时,auth 优先 + AuthParam authParam3 = AuthParam.builder() + .auth("auth_value") + .username("user_value") + .build(); + assertEquals("auth_value", authParam3.getPrimaryCredential()); + + // 只有 username 时 + AuthParam authParam4 = AuthParam.builder() + .username("user_value") + .build(); + assertEquals("user_value", authParam4.getPrimaryCredential()); + pass(); + } catch (AssertionError e) { + fail(e.getMessage()); + } + } + + private static void testEmptyAuthParam() { + System.out.println("测试: 空认证参数"); + try { + AuthParam authParam = new AuthParam(); + assertFalse(authParam.hasValidAuth()); + assertNull(authParam.getPrimaryCredential()); + + AuthParam authParam2 = new AuthParam(null); + assertFalse(authParam2.hasValidAuth()); + pass(); + } catch (AssertionError e) { + fail(e.getMessage()); + } + } + + private static void testSpecialCharacters() { + System.out.println("测试: 特殊字符处理"); + try { + JsonObject original = new JsonObject() + .put("authType", "cookie") + .put("token", "name=中文测试; value=!@#$%^&*()_+-={}|[]\\:\";'<>?,./"); + + String jsonStr = original.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String urlEncoded = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + String urlDecoded = URLDecoder.decode(urlEncoded, StandardCharsets.UTF_8); + byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + String decryptedJson = AESUtils.decryptByAES(base64Decoded, TEST_KEY); + + JsonObject decoded = new JsonObject(decryptedJson); + assertEquals("cookie", decoded.getString("authType")); + assertEquals("name=中文测试; value=!@#$%^&*()_+-={}|[]\\:\";'<>?,./", decoded.getString("token")); + pass(); + } catch (Exception e) { + fail(e.getMessage()); + } + } + + private static void testGenerateAuthForApiCall() { + System.out.println("测试: 生成可用于接口调用的 auth 参数"); + try { + JsonObject authJson = new JsonObject() + .put("authType", "accesstoken") + .put("token", "real_token_for_api_test"); + + String jsonStr = authJson.encode(); + String base64Encoded = AESUtils.encryptBase64ByAES(jsonStr, TEST_KEY); + String auth = URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); + + String baseUrl = "http://127.0.0.1:6400/parser"; + String shareUrl = "https://www.lanzoux.com/test123"; + String pwd = "abcd"; + + String fullUrl = String.format("%s?url=%s&pwd=%s&auth=%s", + baseUrl, + URLEncoder.encode(shareUrl, StandardCharsets.UTF_8), + pwd, + auth); + + System.out.println(" 生成的完整 API 调用 URL:"); + System.out.println(" " + fullUrl); + + assertTrue(fullUrl.contains("url=")); + assertTrue(fullUrl.contains("pwd=")); + assertTrue(fullUrl.contains("auth=")); + pass(); + } catch (Exception e) { + fail(e.getMessage()); + } + } + + private static void printEncryptionExamples() { + System.out.println("\n========== 认证参数加密示例(供接口调用参考)==========\n"); + try { + // 1. AccessToken + JsonObject tokenAuth = new JsonObject() + .put("authType", "accesstoken") + .put("token", "example_access_token"); + String tokenEncrypted = URLEncoder.encode( + AESUtils.encryptBase64ByAES(tokenAuth.encode(), TEST_KEY), + StandardCharsets.UTF_8); + System.out.println("1. AccessToken 认证:"); + System.out.println(" 原始: " + tokenAuth.encode()); + System.out.println(" 加密: " + tokenEncrypted); + System.out.println(); + + // 2. Cookie + JsonObject cookieAuth = new JsonObject() + .put("authType", "cookie") + .put("token", "session=abc123"); + String cookieEncrypted = URLEncoder.encode( + AESUtils.encryptBase64ByAES(cookieAuth.encode(), TEST_KEY), + StandardCharsets.UTF_8); + System.out.println("2. Cookie 认证:"); + System.out.println(" 原始: " + cookieAuth.encode()); + System.out.println(" 加密: " + cookieEncrypted); + System.out.println(); + + // 3. 用户名密码 + JsonObject passwordAuth = new JsonObject() + .put("authType", "password") + .put("username", "testuser") + .put("password", "testpass"); + String passwordEncrypted = URLEncoder.encode( + AESUtils.encryptBase64ByAES(passwordAuth.encode(), TEST_KEY), + StandardCharsets.UTF_8); + System.out.println("3. 用户名密码认证:"); + System.out.println(" 原始: " + passwordAuth.encode()); + System.out.println(" 加密: " + passwordEncrypted); + System.out.println(); + + // 4. 自定义 + JsonObject customAuth = new JsonObject() + .put("authType", "custom") + .put("token", "main_token") + .put("ext1", "key1:value1"); + String customEncrypted = URLEncoder.encode( + AESUtils.encryptBase64ByAES(customAuth.encode(), TEST_KEY), + StandardCharsets.UTF_8); + System.out.println("4. 自定义认证:"); + System.out.println(" 原始: " + customAuth.encode()); + System.out.println(" 加密: " + customEncrypted); + System.out.println(); + + pass(); + } catch (Exception e) { + fail(e.getMessage()); + } + } + + // 断言方法 + private static void assertEquals(Object expected, Object actual) { + if (expected == null && actual == null) return; + if (expected == null || !expected.equals(actual)) { + throw new AssertionError("期望: " + expected + ", 实际: " + actual); + } + } + + private static void assertTrue(boolean condition) { + if (!condition) { + throw new AssertionError("期望为 true,实际为 false"); + } + } + + private static void assertFalse(boolean condition) { + if (condition) { + throw new AssertionError("期望为 false,实际为 true"); + } + } + + private static void assertNull(Object obj) { + if (obj != null) { + throw new AssertionError("期望为 null,实际为: " + obj); + } + } + + private static void pass() { + passCount++; + System.out.println(" ✓ 通过\n"); + } + + private static void fail(String message) { + failCount++; + System.out.println(" ✗ 失败: " + message + "\n"); + } +} diff --git a/webroot/auth-encrypt.html b/webroot/auth-encrypt.html new file mode 100644 index 0000000..3f9e895 --- /dev/null +++ b/webroot/auth-encrypt.html @@ -0,0 +1,444 @@ + + + + + + 认证参数加密工具 + + + +
+

🔐 认证参数加密工具

+

在线加密网盘认证参数,用于调用解析接口

+ +
+

加密算法: AES/ECB/PKCS5Padding

+

默认密钥: nfd_auth_key2026 (16位)

+

编码流程: JSON对象 → AES加密 → Base64编码 → URL编码

+
+ +
+
+ + +
+ +
+ + +
+ 示例: session_id=abc123; user_token=xyz789 +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ 示例: refresh_token:your_refresh_token +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ + +
+ + + + +