Compare commits

..
Author SHA1 Message Date
qandCursor 422d5b845b fix: recognize 123pan uid.share links and trim paste input
Clipboard auto-detect now matches {userId}.share.123pan.cn/123pan/{key}, and the share URL field is trimmed on paste and blur. Split web-service compiler args so IDE rebuilds no longer wipe classes.

Co-authored-by: Cursor <[email protected]>
2026-08-18 01:22:47 +08:00
qandCursor 054e9cc1ec fix: harden gzip decode and release 0.4.5
Avoid ZipException when Vert.x already decompressed gzip bodies, keep jackson-databind on the IDE classpath, and show a real build version instead of unknown.

Co-authored-by: Cursor <[email protected]>
2026-08-18 01:00:32 +08:00
qandCursor f3da45bb16 fix(lz): port Lanzou parser to wwww.lanzoux.com flow
Use the new share/ajax/verify pipeline (arg1 retry, regex ajax extract, delayed CDN verify) on the open-source PanBase client, keeping proxy support and complete() for downloadUrl.

Bump version to 0.4.4.

Co-authored-by: Cursor <[email protected]>
2026-08-18 00:25:49 +08:00
qandCursor 273dbae5e7 ci: only publish GitHub releases for v* tags
Agent/feature tags were matching '*' and becoming Latest, which also multiplied auto-generated notes.

Co-authored-by: Cursor <[email protected]>
2026-08-18 00:22:02 +08:00
q e103003b7a Merge branch 'main' of github.com:qaiu/netdisk-fast-download 2026-08-18 00:21:51 +08:00
qandCursor e853365fe4 fix(lz): adapt new Lanzou pages and stop duplicate release notes
Complete the fake jQuery/document sandbox for cookie, location, querySelector and chained APIs, extract ajax params by regex first with JS fallback, and generate GitHub release notes in a single job so matrix OS uploads no longer append What's Changed twice.

Bump version to 0.4.3.

Co-authored-by: Cursor <[email protected]>
2026-08-18 00:21:03 +08:00
qaiu b5f7d9694b 更新 README.md 2026-08-16 21:20:04 +08:00
qaiu f75d9f687b 更新 README.md 2026-08-16 21:18:37 +08:00
qaiu 703bfa3061 Merge pull request #213 from qaiu/claude/fangcloud-share-validity-check-9fzoi6
fix(parser): FangCloud share validity check reads the wrong JSON field
2026-08-13 16:05:23 +08:00
Claude 6c709fde65 fix(parser): fix FangCloud share validity check and h5 share link regex
Root cause confirmed against a live share link: FcTool never actually
checked share validity against the info API — it read a top-level
"is_valid" field that the endpoint doesn't return. The real validity
flags (process.is_closed / process.is_expired) live under "process",
so an invalid share fell through to HTML scraping and surfaced a
confusing "未匹配到文件id(typed_id)" error instead of a clear
"分享已失效" message. parse() now checks share_links/info first and
fails fast with a clear message when the share is closed/expired.

Also widen PanDomainTemplate's FC regex to accept the mobile H5
landing page path (/h5/share/{key}), which previously didn't match
at all.

Updated verify_fangcloud_share.py to reflect the confirmed API
response shape instead of the earlier incorrect guess.
2026-08-13 07:46:29 +00:00
Claude 449fd741ac Add diagnostic script for FangCloud share validity check
FcTool.java's share validity check hits a share_links/info endpoint on
v2.fangcloud.cn that doesn't appear in the project's own captured
traffic (pan-fc.http) or public docs, and its regex in
PanDomainTemplate doesn't match the /h5/share/ path used by mobile
links. This script reproduces both requests against a live share URL
to confirm the root cause before fixing the parser.
2026-08-13 07:29:42 +00:00
qaiu c18b8d80e1 更新 README.md 2026-08-07 09:36:35 +08:00
qaiu 06ba0aff52 Merge pull request #211 from qaiu/copilot/update-readme-to-latest-version
docs: update download URL to latest release v0.4.2
2026-08-05 21:42:39 +08:00
copilot-swe-agent[bot]andqaiu 74e401d99c docs: fix download URL and zip filename for v0.4.2 linux-amd64
Co-authored-by: qaiu <[email protected]>
2026-08-05 13:28:37 +00:00
copilot-swe-agent[bot]andqaiu 4995bca901 docs: update download URL to latest release v0.4.2
Co-authored-by: qaiu <[email protected]>
2026-08-05 13:18:11 +00:00
qaiu 7299fd8762 Merge pull request #207 from qaiu/cursor/ghsa-997r-ssrf-verify-2b8b
fix(security): GHSA-997r SSRF verification + residual hardening
2026-07-26 21:10:39 +08:00
Cursor Agentandqaiu efbadde4ed fix(security): harden GHSA-997r Cloudreve SSRF residual paths
Disable redirect following on CE/Ce4 attacker-controlled requests and stop
echoing upstream response bodies in client-facing JSON errors. Add
assertPublicHost regression coverage for the advisory PoC hosts.

Co-authored-by: qaiu <[email protected]>
2026-07-26 12:05:16 +00:00
qandCursor d1fa787bef fix(uc/qk): improve directory download with cookie, transfer fallback and URL-safe param encoding
- Preserve download cookie for UC/Quark needDownloader flows and disable browser/copy when required
- Quark: share-link first, transfer only on size limit (23018), reuse savedFileCache/search_exit
- Propagate auth to subdirectory parser URLs; switch path params to URL-safe Base64 (no double encode)
- Bump version to 0.4.2

Fixes #205

Co-authored-by: Cursor <[email protected]>
2026-07-26 16:16:51 +08:00
q c282fcb109 Merge branch 'main' into feature/ysepan-parser 2026-07-26 13:20:16 +08:00
qandCursor 4995173de2 feat: add Yongshuo E-pan (ysepan) share parser
Support space shares like https://xxx.ysepan.com/ with password auth,
directory/file listing, and direct download URL construction.

Co-authored-by: Cursor <[email protected]>
2026-07-26 12:37:38 +08:00
qaiu 1c1068beba Merge pull request #206 from qaiu/dependabot/maven/maven-858658338e
chore(deps): bump the maven group across 1 directory with 2 updates
2026-07-26 09:04:56 +08:00
dependabot[bot] 0f34672e33 chore(deps): bump the maven group across 1 directory with 2 updates
Bumps the maven group with 2 updates in the / directory: [ch.qos.logback:logback-core](https://github.com/qos-ch/logback) and [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson).


Updates `ch.qos.logback:logback-core` from 1.5.33 to 1.5.34
- [Release notes](https://github.com/qos-ch/logback/releases)
- [Commits](https://github.com/qos-ch/logback/compare/v_1.5.33...v_1.5.34)

Updates `com.fasterxml.jackson.core:jackson-databind` from 2.18.6 to 2.18.9
- [Commits](https://github.com/FasterXML/jackson/commits)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-core
  dependency-version: 1.5.34
  dependency-type: direct:production
  dependency-group: maven
- dependency-name: com.fasterxml.jackson.core:jackson-databind
  dependency-version: 2.18.9
  dependency-type: direct:production
  dependency-group: maven
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-26 00:46:44 +00:00
36 changed files with 4784 additions and 895 deletions
+21 -3
View File
@@ -8,7 +8,7 @@ on:
workflow_dispatch: workflow_dispatch:
push: push:
tags: tags:
- '*' - 'v*'
branches-ignore: branches-ignore:
- '*' - '*'
paths-ignore: paths-ignore:
@@ -262,9 +262,27 @@ jobs:
name: ${{ matrix.artifact-name }} name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-name }}.zip path: ${{ matrix.artifact-name }}.zip
- name: 上传到 Release # ================================================================
# 阶段三:创建 GitHub Release(只跑一次,避免矩阵任务重复拼接更新日志)
# ================================================================
publish-release:
name: 发布 GitHub Release
needs: native-package
if: github.event_name != 'pull_request' && github.ref_type == 'tag'
runs-on: ubuntu-latest
steps:
- name: 下载原生安装包
uses: actions/download-artifact@v4
with:
pattern: netdisk-fast-download-*
merge-multiple: true
- name: 创建 Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: ${{ matrix.artifact-name }}.zip files: |
netdisk-fast-download-linux-amd64.zip
netdisk-fast-download-windows-amd64.zip
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
generate_release_notes: true generate_release_notes: true
fail_on_unmatched_files: true
+46 -28
View File
@@ -20,7 +20,7 @@ QQ交流群:1017480890
## 介绍 ## 介绍
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。 > netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/永硕E盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
[官方文档](https://nfd-parser.github.io/) [官方文档](https://nfd-parser.github.io/)
[API接入](https://nfdparser.apifox.cn/) [API接入](https://nfdparser.apifox.cn/)
@@ -28,25 +28,31 @@ QQ交流群:1017480890
[公益解析,lz0站](https://lz0.qaiu.top) [公益解析,lz0站](https://lz0.qaiu.top)
[专业版](https://189.qaiu.top) [专业版](https://189.qaiu.top)
## 快速开始 ## 快速开始
命令行下载分享文件:
```shell
curl -LOJ "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234"
```
或者使用wget:
```shell
wget -O bilibili.mp4 "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234"
```
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4):
```
### 调用演示站下载:
https://lz.qaiu.top/parser?url=https://www.ilanzou.com/s/CDx6xKbT&pwd=1234
### 调用演示站预览:
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4
以带提取码的分享链接为例(提取码 `1234`),链接中含 `#``&`,作为 `url` 参数传入前必须做 URL 编码:
`https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data=70017ece572737b12b30709b7f029308eJD0&isShare=1`
**命令行下载**
```shell
# curl
curl -LOJ "https://lz.qaiu.top/parser?url=https%3A%2F%2Fwww.ecpan.cn%2Fweb%2F%23%2FyunpanProxy%3Fpath%3D%252F%2523%252Fdrive%252Foutside%26data%3D70017ece572737b12b30709b7f029308eJD0%26isShare%3D1&pwd=1234"
# wget
wget -O v01 "https://lz.qaiu.top/parser?url=https%3A%2F%2Fwww.ecpan.cn%2Fweb%2F%23%2FyunpanProxy%3Fpath%3D%252F%2523%252Fdrive%252Foutside%26data%3D70017ece572737b12b30709b7f029308eJD0%26isShare%3D1&pwd=1234"
``` ```
**短链方式**key 取分享链接的 `data` 参数,提取码用 `@` 拼接)
| 用途 | 地址 |
| --- | --- |
| 下载 | `https://lz.qaiu.top/ec/70017ece572737b12b30709b7f029308eJD0@1234` |
| 在线预览 | [点击访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fec%2F70017ece572737b12b30709b7f029308eJD0%401234&name=v01&ext=mp4) |
> 无提取码时去掉 `&pwd=1234` / `@1234` 即可;预览页 `src` 需编码(`@` → `%40`),用短链可避免二次编码。
**解析器模块文档:** [parser/README.md](parser/README.md) **解析器模块文档:** [parser/README.md](parser/README.md)
**JavaScript解析器文档:** [JavaScript解析器开发指南](parser/doc/JAVASCRIPT_PARSER_GUIDE.md) | [自定义解析器扩展指南](parser/doc/CUSTOM_PARSER_GUIDE.md) | [快速开始](parser/doc/CUSTOM_PARSER_QUICKSTART.md) **JavaScript解析器文档:** [JavaScript解析器开发指南](parser/doc/JAVASCRIPT_PARSER_GUIDE.md) | [自定义解析器扩展指南](parser/doc/CUSTOM_PARSER_GUIDE.md) | [快速开始](parser/doc/CUSTOM_PARSER_QUICKSTART.md)
@@ -76,6 +82,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
- [QQ邮箱云盘-qqw](https://mail.qq.com/) - [QQ邮箱云盘-qqw](https://mail.qq.com/)
- [QQ闪传-qqsc](https://nutty.qq.com/nutty/ssr/26797.html) - [QQ闪传-qqsc](https://nutty.qq.com/nutty/ssr/26797.html)
- [城通网盘-ct](https://www.ctfile.com) - [城通网盘-ct](https://www.ctfile.com)
- [永硕E盘-ys](https://www.ysepan.com/)(空间分享,如 `https://xxx.ysepan.com/`,密码为空间访问密码;多文件请用文件列表接口)
- [网易云音乐分享链接-mnes](https://music.163.com) - [网易云音乐分享链接-mnes](https://music.163.com)
- [酷狗音乐分享链接-mkgs](https://www.kugou.com) - [酷狗音乐分享链接-mkgs](https://www.kugou.com)
- [酷我音乐分享链接-mkws](https://kuwo.cn) - [酷我音乐分享链接-mkws](https://kuwo.cn)
@@ -132,6 +139,7 @@ GET /json/parser?url={分享链接}&pwd={密码}
``` ```
GET /json/{网盘标识}/{分享key}@{密码} GET /json/{网盘标识}/{分享key}@{密码}
``` ```
注意: 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
#### 3. 文件夹解析(v0.1.8fixed3+ #### 3. 文件夹解析(v0.1.8fixed3+
@@ -229,11 +237,6 @@ auths:
如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。 如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。
### 特殊说明
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
- 移动云云空间、小飞机网盘的加密分享可忽略密码参数
### 示例 ### 示例
```bash ```bash
@@ -309,9 +312,12 @@ json返回数据格式示例:
"timestamp": 1736489219402 "timestamp": 1736489219402
} }
``` ```
#### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机网盘) #### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机/永硕E盘等)
/v2/getFileList?url=分享链接&pwd=分享密码 /v2/getFileList?url=分享链接&pwd=分享密码
永硕E盘(`ys`)空间链接先返回目录列表,再带 `dirId` 获取目录内文件:
`/v2/getFileList?url=https://xxx.ysepan.com/&pwd=空间密码&dirId=目录编号`
```json ```json
{ {
"code": 200, "code": 200,
@@ -367,6 +373,7 @@ json返回数据格式示例:
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 | | 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
| 123云盘 | √ | √ | 2T | 100G>100M需要登录) | | 123云盘 | √ | √ | 2T | 100G>100M需要登录) |
| 文叔叔 | √ | √ | 10G | 5GB | | 文叔叔 | √ | √ | 10G | 5GB |
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) | | WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 | | 夸克网盘 | x | √ | 10G | 不限大小 |
| UC网盘 | x | √ | 10G | 不限大小 | | UC网盘 | x | √ | 10G | 不限大小 |
@@ -450,8 +457,8 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径 > 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
```shell ```shell
cd ~ cd ~
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v3.0.2/netdisk-fast-download-bin.zip wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.4.5/netdisk-fast-download-linux-amd64.zip
unzip netdisk-fast-download-bin.zip unzip netdisk-fast-download.zip
cd netdisk-fast-download cd netdisk-fast-download
bash service-install.sh bash service-install.sh
``` ```
@@ -539,11 +546,22 @@ Core模块集成Vert.x实现类似spring的注解式路由API
</picture> </picture>
</a> </a>
## **免责声明**
- 用户在使用本项目时,应自行承担风险,并确保其行为符合当地法律法规。开发者不对用户因使用本项目而导致的任何后果负责。
## 支持该项目 ## **声明**
开源不易,用爱发电,本项目长期维护如果觉得有帮助, 可以请作者喝杯咖啡, 感谢支持
- 本项目**仅供个人学习与技术交流使用**,请勿用于商业用途或大规模滥用。
- 所用接口均来自各网盘**官方开放平台**、官方公开接口及**已知开源项目**,仅解析用户主动提供的分享链接,**不涉及破解或绕过版权保护**。
- 本项目**相当于自动化程序代替用户发起请求**,**不会提升或绕过任何会员权限**,也**不会突破网盘的限速策略**;我们**鼓励用户开通官方 VIP/SVIP**以获得更好的下载与加速体验。
- 使用者应遵守各网盘服务商的**用户协议与使用条款**,以及所在地区的**法律法规**,违反所产生的后果自行承担。
- **高频调用可能触发网盘风控**,导致**账号限流、封禁**或**来源 IP 被屏蔽**,请合理控制请求频率,避免高并发与批量请求。
- 网盘接口调整、风控策略变更等因素可能导致功能失效,本项目**不对可用性与稳定性作任何担保**。
- 开发者**不对**使用本项目产生的**任何直接或间接损失**(账号封禁、数据丢失、法律纠纷等)**承担责任**。
- 开发者保留**随时修改本免责声明**的权利,恕不另行通知。
- **下载、部署或使用本项目,即视为您已阅读并接受上述全部条款。**
## 赞助该项目
开源不易,用爱发电,本项目长期维护如果觉得有帮助, 可以请开发者[喝杯咖啡](https://blog.qaiu.top/archives/da-shang-zhuan-yong), 感谢支持。
本项目的服务器由林枫云提供赞助<br> 本项目的服务器由林枫云提供赞助<br>
</a> </a>
+1 -1
View File
@@ -65,7 +65,7 @@
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<version>42.7.12</version> <version>42.7.13</version>
</dependency> </dependency>
</dependencies> </dependencies>
@@ -162,13 +162,18 @@ public class CommonUtil {
try (var is = CommonUtil.class.getClassLoader().getResourceAsStream("app.properties")) { try (var is = CommonUtil.class.getClassLoader().getResourceAsStream("app.properties")) {
if (is != null) { if (is != null) {
properties.load(is); properties.load(is);
if (!properties.isEmpty()) { String version = properties.getProperty("app.version");
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build"); String build = properties.getProperty("build");
if (version != null && !version.contains("${")) {
appVersion = version + "build" + (build == null || build.contains("${") ? "" : build);
} }
} }
} catch (IOException e) { } catch (Exception e) {
LOGGER.error("读取app.properties失败", e); LOGGER.error("读取app.properties失败", e);
} }
if (appVersion == null) {
appVersion = "unknown";
}
} }
return appVersion; return appVersion;
} }
+1 -1
View File
@@ -63,7 +63,7 @@
<lombok.version>1.18.38</lombok.version> <lombok.version>1.18.38</lombok.version>
<slf4j.version>2.0.16</slf4j.version> <slf4j.version>2.0.16</slf4j.version>
<commons-lang3.version>3.18.0</commons-lang3.version> <commons-lang3.version>3.18.0</commons-lang3.version>
<jackson.version>2.18.6</jackson.version> <jackson.version>2.18.9</jackson.version>
<logback.version>1.5.32</logback.version> <logback.version>1.5.32</logback.version>
<junit.version>4.13.2</junit.version> <junit.version>4.13.2</junit.version>
</properties> </properties>
@@ -368,9 +368,12 @@ public abstract class PanBase implements IPanTool, Closeable {
// 检查响应头中的Content-Encoding是否为gzip // 检查响应头中的Content-Encoding是否为gzip
String contentEncoding = res.getHeader("Content-Encoding"); String contentEncoding = res.getHeader("Content-Encoding");
try { try {
if ("gzip".equalsIgnoreCase(contentEncoding)) { if ("gzip".equalsIgnoreCase(contentEncoding) && res.body() instanceof Buffer gzipBody
&& gzipBody.length() >= 2
&& (gzipBody.getByte(0) & 0xff) == 0x1f
&& (gzipBody.getByte(1) & 0xff) == 0x8b) {
// 如果是gzip压缩的响应体,解压(只解压一次,缓存结果) // 如果是gzip压缩的响应体,解压(只解压一次,缓存结果)
String decompressed = decompressGzip((Buffer) res.body()); String decompressed = decompressGzip(gzipBody);
return new JsonObject(decompressed); return new JsonObject(decompressed);
} else { } else {
return res.bodyAsJsonObject(); return res.bodyAsJsonObject();
@@ -382,9 +385,10 @@ public abstract class PanBase implements IPanTool, Closeable {
log.error("响应gzip解压或JSON解析失败: {}", e.getMessage()); log.error("响应gzip解压或JSON解析失败: {}", e.getMessage());
fail("响应gzip解压或JSON解析失败: {}", e.getMessage()); fail("响应gzip解压或JSON解析失败: {}", e.getMessage());
} else { } else {
// 上游响应体可能来自内网探测目标,仅写日志,避免经 HTTP 500 回传给调用方
String bodyPreview = responseBodyPreview(res); String bodyPreview = responseBodyPreview(res);
log.error("解析失败: json格式异常: {}", bodyPreview); log.error("解析失败: json格式异常: {}", bodyPreview);
fail("解析失败: json格式异常: {}", bodyPreview); fail("解析失败: json格式异常");
} }
return JsonObject.of(); return JsonObject.of();
} }
@@ -414,7 +418,18 @@ public abstract class PanBase implements IPanTool, Closeable {
protected void completeWithMeta(String url, Map<String, String> headers) { protected void completeWithMeta(String url, Map<String, String> headers) {
shareLinkInfo.getOtherParam().put("downloadUrl", url); shareLinkInfo.getOtherParam().put("downloadUrl", url);
if (headers != null && !headers.isEmpty()) { if (headers != null && !headers.isEmpty()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", headers); // 过滤 null/空值,避免 cookie:null 覆盖入口参数或污染 curl 命令
Map<String, String> clean = new HashMap<>();
headers.forEach((k, v) -> {
if (k != null && v != null && !v.isBlank()) {
clean.put(k, v);
}
});
if (!clean.isEmpty()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", clean);
// UC/夸克等需带 cookie 的直链,标记前端走下载器
shareLinkInfo.getOtherParam().put("needDownloader", true);
}
} }
promise.complete(url); promise.complete(url);
} }
@@ -522,6 +537,34 @@ public abstract class PanBase implements IPanTool, Closeable {
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString(); return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
} }
/**
* 将入口请求中的加密 auth 透传到子目录/下载链接,避免进入子目录后丢失认证。
* otherParam 中的 key 为 {@code _authQuery}(由 web 层写入)。
*/
protected String appendAuthQuery(String url) {
if (StringUtils.isBlank(url) || shareLinkInfo == null || shareLinkInfo.getOtherParam() == null) {
return url;
}
Object authObj = shareLinkInfo.getOtherParam().get("_authQuery");
if (authObj == null) {
return url;
}
String auth = authObj.toString();
if (StringUtils.isBlank(auth)) {
return url;
}
// 已带 auth 则不再追加
if (url.contains("auth=")) {
return url;
}
try {
String encoded = java.net.URLEncoder.encode(auth, StandardCharsets.UTF_8);
return url + (url.contains("?") ? "&" : "?") + "auth=" + encoded;
} catch (Exception e) {
return url + (url.contains("?") ? "&" : "?") + "auth=" + auth;
}
}
@Override @Override
public ShareLinkInfo getShareLinkInfo() { public ShareLinkInfo getShareLinkInfo() {
return shareLinkInfo; return shareLinkInfo;
@@ -121,9 +121,9 @@ public enum PanDomainTemplate {
"https://lecloud.lenovo.com/share/{shareKey}", "https://lecloud.lenovo.com/share/{shareKey}",
LeTool.class), LeTool.class),
// https://v2.fangcloud.com/s/ // https://v2.fangcloud.com/s/ https://v2.fangcloud.cn/h5/share/ (移动端H5落地页)
FC("亿方云", FC("亿方云",
compile("https://v2\\.fangcloud\\.(com|cn)/(s|share|sharing)/(?<KEY>.+)"), compile("https://v2\\.fangcloud\\.(com|cn)/(?:h5/)?(s|share|sharing)/(?<KEY>.+)"),
"https://v2.fangcloud.com/s/{shareKey}", "https://v2.fangcloud.com/s/{shareKey}",
"https://www.fangcloud.com/", "https://www.fangcloud.com/",
FcTool.class), FcTool.class),
@@ -377,6 +377,16 @@ public enum PanDomainTemplate {
MmgTool.class), MmgTool.class),
// =====================私有盘解析========================== // =====================私有盘解析==========================
// 永硕E盘空间分享:https://qaiu.ysepan.com/ (空间名即 shareKey,密码为空间访问密码)
// 主域名 ysepan.com / ys168.com;备用 cccpan.com / ysupan.com / uupan.net / ysok.net
YS("永硕E盘",
compile("https?://(?!(?:www|zy|ht|api|c\\d+|ys-[a-zA-Z0-9]+)\\.)(?<KEY>[a-zA-Z\\d-]+)\\."
+ "(?:ysepan\\.com|ys168\\.com|cccpan\\.com|ysupan\\.com|uupan\\.net|ysok\\.net)"
+ "/?(?:\\?.*)?"),
"https://{shareKey}.ysepan.com/",
"https://www.ysepan.com/",
YsTool.class),
// Cloudreve自定义域名解析, 解析器CeTool兜底策略, 即任意域名如果匹配不到对应的规则, 则由CeTool统一处理, // Cloudreve自定义域名解析, 解析器CeTool兜底策略, 即任意域名如果匹配不到对应的规则, 则由CeTool统一处理,
// 如果不属于Cloudreve盘 则调用下一个自定义域名解析器, 若都处理不了则抛出异常, 这种匹配模式类似责任链 // 如果不属于Cloudreve盘 则调用下一个自定义域名解析器, 若都处理不了则抛出异常, 这种匹配模式类似责任链
// http(s)://pan.huang1111.cn/s/xxx // http(s)://pan.huang1111.cn/s/xxx
@@ -1,53 +1,149 @@
package cn.qaiu.parser; package cn.qaiu.parser;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* Parser token cache keyed by parser type and account identity. * 解析器 Token/Cookie 缓存 — 支持多账号隔离。
* <p>
* 以 (diskType + "#" + accountKey) 作为缓存 key,不同账号的 token 互不覆盖。
* accountKey 优先使用 _configId,其次使用 username、cookie 前16位等可区分标识。
* </p>
*/ */
public final class TokenCache { public final class TokenCache {
private static final Map<String, String> TOKENS = new ConcurrentHashMap<>(); private TokenCache() {}
private static final Map<String, Long> EXPIRES = new ConcurrentHashMap<>();
private TokenCache() { /** token 缓存 */
private static final ConcurrentHashMap<String, String> tokenMap = new ConcurrentHashMap<>();
/** 过期时间缓存(毫秒时间戳) */
private static final ConcurrentHashMap<String, Long> expireMap = new ConcurrentHashMap<>();
/** 同一 key 下的额外字符串缓存(如 userId) */
private static final ConcurrentHashMap<String, String> extraMap = new ConcurrentHashMap<>();
/** 布尔标记缓存(如 authFlag */
private static final ConcurrentHashMap<String, Boolean> flagMap = new ConcurrentHashMap<>();
// ============ key 构造 ============
public static String key(String diskType, String accountKey) {
return diskType + "#" + (accountKey == null ? "_default" : accountKey);
} }
public static String key(String type, String accountId) { // ============ token ============
return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId);
public static String getToken(String cacheKey) {
return tokenMap.get(cacheKey);
} }
public static void putToken(String key, String token) { public static void putToken(String cacheKey, String token) {
if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) { if (token == null) {
return; tokenMap.remove(cacheKey);
} else {
tokenMap.put(cacheKey, token);
} }
TOKENS.put(key, token);
} }
public static String getToken(String key) { // ============ expire ============
if (StringUtils.isBlank(key)) {
return null; public static long getExpire(String cacheKey) {
} return expireMap.getOrDefault(cacheKey, 0L);
if (isExpired(key)) {
TOKENS.remove(key);
EXPIRES.remove(key);
return null;
}
return TOKENS.get(key);
} }
public static void putExpire(String key, long expireTimeMillis) { public static void putExpire(String cacheKey, long expireMs) {
if (StringUtils.isBlank(key)) { expireMap.put(cacheKey, expireMs);
return;
}
EXPIRES.put(key, expireTimeMillis);
} }
public static boolean isExpired(String key) { public static boolean isExpired(String cacheKey) {
Long expireTimeMillis = EXPIRES.get(key); long exp = getExpire(cacheKey);
return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis; return exp <= 0 || System.currentTimeMillis() > exp;
}
// ============ extra (userId 等) ============
public static String getExtra(String cacheKey) {
return extraMap.get(cacheKey);
}
public static void putExtra(String cacheKey, String value) {
if (value == null) {
extraMap.remove(cacheKey);
} else {
extraMap.put(cacheKey, value);
}
}
// ============ flag (authFlag 等) ============
public static boolean getFlag(String cacheKey, boolean defaultValue) {
return flagMap.getOrDefault(cacheKey, defaultValue);
}
public static void putFlag(String cacheKey, boolean value) {
flagMap.put(cacheKey, value);
}
// ============ 清除 ============
public static void remove(String cacheKey) {
tokenMap.remove(cacheKey);
expireMap.remove(cacheKey);
extraMap.remove(cacheKey);
flagMap.remove(cacheKey);
}
/**
* 清除指定网盘类型的所有缓存(精准清除,不影响其他网盘类型)
*/
public static void removeByDiskType(String diskType) {
String prefix = diskType + "#";
tokenMap.keySet().removeIf(k -> k.startsWith(prefix));
expireMap.keySet().removeIf(k -> k.startsWith(prefix));
extraMap.keySet().removeIf(k -> k.startsWith(prefix));
flagMap.keySet().removeIf(k -> k.startsWith(prefix));
}
public static void clear() {
tokenMap.clear();
expireMap.clear();
extraMap.clear();
flagMap.clear();
}
// ============ Token 持久化队列 ============
/** 待持久化的 cachedToken 数据 (cacheKey -> [token, expireMs]) */
private static final ConcurrentHashMap<String, String[]> persistQueue = new ConcurrentHashMap<>();
/** 待回写的凭据更新 (cacheKey -> newCredential),如 PaliTool refresh_token 轮换 */
private static final ConcurrentHashMap<String, String> credentialUpdateQueue = new ConcurrentHashMap<>();
/**
* 解析器登录成功后,将 token 加入持久化队列(下次 recordConfigUsage 回写 DB
*/
public static void queueCachedTokenPersist(String cacheKey, String token, long expireMs) {
if (cacheKey != null && token != null) {
persistQueue.put(cacheKey, new String[]{token, String.valueOf(expireMs)});
}
}
/**
* 凭据本身被替换(如 PaliTool refresh_token 轮换),加入回写队列
*/
public static void queueCredentialUpdate(String cacheKey, String newCredential) {
if (cacheKey != null && newCredential != null) {
credentialUpdateQueue.put(cacheKey, newCredential);
}
}
/**
* 消费持久化队列:返回 [token, expireMs] 并移除;无数据返回 null
*/
public static String[] pollCachedTokenPersist(String cacheKey) {
return cacheKey == null ? null : persistQueue.remove(cacheKey);
}
/**
* 消费凭据更新队列:返回新凭据并移除;无数据返回 null
*/
public static String pollCredentialUpdate(String cacheKey) {
return cacheKey == null ? null : credentialUpdateQueue.remove(cacheKey);
} }
} }
@@ -111,7 +111,8 @@ public class Ce4Tool extends PanBase {
private void requestShareDetail(String baseUrl, String key, String pwd, String path) { private void requestShareDetail(String baseUrl, String key, String pwd, String path) {
String shareApiUrl = baseUrl + SHARE_API_PATH + key; String shareApiUrl = baseUrl + SHARE_API_PATH + key;
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl); // 禁止跟随重定向:防止公网 host 302 到内网/元数据绕过 assertPublicHost
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
if (pwd != null && !pwd.isEmpty()) { if (pwd != null && !pwd.isEmpty()) {
httpRequest.addQueryParam("password", pwd); httpRequest.addQueryParam("password", pwd);
} }
@@ -232,7 +233,7 @@ public class Ce4Tool extends PanBase {
.put("uris", new JsonArray().add(filePath)) .put("uris", new JsonArray().add(filePath))
.put("download", true); .put("download", true);
clientSession.postAbs(fileUrlApi) clientNoRedirects.postAbs(fileUrlApi)
.putHeader("Content-Type", "application/json") .putHeader("Content-Type", "application/json")
.sendJsonObject(requestBody) .sendJsonObject(requestBody)
.onSuccess(res -> { .onSuccess(res -> {
@@ -78,7 +78,8 @@ public class CeTool extends PanBase {
private void tryV4Ping(String baseUrl, String key, String pwd) { private void tryV4Ping(String baseUrl, String key, String pwd) {
String pingUrlV4 = baseUrl + PING_API_V4_PATH; String pingUrlV4 = baseUrl + PING_API_V4_PATH;
clientSession.getAbs(pingUrlV4).send().onSuccess(res -> { // 禁止跟随重定向:assertPublicHost 只校验初始 host,自动 30x 会绕过 SSRF 防护
clientNoRedirects.getAbs(pingUrlV4).send().onSuccess(res -> {
if (res.statusCode() == 200) { if (res.statusCode() == 200) {
try { try {
JsonObject json = asJson(res); JsonObject json = asJson(res);
@@ -108,7 +109,7 @@ public class CeTool extends PanBase {
private void tryV3Ping(String baseUrl, String key, String pwd) { private void tryV3Ping(String baseUrl, String key, String pwd) {
String pingUrlV3 = baseUrl + PING_API_V3_PATH; String pingUrlV3 = baseUrl + PING_API_V3_PATH;
clientSession.getAbs(pingUrlV3).send().onSuccess(res -> { clientNoRedirects.getAbs(pingUrlV3).send().onSuccess(res -> {
if (res.statusCode() == 200) { if (res.statusCode() == 200) {
try { try {
JsonObject json = asJson(res); JsonObject json = asJson(res);
@@ -139,7 +140,7 @@ public class CeTool extends PanBase {
*/ */
private void verifyV3AndParse(String baseUrl, String key, String pwd) { private void verifyV3AndParse(String baseUrl, String key, String pwd) {
String shareApiUrl = baseUrl + SHARE_API_PATH + key; String shareApiUrl = baseUrl + SHARE_API_PATH + key;
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl); HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
if (pwd != null && !pwd.isEmpty()) { if (pwd != null && !pwd.isEmpty()) {
httpRequest.addQueryParam("password", pwd); httpRequest.addQueryParam("password", pwd);
} }
@@ -175,7 +176,7 @@ public class CeTool extends PanBase {
*/ */
private void tryV4ShareApi(String baseUrl, String key, String pwd) { private void tryV4ShareApi(String baseUrl, String key, String pwd) {
String shareApiUrl = baseUrl + "/api/v4/share/info/" + key; String shareApiUrl = baseUrl + "/api/v4/share/info/" + key;
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl); HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
if (pwd != null && !pwd.isEmpty()) { if (pwd != null && !pwd.isEmpty()) {
httpRequest.addQueryParam("password", pwd); httpRequest.addQueryParam("password", pwd);
} }
@@ -291,7 +292,8 @@ public class CeTool extends PanBase {
} }
private void getDownURL(String shareApiUrl) { private void getDownURL(String shareApiUrl) {
clientSession.putAbs(shareApiUrl) // PUT 默认不跟随重定向,但仍统一使用 no-redirect 客户端避免配置漂移
clientNoRedirects.putAbs(shareApiUrl)
.putHeader("Referer", shareLinkInfo.getShareUrl()) .putHeader("Referer", shareLinkInfo.getShareUrl())
.send().onSuccess(res -> { .send().onSuccess(res -> {
JsonObject jsonObject = asJson(res); JsonObject jsonObject = asJson(res);
@@ -22,6 +22,7 @@ public class FcTool extends PanBase {
public static final String SHARE_URL_PREFIX = "https://v2.fangcloud.com/sharing/"; public static final String SHARE_URL_PREFIX = "https://v2.fangcloud.com/sharing/";
public static final String SHARE_URL_PREFIX2 = "https://v2.fangcloud.cn/sharing/"; public static final String SHARE_URL_PREFIX2 = "https://v2.fangcloud.cn/sharing/";
private static final String SHARE_INFO_URL = "https://v2.fangcloud.cn/apps/share_links/info/";
private static final String DOWN_REQUEST_URL = "https://v2.fangcloud.cn/apps/files/download?file_id={fid}" + private static final String DOWN_REQUEST_URL = "https://v2.fangcloud.cn/apps/files/download?file_id={fid}" +
"&scenario=share&unique_name={uname}"; "&scenario=share&unique_name={uname}";
@@ -38,6 +39,25 @@ public class FcTool extends PanBase {
final String dataKey = shareLinkInfo.getShareKey(); final String dataKey = shareLinkInfo.getShareKey();
final String pwd = shareLinkInfo.getSharePassword(); final String pwd = shareLinkInfo.getSharePassword();
WebClientSession sClient = WebClientSession.create(client); WebClientSession sClient = WebClientSession.create(client);
// 先查询分享有效性, 避免分享已失效/已过期时仍去解析HTML, 报出令人困惑的技术错误
sClient.getAbs(SHARE_INFO_URL + dataKey).send().onSuccess(infoRes -> {
JsonObject infoJson = asJson(infoRes);
if (promise.future().isComplete()) {
return;
}
JsonObject process = infoJson.getJsonObject("process");
boolean isClosed = process != null && Boolean.TRUE.equals(process.getBoolean("is_closed"));
boolean isExpired = process != null && Boolean.TRUE.equals(process.getBoolean("is_expired"));
if (process == null || isClosed || isExpired) {
fail("分享已失效或不存在");
return;
}
doParse(dataKey, pwd, sClient);
}).onFailure(handleFail(SHARE_INFO_URL + dataKey));
return promise.future();
}
private void doParse(String dataKey, String pwd, WebClientSession sClient) {
// 第一次请求 自动重定向 // 第一次请求 自动重定向
sClient.getAbs(SHARE_URL_PREFIX + dataKey).send().onSuccess(res -> { sClient.getAbs(SHARE_URL_PREFIX + dataKey).send().onSuccess(res -> {
@@ -67,7 +87,6 @@ public class FcTool extends PanBase {
} }
getDownURL(dataKey, promise, res, sClient); getDownURL(dataKey, promise, res, sClient);
}).onFailure(handleFail(SHARE_URL_PREFIX + dataKey)); }).onFailure(handleFail(SHARE_URL_PREFIX + dataKey));
return promise.future();
} }
private void getDownURL(String dataKey, Promise<String> promise, HttpResponse<Buffer> res, private void getDownURL(String dataKey, Promise<String> promise, HttpResponse<Buffer> res,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -265,13 +265,8 @@ public class UcTool extends PanBase {
return; return;
} }
String downloadUrl = dataList.getJsonObject(0).getString("download_url"); String downloadUrl = dataList.getJsonObject(0).getString("download_url");
// UC网盘需要配合aria2下载,保存下载请求头 // UC 需配合下载器(带 cookie,保存下载请求头
Map<String, String> downloadHeaders = new HashMap<>(); completeWithMeta(downloadUrl, buildDownloadHeaders(null));
// 将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) { } catch (Exception e) {
fail("解析 UC 下载链接失败: " + e.getMessage()); fail("解析 UC 下载链接失败: " + e.getMessage());
} }
@@ -466,29 +461,40 @@ public class UcTool extends PanBase {
if (shareFidToken != null) { if (shareFidToken != null) {
extParams.put("share_fid_token", shareFidToken); extParams.put("share_fid_token", shareFidToken);
} }
extParams.put("needDownloader", true);
Map<String, String> dlHeaders = new HashMap<>();
String listCookie = header.get(HttpHeaders.COOKIE);
if (listCookie != null && !listCookie.isEmpty()) {
dlHeaders.put(HttpHeaders.COOKIE.toString(), listCookie);
}
dlHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
dlHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
extParams.put("downloadHeaders", dlHeaders);
fileInfo.setExtParameters(extParams); fileInfo.setExtParameters(extParams);
// 设置解析URL(用于下载) // 设置解析URL(用于下载)
JsonObject paramJson = new JsonObject(extParams); JsonObject paramJson = new JsonObject(extParams);
paramJson.put("fileName", fileName);
String param = CommonUtils.urlBase64Encode(paramJson.encode()); String param = CommonUtils.urlBase64Encode(paramJson.encode());
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", // 透传 auth,避免下载/转存时变成 guest
getDomainName(), shareLinkInfo.getType(), param)); fileInfo.setParserUrl(appendAuthQuery(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), param)));
} else { } else {
// 文件夹 // 文件夹
fileInfo.setFileType("folder"); fileInfo.setFileType("folder");
fileInfo.setSize(0L); fileInfo.setSize(0L);
fileInfo.setSizeStr("0B"); fileInfo.setSizeStr("0B");
// 设置目录解析URL(用于递归解析子目录) // 递归子目录须透传 auth,否则会丢失认证
// 对 URL 参数进行编码,确保特殊字符正确传递
try { try {
String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString()); String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString());
String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString()); String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString());
String encodedStoken = URLEncoder.encode(stoken, 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", fileInfo.setParserUrl(appendAuthQuery(String.format(
getDomainName(), encodedUrl, encodedDirId, encodedStoken)); "%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), encodedUrl, encodedDirId, encodedStoken)));
} catch (Exception e) { } catch (Exception e) {
// 如果编码失败,使用原始值 fileInfo.setParserUrl(appendAuthQuery(String.format(
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s", "%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken)); getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken)));
} }
} }
@@ -510,6 +516,9 @@ public class UcTool extends PanBase {
promise.fail("缺少必要的参数"); promise.fail("缺少必要的参数");
return promise.future(); return promise.future();
} }
// 会话无 cookie 时,回退使用入口参数中已带的 cookie
ensureCookieFromParam(paramJson);
String fid = paramJson.getString("fid"); String fid = paramJson.getString("fid");
String pwdId = paramJson.getString("pwd_id"); String pwdId = paramJson.getString("pwd_id");
@@ -554,6 +563,11 @@ public class UcTool extends PanBase {
promise.fail("未找到下载链接"); promise.fail("未找到下载链接");
return; return;
} }
// 存储下载请求头,供目录解析 getFileDownInfo 接口使用
// 优先用当前会话 cookie;缺失时回退入口参数中已带的 cookie
Map<String, String> downloadHeaders = buildDownloadHeaders(paramJson);
shareLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
shareLinkInfo.getOtherParam().put("fileName", paramJson.getString("fileName", ""));
promise.complete(downloadUrl); promise.complete(downloadUrl);
} catch (Exception e) { } catch (Exception e) {
promise.fail("解析 UC 下载链接失败: " + e.getMessage()); promise.fail("解析 UC 下载链接失败: " + e.getMessage());
@@ -564,6 +578,53 @@ public class UcTool extends PanBase {
return promise.future(); return promise.future();
} }
/**
* 会话无 cookie 时,从入口参数 downloadHeaders 回填到请求头。
*/
private void ensureCookieFromParam(JsonObject paramJson) {
String cookie = header.get(HttpHeaders.COOKIE);
if (cookie != null && !cookie.isEmpty()) {
return;
}
String paramCookie = extractCookieFromParam(paramJson);
if (paramCookie != null && !paramCookie.isEmpty()) {
header.set(HttpHeaders.COOKIE, CookieUtils.filterUcQuarkCookie(paramCookie));
}
}
private static String extractCookieFromParam(JsonObject paramJson) {
if (paramJson == null) {
return null;
}
JsonObject paramHeaders = paramJson.getJsonObject("downloadHeaders");
if (paramHeaders == null) {
return null;
}
String cookie = paramHeaders.getString("cookie");
return cookie != null ? cookie : paramHeaders.getString("Cookie");
}
/**
* 构建下载请求头:会话 cookie 优先,缺失时回退入口参数中的 downloadHeaders。
*/
private Map<String, String> buildDownloadHeaders(JsonObject paramJson) {
Map<String, String> downloadHeaders = new HashMap<>();
String cookie = header.get(HttpHeaders.COOKIE);
if (cookie == null || cookie.isEmpty()) {
cookie = extractCookieFromParam(paramJson);
}
if (cookie != null && !cookie.isEmpty()) {
downloadHeaders.put(HttpHeaders.COOKIE.toString(), cookie);
}
String ua = header.get(HttpHeaders.USER_AGENT);
if (ua == null || ua.isEmpty()) {
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
}
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), ua);
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
return downloadHeaders;
}
// public static void main(String[] args) { // public static void main(String[] args) {
// // https://drive.uc.cn/s/12450d1694844?public=1 // // https://drive.uc.cn/s/12450d1694844?public=1
// new UcTool(ShareLinkInfo.newBuilder().shareKey("12450d1694844").build()).parse().onSuccess( // new UcTool(ShareLinkInfo.newBuilder().shareKey("12450d1694844").build()).parse().onSuccess(
@@ -0,0 +1,694 @@
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.FileSizeConverter;
import io.vertx.core.Future;
import io.vertx.core.Promise;
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 org.apache.commons.lang3.StringUtils;
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;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 永硕E盘(主 ysepan.com / ys168.com,备 cccpan.com / ysupan.com / uupan.net / ysok.net
* <p>
* 空间分享形如 https://{space}.ysepan.com/ ,需空间访问密码时通过 sharePassword 传入。
*/
public class YsTool extends PanBase {
private static final String BROWSER_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
private static final Pattern ANTIFORGERY_PATTERN =
Pattern.compile("RequestVerificationToken'\\s*:\\s*'([^']+)'");
private static final Pattern HTXX_PATTERN =
Pattern.compile("window\\.htxx\\s*=\\s*(\\{.*?});", Pattern.DOTALL);
private static final Pattern JWT_COOKIE_PATTERN =
Pattern.compile("jwttk_[^=]+=([^;\\s]+)");
private static final String PARAM_DIR_ID = "dirId";
/** 永硕目录内的子目录名(API 字段 zml),用于二级层级 */
private static final String PARAM_ZML = "zml";
public YsTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@Override
public Future<String> parse() {
ensureSession().onSuccess(session -> {
Object dirIdObj = shareLinkInfo.getOtherParam().get(PARAM_DIR_ID);
if (dirIdObj != null && StringUtils.isNotBlank(dirIdObj.toString())) {
int dirId = Integer.parseInt(dirIdObj.toString());
fetchFiles(session, dirId).onSuccess(filesResp -> {
List<JsonObject> files = downloadableFiles(filesResp);
if (files.isEmpty()) {
fail("目录内没有可下载文件");
return;
}
completeDownload(session, filesResp, files.get(0));
}).onFailure(err -> fail(err, err.getMessage()));
return;
}
fetchDirectories(session).compose(dirs -> collectDownloadableFiles(session, dirs))
.onSuccess(collected -> {
if (collected.isEmpty()) {
fail("空间内没有可下载文件,请检查密码或目录权限");
return;
}
if (collected.size() > 1) {
fail("空间包含多个文件(共{}个),请使用文件列表接口后再按文件解析", collected.size());
return;
}
CollectedFile only = collected.get(0);
completeDownload(session, only.filesResp, only.file);
}).onFailure(err -> fail(err, err.getMessage()));
}).onFailure(err -> fail(err, err.getMessage()));
return promise.future();
}
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> listPromise = Promise.promise();
ensureSession().onSuccess(session -> {
Object dirIdObj = shareLinkInfo.getOtherParam().get(PARAM_DIR_ID);
if (dirIdObj != null && StringUtils.isNotBlank(dirIdObj.toString())) {
int dirId = Integer.parseInt(dirIdObj.toString());
String zmlFilter = currentZmlFilter();
fetchFiles(session, dirId).onSuccess(filesResp -> {
try {
listPromise.complete(mapFiles(session, dirId, filesResp, zmlFilter));
} catch (Exception e) {
listPromise.fail(baseMsg() + " - 解析文件列表失败: " + e.getMessage());
}
}).onFailure(listPromise::fail);
return;
}
fetchDirectories(session).onSuccess(dirs -> {
try {
listPromise.complete(mapDirectories(session, dirs));
} catch (Exception e) {
listPromise.fail(baseMsg() + " - 解析目录列表失败: " + e.getMessage());
}
}).onFailure(listPromise::fail);
}).onFailure(listPromise::fail);
return listPromise.future();
}
@Override
public Future<String> parseById() {
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
if (paramJson == null) {
fail("缺少 paramJson 参数");
return promise.future();
}
String downloadUrl = paramJson.getString("downloadUrl");
if (StringUtils.isNotBlank(downloadUrl)) {
completeWithMeta(downloadUrl, downloadHeaders(paramJson.getString("referer")));
return promise.future();
}
String space = paramJson.getString("space", spaceName());
String xzpz = paramJson.getString("xzpz");
String pz = paramJson.getString("pz");
String fwq = paramJson.getString("fwq");
String fileName = paramJson.getString("fileName");
if (StringUtils.isAnyBlank(space, xzpz, pz, fwq, fileName)) {
fail("下载参数不完整: {}", paramJson);
return promise.future();
}
String url = buildDownloadUrl(space, xzpz, pz, fwq, fileName);
completeWithMeta(url, downloadHeaders(paramJson.getString("referer", spaceOrigin())));
return promise.future();
}
private Future<Session> ensureSession() {
Promise<Session> p = Promise.promise();
String space = spaceName();
if (StringUtils.isBlank(space)) {
p.fail(baseMsg() + " - 空间名为空");
return p.future();
}
String origin = spaceOrigin();
clientSession.getAbs(origin + "/")
.putHeader("User-Agent", BROWSER_UA)
.putHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.send()
.compose(res -> handleSpaceHome(origin, space, res))
.onSuccess(p::complete)
.onFailure(p::fail);
return p.future();
}
private Future<Session> handleSpaceHome(String origin, String space, HttpResponse<Buffer> res) {
String html = res.bodyAsString() != null ? res.bodyAsString() : "";
String jwt = extractJwt(res);
JsonObject htxx = extractHtxx(html);
// 已进入空间
if (htxx != null && StringUtils.isNotBlank(htxx.getString("qqdz"))) {
if (StringUtils.isBlank(jwt)) {
return Future.failedFuture(baseMsg() + " - 无法获取会话令牌");
}
return Future.succeededFuture(toSession(origin, space, jwt, htxx));
}
// 需要空间密码
if (html.contains("VerifyPassword") || html.contains("loginPopup")) {
String pwd = shareLinkInfo.getSharePassword();
if (StringUtils.isBlank(pwd)) {
return Future.failedFuture(baseMsg() + " - 空间需要访问密码");
}
String antiforgery = extractAntiforgery(html);
if (StringUtils.isBlank(antiforgery)) {
return Future.failedFuture(baseMsg() + " - 无法获取防伪令牌");
}
return verifyPassword(origin, space, pwd, antiforgery)
.compose(verifiedJwt -> reloadSpace(origin, space, verifiedJwt));
}
return Future.failedFuture(baseMsg() + " - 无法解析空间信息");
}
private Future<String> verifyPassword(String origin, String space, String pwd, String antiforgery) {
Promise<String> p = Promise.promise();
JsonObject body = new JsonObject()
.put("password", pwd)
.put("dlmc", space)
.put("remember", false);
clientSession.postAbs(origin + "/?handler=VerifyPassword")
.putHeader("User-Agent", BROWSER_UA)
.putHeader("Content-Type", "application/json")
.putHeader("Origin", origin)
.putHeader("Referer", origin + "/")
.putHeader("RequestVerificationToken", antiforgery)
.sendJsonObject(body)
.onSuccess(res -> {
try {
JsonObject json = res.bodyAsJsonObject();
if (json == null || !Boolean.TRUE.equals(json.getBoolean("success"))) {
String msg = json != null ? json.getString("message", "密码错误") : "密码验证失败";
p.fail(baseMsg() + " - " + msg);
return;
}
String jwt = extractJwt(res);
if (StringUtils.isBlank(jwt)) {
p.fail(baseMsg() + " - 密码验证成功但未返回会话令牌");
return;
}
p.complete(jwt);
} catch (Exception e) {
p.fail(baseMsg() + " - 密码验证响应异常: " + e.getMessage());
}
})
.onFailure(t -> p.fail(baseMsg() + " - 密码验证请求失败: " + t.getMessage()));
return p.future();
}
private Future<Session> reloadSpace(String origin, String space, String verifiedJwt) {
Promise<Session> p = Promise.promise();
clientSession.getAbs(origin + "/")
.putHeader("User-Agent", BROWSER_UA)
.putHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.send()
.onSuccess(res -> {
String html = res.bodyAsString() != null ? res.bodyAsString() : "";
JsonObject htxx = extractHtxx(html);
String jwt = StringUtils.defaultIfBlank(extractJwt(res), verifiedJwt);
if (htxx == null || StringUtils.isBlank(htxx.getString("qqdz"))) {
p.fail(baseMsg() + " - 密码验证后仍无法进入空间");
return;
}
if (StringUtils.isBlank(jwt)) {
p.fail(baseMsg() + " - 密码验证后无法获取会话令牌");
return;
}
p.complete(toSession(origin, space, jwt, htxx));
})
.onFailure(t -> p.fail(baseMsg() + " - 重新加载空间失败: " + t.getMessage()));
return p.future();
}
private Session toSession(String origin, String space, String jwt, JsonObject htxx) {
String apiBase = htxx.getString("qqdz");
if (apiBase != null && !apiBase.endsWith("/")) {
apiBase = apiBase + "/";
}
String dlmc = htxx.getString("dlmc", space);
return new Session(origin, dlmc, jwt, apiBase);
}
private Future<JsonArray> fetchDirectories(Session session) {
Promise<JsonArray> p = Promise.promise();
apiPost(session, "ml/mldq", new JsonObject())
.onSuccess(json -> {
JsonArray lb = json.getJsonArray("lb");
p.complete(lb != null ? lb : new JsonArray());
})
.onFailure(p::fail);
return p.future();
}
private Future<JsonObject> fetchFiles(Session session, int dirId) {
Promise<JsonObject> p = Promise.promise();
JsonObject body = new JsonObject()
.put("mlbh", dirId)
.put("kqmm", "")
.put("wjbh", 0)
.put("ip1", "");
apiPost(session, "wj/wjdq", body)
.onSuccess(p::complete)
.onFailure(p::fail);
return p.future();
}
private Future<JsonObject> apiPost(Session session, String path, JsonObject body) {
Promise<JsonObject> p = Promise.promise();
String url = session.apiBase + path;
HttpRequest<Buffer> req = clientSession.postAbs(url)
.putHeader("User-Agent", BROWSER_UA)
.putHeader("Accept", "application/json, text/plain, */*")
.putHeader("Content-Type", "application/json")
.putHeader("Origin", session.origin)
.putHeader("Referer", session.origin + "/")
.putHeader("Authorization", "Bearer " + session.jwt);
req.sendJsonObject(body)
.onSuccess(res -> {
try {
if (res.statusCode() >= 400) {
p.fail(baseMsg() + " - API " + path + " HTTP " + res.statusCode());
return;
}
JsonObject json = asJson(res);
if (json == null || json.isEmpty()) {
p.fail(baseMsg() + " - API " + path + " 返回空响应");
return;
}
p.complete(json);
} catch (Exception e) {
p.fail(baseMsg() + " - API " + path + " 响应异常: " + e.getMessage());
}
})
.onFailure(t -> p.fail(baseMsg() + " - API " + path + " 请求失败: " + t.getMessage()));
return p.future();
}
private Future<List<CollectedFile>> collectDownloadableFiles(Session session, JsonArray dirs) {
Promise<List<CollectedFile>> p = Promise.promise();
List<CollectedFile> collected = new ArrayList<>();
List<Integer> dirIds = new ArrayList<>();
for (int i = 0; i < dirs.size(); i++) {
JsonObject dir = dirs.getJsonObject(i);
if (isAccessibleDirectory(dir)) {
dirIds.add(dir.getInteger("bh"));
}
}
if (dirIds.isEmpty()) {
p.complete(collected);
return p.future();
}
fetchNextDirFiles(session, dirIds, 0, collected, p);
return p.future();
}
private void fetchNextDirFiles(Session session, List<Integer> dirIds, int index,
List<CollectedFile> collected, Promise<List<CollectedFile>> promise) {
if (index >= dirIds.size()) {
promise.complete(collected);
return;
}
int dirId = dirIds.get(index);
fetchFiles(session, dirId).onSuccess(filesResp -> {
for (JsonObject file : downloadableFiles(filesResp)) {
collected.add(new CollectedFile(filesResp, file));
}
fetchNextDirFiles(session, dirIds, index + 1, collected, promise);
}).onFailure(promise::fail);
}
private List<FileInfo> mapDirectories(Session session, JsonArray dirs) {
List<FileInfo> result = new ArrayList<>();
for (int i = 0; i < dirs.size(); i++) {
JsonObject dir = dirs.getJsonObject(i);
if (!isAccessibleDirectory(dir)) {
continue;
}
Integer bh = dir.getInteger("bh");
if (bh == null) {
continue;
}
String title = StringUtils.defaultIfBlank(dir.getString("bt"), "目录" + bh);
FileInfo info = new FileInfo()
.setFileName(title)
.setFileId(bh.toString())
.setFileType("folder")
.setSize(0L)
.setSizeStr("0B")
.setDescription(dir.getString("sm", ""))
.setCreateTime(normalizeTime(dir.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&pwd=%s",
getDomainName(),
urlEncode(shareLinkInfo.getShareUrl()),
bh,
urlEncode(StringUtils.defaultString(shareLinkInfo.getSharePassword()))));
result.add(info);
}
return result;
}
/**
* 将目录内文件按 zml(子目录)分层。
* <ul>
* <li>zmlFilter 为空:返回子目录(folder+ 根级文件</li>
* <li>zmlFilter 非空:仅返回该子目录下的文件/链接</li>
* </ul>
*/
private List<FileInfo> mapFiles(Session session, int dirId, JsonObject filesResp, String zmlFilter) {
List<FileInfo> result = new ArrayList<>();
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
JsonArray lb = filesResp.getJsonArray("lb", new JsonArray());
boolean listingSubdir = StringUtils.isNotBlank(zmlFilter);
// 未进入子目录时,先按出现顺序收集 zml 作为二级文件夹
if (!listingSubdir) {
java.util.LinkedHashSet<String> subdirs = new java.util.LinkedHashSet<>();
for (int i = 0; i < lb.size(); i++) {
JsonObject item = lb.getJsonObject(i);
if (item == null) {
continue;
}
String zml = StringUtils.defaultString(item.getString("zml")).trim();
if (StringUtils.isNotBlank(zml)) {
subdirs.add(zml);
}
}
for (String zml : subdirs) {
result.add(new FileInfo()
.setFileName(zml)
.setFileId(dirId + ":" + zml)
.setFileType("folder")
.setSize(0L)
.setSizeStr("0B")
.setFilePath(zml)
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&zml=%s&pwd=%s",
getDomainName(),
urlEncode(shareLinkInfo.getShareUrl()),
dirId,
urlEncode(zml),
urlEncode(StringUtils.defaultString(shareLinkInfo.getSharePassword())))));
}
}
for (int i = 0; i < lb.size(); i++) {
JsonObject item = lb.getJsonObject(i);
if (item == null) {
continue;
}
String itemZml = StringUtils.defaultString(item.getString("zml")).trim();
if (listingSubdir) {
if (!zmlFilter.equals(itemZml)) {
continue;
}
} else if (StringUtils.isNotBlank(itemZml)) {
// 根级列表只展示无 zml 的条目,有 zml 的归入子目录
continue;
}
Integer bh = item.getInteger("bh");
if (bh == null) {
continue;
}
String wjlx = item.getString("wjlx", "");
// URL / 公告条目
if ("url".equalsIgnoreCase(wjlx)) {
String link = StringUtils.defaultString(item.getString("wjm")).trim();
String title = StringUtils.defaultString(item.getString("bt")).trim();
// 空占位(标题和链接都空)跳过,与官网展示一致
if (StringUtils.isAllBlank(title, link)) {
continue;
}
if (StringUtils.isBlank(title)) {
title = StringUtils.defaultIfBlank(link, "链接");
}
FileInfo urlInfo = new FileInfo()
.setFileName(title)
.setFileId(bh.toString())
.setFileType("url")
.setSize(0L)
.setSizeStr("0B")
.setFilePath(itemZml)
.setCreateTime(normalizeTime(item.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setPreviewUrl(link)
.setDescription(link);
// parserUrl 置空,避免前端误走下载;打开走 previewUrl
result.add(urlInfo);
continue;
}
String fileName = item.getString("wjm");
String fwq = item.getString("fwq");
String pz = item.getString("pz");
if (StringUtils.isAnyBlank(fileName, fwq, pz, xzpz)) {
continue;
}
long size = item.getLong("dx", 0L);
String downloadUrl = buildDownloadUrl(session.space, xzpz, pz, fwq, fileName);
JsonObject param = new JsonObject()
.put("space", session.space)
.put("xzpz", xzpz)
.put("pz", pz)
.put("fwq", fwq)
.put("fileName", fileName)
.put("mlbh", dirId)
.put("fileId", bh)
.put("downloadUrl", downloadUrl)
.put("referer", session.origin + "/");
String paramEncoded = CommonUtils.urlBase64Encode(param.encode());
FileInfo fileInfo = new FileInfo()
.setFileName(fileName)
.setFileId(bh.toString())
.setFileType("file")
.setSize(size)
.setSizeStr(FileSizeConverter.convertToReadableSize(size))
.setFilePath(itemZml)
.setCreateTime(normalizeTime(item.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), paramEncoded))
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), paramEncoded));
result.add(fileInfo);
}
return result;
}
private String currentZmlFilter() {
Object zml = shareLinkInfo.getOtherParam().get(PARAM_ZML);
if (zml == null) {
return "";
}
try {
return java.net.URLDecoder.decode(zml.toString(), StandardCharsets.UTF_8).trim();
} catch (Exception e) {
return zml.toString().trim();
}
}
private List<JsonObject> downloadableFiles(JsonObject filesResp) {
List<JsonObject> files = new ArrayList<>();
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
if (StringUtils.isBlank(xzpz)) {
return files;
}
JsonArray lb = filesResp.getJsonArray("lb", new JsonArray());
for (int i = 0; i < lb.size(); i++) {
JsonObject item = lb.getJsonObject(i);
if (item == null) {
continue;
}
if ("url".equalsIgnoreCase(item.getString("wjlx", ""))) {
continue;
}
if (StringUtils.isAnyBlank(item.getString("wjm"), item.getString("fwq"), item.getString("pz"))) {
continue;
}
files.add(item);
}
return files;
}
private void completeDownload(Session session, JsonObject filesResp, JsonObject file) {
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz");
String url = buildDownloadUrl(session.space, xzpz, file.getString("pz"),
file.getString("fwq"), file.getString("wjm"));
FileInfo fileInfo = new FileInfo()
.setFileName(file.getString("wjm"))
.setFileId(String.valueOf(file.getInteger("bh")))
.setSize(file.getLong("dx", 0L))
.setSizeStr(FileSizeConverter.convertToReadableSize(file.getLong("dx", 0L)))
.setFileType("file")
.setFilePath(file.getString("zml", ""))
.setCreateTime(normalizeTime(file.getString("sj")))
.setPanType(shareLinkInfo.getType());
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
completeWithMeta(url, downloadHeaders(session.origin + "/"));
}
/**
* 拼装直链。注意:不要在 xzpz 前加 "_",官方页面直链无此前缀,加了会 404。
*/
static String buildDownloadUrl(String space, String xzpz, String pz, String fwq, String fileName) {
String host = "X".equalsIgnoreCase(fwq)
? "y.ys168.com:8000"
: "ys-" + fwq.toLowerCase() + ".ysepan.com";
return "https://" + host + "/wap/"
+ encodePathSegment(space) + "/"
+ encodePathSegment(xzpz) + "/"
+ encodePathSegment(pz) + "/"
+ encodePathSegment(fileName);
}
private static String encodePathSegment(String value) {
// 保留永硕 token 中常见的 . , _ - 等字符,仅编码必须转义的部分
return URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20")
.replace("%2E", ".")
.replace("%2C", ",")
.replace("%5F", "_")
.replace("%2D", "-");
}
private Map<String, String> downloadHeaders(String referer) {
Map<String, String> headers = new HashMap<>();
headers.put("User-Agent", BROWSER_UA);
if (StringUtils.isNotBlank(referer)) {
headers.put("Referer", referer);
}
return headers;
}
private boolean isAccessibleDirectory(JsonObject dir) {
if (dir == null) {
return false;
}
// 无可下载权限或需特殊打开方式的目录忽略
if (!Boolean.TRUE.equals(dir.getBoolean("qxz", true))) {
return false;
}
Integer kqfs = dir.getInteger("kqfs", 0);
return kqfs == null || kqfs == 0;
}
private String spaceName() {
String key = shareLinkInfo.getShareKey();
if (StringUtils.isBlank(key)) {
return null;
}
try {
return java.net.URLDecoder.decode(key, StandardCharsets.UTF_8);
} catch (Exception e) {
return key;
}
}
private String spaceOrigin() {
String shareUrl = shareLinkInfo.getShareUrl();
if (StringUtils.isNotBlank(shareUrl)) {
try {
java.net.URI uri = java.net.URI.create(shareUrl);
String scheme = uri.getScheme() != null ? uri.getScheme() : "https";
return scheme + "://" + uri.getHost();
} catch (Exception ignored) {
}
}
return "https://" + spaceName() + ".ysepan.com";
}
private static String extractAntiforgery(String html) {
Matcher m = ANTIFORGERY_PATTERN.matcher(html);
return m.find() ? m.group(1) : null;
}
private static JsonObject extractHtxx(String html) {
Matcher m = HTXX_PATTERN.matcher(html);
if (!m.find()) {
return null;
}
try {
return new JsonObject(m.group(1));
} catch (Exception e) {
return null;
}
}
private static String extractJwt(HttpResponse<Buffer> res) {
List<String> setCookies = res.cookies();
if (setCookies != null) {
for (String c : setCookies) {
Matcher m = JWT_COOKIE_PATTERN.matcher(c);
if (m.find()) {
return m.group(1);
}
}
}
String raw = res.getHeader("Set-Cookie");
if (raw != null) {
Matcher m = JWT_COOKIE_PATTERN.matcher(raw);
if (m.find()) {
return m.group(1);
}
}
return null;
}
private static String normalizeTime(String sj) {
if (StringUtils.isBlank(sj)) {
return sj;
}
// 2024-10-26T10:12:54.98 -> 2024-10-26 10:12:54
String t = sj.replace('T', ' ');
int dot = t.indexOf('.');
if (dot > 0) {
t = t.substring(0, dot);
}
return t;
}
private static String urlEncode(String value) {
return URLEncoder.encode(StringUtils.defaultString(value), StandardCharsets.UTF_8);
}
private record Session(String origin, String space, String jwt, String apiBase) {
}
private record CollectedFile(JsonObject filesResp, JsonObject file) {
}
}
@@ -77,32 +77,57 @@ public class CommonUtils {
} }
/** /**
* urlEncode -> deBase64 -> string * 解码路径参数中的 Base64。
* @param encoded 编码后的字符串 * <p>优先按 URL-Safe Base64 解;兼容历史「标准 Base64 + URLEncode」以及重复 encode。</p>
* @return 解码后的字符串
*/ */
public static String urlBase64Decode(String encoded) { public static String urlBase64Decode(String encoded) {
try { if (encoded == null || encoded.isEmpty()) {
String urlDecoded = java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8); throw new RuntimeException("URL Base64 解码失败: empty");
byte[] base64DecodedBytes = java.util.Base64.getDecoder().decode(urlDecoded);
return new String(base64DecodedBytes, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("URL Base64 解码失败", e);
} }
String s = encoded.trim().replace(' ', '+');
// 兼容历史 URLEncode / 误二次 encode:有 % 则解到不再变化
for (int i = 0; i < 3 && s.contains("%"); i++) {
try {
String next = java.net.URLDecoder.decode(s, StandardCharsets.UTF_8);
if (next.equals(s)) {
break;
}
s = next;
} catch (Exception e) {
break;
}
}
Exception last = null;
for (String candidate : new String[]{s, padBase64(s)}) {
try {
return new String(java.util.Base64.getUrlDecoder().decode(candidate), StandardCharsets.UTF_8);
} catch (Exception e) {
last = e;
}
try {
return new String(java.util.Base64.getDecoder().decode(candidate), StandardCharsets.UTF_8);
} catch (Exception e) {
last = e;
}
}
throw new RuntimeException("URL Base64 解码失败", last);
} }
/** /**
* string -> base64Encode -> urlEncode * 编码为可直接放进 URL path 的 Base64URL-Safe,无 padding)。
* @param str 原始字符串 * <p>不再做 URLEncoder,避免前端/代理再 encode 时变成 %253D。</p>
* @return 编码后的字符串
*/ */
public static String urlBase64Encode(String str) { public static String urlBase64Encode(String str) {
try { return java.util.Base64.getUrlEncoder()
byte[] base64EncodedBytes = java.util.Base64.getEncoder().encode(str.getBytes(java.nio.charset.StandardCharsets.UTF_8)); .withoutPadding()
String base64Encoded = new String(base64EncodedBytes, java.nio.charset.StandardCharsets.UTF_8); .encodeToString(str.getBytes(StandardCharsets.UTF_8));
return java.net.URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8); }
} catch (Exception e) {
throw new RuntimeException("URL Base64 编码失败", e); private static String padBase64(String s) {
int mod = s.length() % 4;
if (mod == 0) {
return s;
} }
return s + "====".substring(mod);
} }
} }
@@ -119,8 +119,18 @@ public class HttpResponseHelper {
}; };
} }
private static boolean looksLikeGzip(Buffer compressed) {
return compressed != null && compressed.length() >= 2
&& (compressed.getByte(0) & 0xff) == 0x1f
&& (compressed.getByte(1) & 0xff) == 0x8b;
}
// -------------------- gzip -------------------- // -------------------- gzip --------------------
private static String decompressGzip(Buffer compressed) throws IOException { private static String decompressGzip(Buffer compressed) throws IOException {
// Vert.x 可能已解压但仍带 Content-Encoding: gzip,再走 GZIPInputStream 会变成 ZipException
if (!looksLikeGzip(compressed)) {
return compressed.toString(StandardCharsets.UTF_8);
}
try (ByteArrayInputStream bais = new ByteArrayInputStream(compressed.getBytes()); try (ByteArrayInputStream bais = new ByteArrayInputStream(compressed.getBytes());
GZIPInputStream gzis = new GZIPInputStream(bais); GZIPInputStream gzis = new GZIPInputStream(bais);
InputStreamReader isr = new InputStreamReader(gzis, StandardCharsets.UTF_8); InputStreamReader isr = new InputStreamReader(gzis, StandardCharsets.UTF_8);
+362 -37
View File
@@ -101,59 +101,384 @@ public interface JsContent {
"""; """;
String lz = """ String lz = """
/** /**
* 蓝奏云解析器js签名获取工具 * 蓝奏云解析器 JS 沙箱:伪装 jQuery / document / window。
* 新版页面会用 document.cookie、location.reload、querySelector、
* $('#pwd').val()、.html()、.css() 等,这里做成可链式的最小实现。
* kdns.js 在浏览器里是 `var killdns = true`,需一并注入,否则 kd 会被改成 0。
*/ */
var signObj; var signObj;
var __lzPwd = '';
var killdns = true;
function __lzSetPwd(p) {
__lzPwd = p == null ? '' : String(p);
}
function __lzEl(id) {
var nid = String(id == null ? '' : id).replace(/^[#.]/, '');
var el = {
id: nid,
_value: nid === 'pwd' ? __lzPwd : '',
checked: false,
disabled: false,
innerHTML: '',
innerText: '',
textContent: '',
className: '',
style: { display: '', visibility: '', width: '', height: '' },
classList: {
add: function () {},
remove: function () {},
contains: function () { return false; },
toggle: function () {}
},
setAttribute: function () {},
getAttribute: function () { return null; },
removeAttribute: function () {},
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
},
removeEventListener: function () {},
appendChild: function (n) { return n; },
removeChild: function (n) { return n; },
insertBefore: function (n) { return n; },
click: function () {},
focus: function () {},
blur: function () {},
submit: function () {},
select: function () {},
reset: function () {}
};
el.parentNode = el;
el.parentElement = el;
el.children = [];
el.childNodes = [];
el.firstChild = null;
el.lastChild = null;
try {
Object.defineProperty(el, 'value', {
get: function () { return nid === 'pwd' ? __lzPwd : el._value; },
set: function (v) {
el._value = v;
if (nid === 'pwd') {
__lzPwd = v == null ? '' : String(v);
}
}
});
} catch (e) {
el.value = el._value;
}
return el;
}
function __lzJq(sel) {
var id = '';
if (typeof sel === 'string') {
id = sel.replace(/^[#.]/, '');
} else if (sel && sel.id) {
id = String(sel.id);
}
var el = (sel && sel.style && sel.addEventListener) ? sel : __lzEl(id);
var api = {
0: el,
length: 1,
selector: sel,
ready: function (fn) {
if (typeof fn === 'function') {
try { fn(jQuery); } catch (e) {}
}
return api;
},
on: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
off: function () { return api; },
bind: function (t, fn) { return api.on(t, fn); },
unbind: function () { return api; },
click: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
focus: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
blur: function () { return api; },
keyup: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
keydown: function (fn) { return api.keyup(fn); },
keypress: function (fn) { return api.keyup(fn); },
submit: function (fn) { return api.click(fn); },
change: function (fn) { return api.click(fn); },
hover: function () { return api; },
val: function (v) {
if (arguments.length === 0) {
if (typeof el.value !== 'undefined') {
return el.value;
}
return (id === 'pwd') ? __lzPwd : '';
}
el.value = v;
return api;
},
html: function (v) {
if (arguments.length === 0) {
return el.innerHTML;
}
el.innerHTML = v;
return api;
},
text: function (v) {
if (arguments.length === 0) {
return el.innerText;
}
el.innerText = v;
el.textContent = v;
return api;
},
attr: function (k, v) {
if (arguments.length < 2) {
return null;
}
return api;
},
prop: function (k, v) {
if (arguments.length < 2) {
return false;
}
return api;
},
css: function () { return api; },
addClass: function () { return api; },
removeClass: function () { return api; },
toggleClass: function () { return api; },
hasClass: function () { return false; },
show: function () {
el.style.display = '';
return api;
},
hide: function () {
el.style.display = 'none';
return api;
},
fadeIn: function () { return api; },
fadeOut: function () { return api; },
animate: function () { return api; },
find: function () { return api; },
parent: function () { return api; },
children: function () { return api; },
eq: function () { return api; },
first: function () { return api; },
last: function () { return api; },
each: function (fn) {
if (typeof fn === 'function') {
try { fn.call(el, 0, el); } catch (e) {}
}
return api;
},
append: function () { return api; },
prepend: function () { return api; },
remove: function () { return api; },
empty: function () { return api; },
ajax: function (obj) {
signObj = obj;
return api;
},
get: function () { return el; }
};
return api;
}
var $, jQuery; var $, jQuery;
$ = jQuery = function (sel) {
$ = jQuery = function () { if (typeof sel === 'function') {
return new jQuery.fn.init(); try { sel(jQuery); } catch (e) {}
} return __lzJq(document);
}
return __lzJq(sel);
};
jQuery.fn = jQuery.prototype = { jQuery.fn = jQuery.prototype = {
init: function () { init: function (sel) {
return { return __lzJq(sel);
focus: function (a) { }
};
},
keyup: function(a) {
},
ajax: function (obj) {
signObj = obj
},
val: function(a) {
},
}
},
}
jQuery.fn.init.prototype = jQuery.fn; jQuery.fn.init.prototype = jQuery.fn;
$.fn = jQuery.fn;
$.ajax = function (obj) { $.ajax = function (obj) {
signObj = obj signObj = obj;
} return {
done: function () { return this; },
fail: function () { return this; },
always: function () { return this; }
};
};
$.get = function () {};
$.post = function () {};
$.extend = function () {
var t = arguments[0] || {};
for (var i = 1; i < arguments.length; i++) {
var s = arguments[i];
if (s) {
for (var k in s) {
if (s.hasOwnProperty(k)) {
t[k] = s[k];
}
}
}
}
return t;
};
$.each = function (obj, fn) {
if (!obj || typeof fn !== 'function') {
return obj;
}
if (typeof obj.length === 'number') {
for (var i = 0; i < obj.length; i++) {
fn.call(obj[i], i, obj[i]);
}
} else {
for (var k in obj) {
if (obj.hasOwnProperty(k)) {
fn.call(obj[k], k, obj[k]);
}
}
}
return obj;
};
$.isFunction = function (f) { return typeof f === 'function'; };
$.isArray = function (a) {
return Object.prototype.toString.call(a) === '[object Array]';
};
$.trim = function (s) {
return s == null ? '' : String(s).replace(/^\\s+|\\s+$/g, '');
};
var __lzLocation = {
href: '',
search: '',
pathname: '/',
hash: '',
host: '',
hostname: '',
protocol: 'https:',
port: '',
origin: '',
assign: function () {},
replace: function () {},
reload: function () {}
};
var document = { var document = {
getElementById: function (v) { cookie: '',
return { title: '',
value: 'v', domain: '',
style: { referrer: '',
display: '' readyState: 'complete',
}, hidden: false,
addEventListener: function() {} visibilityState: 'visible',
documentElement: null,
body: null,
head: null,
location: __lzLocation,
getElementById: function (id) { return __lzEl(id); },
getElementsByClassName: function () { return []; },
getElementsByTagName: function (t) {
return t === 'script' ? [] : [__lzEl(t)];
},
getElementsByName: function () { return []; },
querySelector: function (s) { return __lzEl(s); },
querySelectorAll: function (s) { return [__lzEl(s)]; },
createElement: function (t) { return __lzEl(t); },
createTextNode: function (t) { return { nodeValue: t, data: t }; },
createDocumentFragment: function () { return __lzEl('fragment'); },
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
} }
}, },
removeEventListener: function () {},
write: function () {},
writeln: function () {},
open: function () {},
close: function () {}
};
document.documentElement = __lzEl('html');
document.body = __lzEl('body');
document.head = __lzEl('head');
var navigator = {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
platform: 'Win32',
language: 'zh-CN',
cookieEnabled: true,
onLine: true
};
var location = __lzLocation;
var console = {
log: function () {},
warn: function () {},
error: function () {},
info: function () {},
debug: function () {}
};
function setTimeout(fn, delay) {
if (typeof fn === 'function' && (!delay || delay <= 0)) {
try { fn(); } catch (e) {}
}
return 0;
} }
function setInterval() { return 0; }
var window = {location: {}} function clearTimeout() {}
function clearInterval() {}
var window = {
location: __lzLocation,
document: document,
navigator: navigator,
console: console,
innerWidth: 1920,
innerHeight: 1080,
setTimeout: setTimeout,
setInterval: setInterval,
clearTimeout: clearTimeout,
clearInterval: clearInterval,
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
},
removeEventListener: function () {},
atob: function (s) { return s; },
btoa: function (s) { return s; }
};
window.window = window;
window.top = window;
window.self = window;
window.parent = window;
window.jQuery = jQuery;
window.$ = $;
var top = window;
var self = window;
var parent = window;
"""; """;
String kwSignString = """ String kwSignString = """
@@ -50,21 +50,47 @@ public class JsExecUtils {
*/ */
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException, public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException,
NoSuchMethodException { NoSuchMethodException {
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎 return executeDynamicJs(jsText, funName, null);
}
/**
* @param pwd 分享密码,写入伪 DOM#pwd / getElementById('pwd')),供新版页面取值
*/
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName, String pwd) throws ScriptException,
NoSuchMethodException {
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript");
try { try {
engine.eval(JsContent.lz + "\n" + jsText); engine.eval(JsContent.lz);
Invocable inv = (Invocable) engine; Invocable inv = (Invocable) engine;
//调用js中的函数 if (pwd != null) {
if (StringUtils.isNotEmpty(funName)) { inv.invokeFunction("__lzSetPwd", pwd);
inv.invokeFunction(funName);
} }
return (ScriptObjectMirror) engine.get("signObj"); try {
engine.eval(jsText);
if (StringUtils.isNotEmpty(funName)) {
inv.invokeFunction(funName);
}
} catch (ScriptException | NoSuchMethodException | RuntimeException e) {
ScriptObjectMirror captured = asSignObj(engine.get("signObj"));
if (captured != null) {
return captured;
}
throw e;
}
return asSignObj(engine.get("signObj"));
} finally { } finally {
// 清理引擎持有的引用,帮助 GC 回收
clearEngineBindings(engine); clearEngineBindings(engine);
} }
} }
private static ScriptObjectMirror asSignObj(Object sign) {
if (sign instanceof ScriptObjectMirror mirror
&& (mirror.get("url") != null || mirror.get("data") != null)) {
return mirror;
}
return null;
}
/** /**
* 调用执行js文件(使用缓存的 ScriptEngineManager 创建新引擎实例) * 调用执行js文件(使用缓存的 ScriptEngineManager 创建新引擎实例)
+362 -29
View File
@@ -1,46 +1,379 @@
/** /**
* 蓝奏云解析器js签名获取工具 * 蓝奏云解析器 JS 沙箱:伪装 jQuery / document / window。
* 新版页面会用 document.cookie、location.reload、querySelector、
* $('#pwd').val()、.html()、.css() 等,这里做成可链式的最小实现。
* kdns.js 在浏览器里是 `var killdns = true`,需一并注入,否则 kd 会被改成 0。
*/ */
var signObj; var signObj;
var __lzPwd = '';
var killdns = true;
function __lzSetPwd(p) {
__lzPwd = p == null ? '' : String(p);
}
function __lzEl(id) {
var nid = String(id == null ? '' : id).replace(/^[#.]/, '');
var el = {
id: nid,
_value: nid === 'pwd' ? __lzPwd : '',
checked: false,
disabled: false,
innerHTML: '',
innerText: '',
textContent: '',
className: '',
style: { display: '', visibility: '', width: '', height: '' },
classList: {
add: function () {},
remove: function () {},
contains: function () { return false; },
toggle: function () {}
},
setAttribute: function () {},
getAttribute: function () { return null; },
removeAttribute: function () {},
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
},
removeEventListener: function () {},
appendChild: function (n) { return n; },
removeChild: function (n) { return n; },
insertBefore: function (n) { return n; },
click: function () {},
focus: function () {},
blur: function () {},
submit: function () {},
select: function () {},
reset: function () {}
};
el.parentNode = el;
el.parentElement = el;
el.children = [];
el.childNodes = [];
el.firstChild = null;
el.lastChild = null;
try {
Object.defineProperty(el, 'value', {
get: function () { return nid === 'pwd' ? __lzPwd : el._value; },
set: function (v) {
el._value = v;
if (nid === 'pwd') {
__lzPwd = v == null ? '' : String(v);
}
}
});
} catch (e) {
el.value = el._value;
}
return el;
}
function __lzJq(sel) {
var id = '';
if (typeof sel === 'string') {
id = sel.replace(/^[#.]/, '');
} else if (sel && sel.id) {
id = String(sel.id);
}
var el = (sel && sel.style && sel.addEventListener) ? sel : __lzEl(id);
var api = {
0: el,
length: 1,
selector: sel,
ready: function (fn) {
if (typeof fn === 'function') {
try { fn(jQuery); } catch (e) {}
}
return api;
},
on: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
off: function () { return api; },
bind: function (t, fn) { return api.on(t, fn); },
unbind: function () { return api; },
click: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
focus: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
blur: function () { return api; },
keyup: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
keydown: function (fn) { return api.keyup(fn); },
keypress: function (fn) { return api.keyup(fn); },
submit: function (fn) { return api.click(fn); },
change: function (fn) { return api.click(fn); },
hover: function () { return api; },
val: function (v) {
if (arguments.length === 0) {
if (typeof el.value !== 'undefined') {
return el.value;
}
return (id === 'pwd') ? __lzPwd : '';
}
el.value = v;
return api;
},
html: function (v) {
if (arguments.length === 0) {
return el.innerHTML;
}
el.innerHTML = v;
return api;
},
text: function (v) {
if (arguments.length === 0) {
return el.innerText;
}
el.innerText = v;
el.textContent = v;
return api;
},
attr: function (k, v) {
if (arguments.length < 2) {
return null;
}
return api;
},
prop: function (k, v) {
if (arguments.length < 2) {
return false;
}
return api;
},
css: function () { return api; },
addClass: function () { return api; },
removeClass: function () { return api; },
toggleClass: function () { return api; },
hasClass: function () { return false; },
show: function () {
el.style.display = '';
return api;
},
hide: function () {
el.style.display = 'none';
return api;
},
fadeIn: function () { return api; },
fadeOut: function () { return api; },
animate: function () { return api; },
find: function () { return api; },
parent: function () { return api; },
children: function () { return api; },
eq: function () { return api; },
first: function () { return api; },
last: function () { return api; },
each: function (fn) {
if (typeof fn === 'function') {
try { fn.call(el, 0, el); } catch (e) {}
}
return api;
},
append: function () { return api; },
prepend: function () { return api; },
remove: function () { return api; },
empty: function () { return api; },
ajax: function (obj) {
signObj = obj;
return api;
},
get: function () { return el; }
};
return api;
}
var $, jQuery; var $, jQuery;
$ = jQuery = function (sel) {
$ = jQuery = function () { if (typeof sel === 'function') {
return new jQuery.fn.init(); try { sel(jQuery); } catch (e) {}
} return __lzJq(document);
}
return __lzJq(sel);
};
jQuery.fn = jQuery.prototype = { jQuery.fn = jQuery.prototype = {
init: function () { init: function (sel) {
return { return __lzJq(sel);
focus: function (a) { }
};
},
keyup: function(a) {
},
ajax: function (obj) {
signObj = obj
}
}
},
}
jQuery.fn.init.prototype = jQuery.fn; jQuery.fn.init.prototype = jQuery.fn;
$.fn = jQuery.fn;
// 伪装jquery.ajax函数获取关键数据
$.ajax = function (obj) { $.ajax = function (obj) {
signObj = obj signObj = obj;
} return {
done: function () { return this; },
fail: function () { return this; },
always: function () { return this; }
};
};
$.get = function () {};
$.post = function () {};
$.extend = function () {
var t = arguments[0] || {};
for (var i = 1; i < arguments.length; i++) {
var s = arguments[i];
if (s) {
for (var k in s) {
if (s.hasOwnProperty(k)) {
t[k] = s[k];
}
}
}
}
return t;
};
$.each = function (obj, fn) {
if (!obj || typeof fn !== 'function') {
return obj;
}
if (typeof obj.length === 'number') {
for (var i = 0; i < obj.length; i++) {
fn.call(obj[i], i, obj[i]);
}
} else {
for (var k in obj) {
if (obj.hasOwnProperty(k)) {
fn.call(obj[k], k, obj[k]);
}
}
}
return obj;
};
$.isFunction = function (f) { return typeof f === 'function'; };
$.isArray = function (a) {
return Object.prototype.toString.call(a) === '[object Array]';
};
$.trim = function (s) {
return s == null ? '' : String(s).replace(/^\s+|\s+$/g, '');
};
var __lzLocation = {
href: '',
search: '',
pathname: '/',
hash: '',
host: '',
hostname: '',
protocol: 'https:',
port: '',
origin: '',
assign: function () {},
replace: function () {},
reload: function () {}
};
var document = { var document = {
getElementById: function (v) { cookie: '',
return { title: '',
value: 'v' domain: '',
referrer: '',
readyState: 'complete',
hidden: false,
visibilityState: 'visible',
documentElement: null,
body: null,
head: null,
location: __lzLocation,
getElementById: function (id) { return __lzEl(id); },
getElementsByClassName: function () { return []; },
getElementsByTagName: function (t) {
return t === 'script' ? [] : [__lzEl(t)];
},
getElementsByName: function () { return []; },
querySelector: function (s) { return __lzEl(s); },
querySelectorAll: function (s) { return [__lzEl(s)]; },
createElement: function (t) { return __lzEl(t); },
createTextNode: function (t) { return { nodeValue: t, data: t }; },
createDocumentFragment: function () { return __lzEl('fragment'); },
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
} }
}, },
removeEventListener: function () {},
write: function () {},
writeln: function () {},
open: function () {},
close: function () {}
};
document.documentElement = __lzEl('html');
document.body = __lzEl('body');
document.head = __lzEl('head');
var navigator = {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
platform: 'Win32',
language: 'zh-CN',
cookieEnabled: true,
onLine: true
};
var location = __lzLocation;
var console = {
log: function () {},
warn: function () {},
error: function () {},
info: function () {},
debug: function () {}
};
function setTimeout(fn, delay) {
if (typeof fn === 'function' && (!delay || delay <= 0)) {
try { fn(); } catch (e) {}
}
return 0;
} }
function setInterval() { return 0; }
function clearTimeout() {}
function clearInterval() {}
var window = {
location: __lzLocation,
document: document,
navigator: navigator,
console: console,
innerWidth: 1920,
innerHeight: 1080,
setTimeout: setTimeout,
setInterval: setInterval,
clearTimeout: clearTimeout,
clearInterval: clearInterval,
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
},
removeEventListener: function () {},
atob: function (s) { return s; },
btoa: function (s) { return s; }
};
window.window = window;
window.top = window;
window.self = window;
window.parent = window;
window.jQuery = jQuery;
window.$ = $;
var top = window;
var self = window;
var parent = window;
@@ -0,0 +1,44 @@
package cn.qaiu.parser;
import org.junit.Test;
import java.io.IOException;
import java.net.URL;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
* GHSA-997r-7xx2-p9x6 regression: Cloudreve generic parser must reject
* hosts that resolve to loopback / private / link-local / metadata ranges
* before any outbound request.
*/
public class AssertPublicHostTest {
@Test
public void rejectsLoopbackAndPrivateHosts() throws Exception {
String[] blocked = {
"http://127.0.0.1.nip.io/s/poc",
"http://localhost/s/poc",
"http://10.0.0.1/s/poc",
"http://192.168.1.1/s/poc",
"http://172.16.0.1/s/poc",
"http://169.254.169.254/s/poc",
"http://[::1]/s/poc"
};
for (String raw : blocked) {
try {
PanBase.assertPublicHost(new URL(raw));
fail("expected block for " + raw);
} catch (IOException expected) {
assertTrue(expected.getMessage().contains("不允许访问")
|| expected.getMessage().contains("无法解析"));
}
}
}
@Test
public void allowsPublicHost() throws Exception {
PanBase.assertPublicHost(new URL("https://example.com/s/demo"));
}
}
@@ -170,6 +170,10 @@ public class PanDomainTemplateTest {
assertTrue("YE should match redirected mshare URL", redirectedUrl.find()); assertTrue("YE should match redirected mshare URL", redirectedUrl.find());
assertEquals("lN7UVv-pbYJ", redirectedUrl.group("KEY")); assertEquals("lN7UVv-pbYJ", redirectedUrl.group("KEY"));
Matcher shareUrl = yePattern.matcher("https://1815268665.share.123pan.cn/123pan/iaKtVv-r4aCd");
assertTrue("YE should match uid.share.123pan.cn URL", shareUrl.find());
assertEquals("iaKtVv-r4aCd", shareUrl.group("KEY"));
Matcher htmlUrl = yePattern.matcher("https://www.123278.com/s/lN7UVv-pbYJ.html?pwd=abcd"); Matcher htmlUrl = yePattern.matcher("https://www.123278.com/s/lN7UVv-pbYJ.html?pwd=abcd");
assertTrue("YE should match html URL with query", htmlUrl.find()); assertTrue("YE should match html URL with query", htmlUrl.find());
assertEquals("lN7UVv-pbYJ", htmlUrl.group("KEY")); assertEquals("lN7UVv-pbYJ", htmlUrl.group("KEY"));
@@ -323,6 +327,63 @@ public class PanDomainTemplateTest {
fsPattern.matcher("https://xxx.feishu.cn/docs/abc123").matches()); fsPattern.matcher("https://xxx.feishu.cn/docs/abc123").matches());
} }
@Test
public void testYsPatternMatching() {
Pattern ysPattern = PanDomainTemplate.YS.getPattern();
// 主域名
Matcher m1 = ysPattern.matcher("https://qaiu.ysepan.com/");
assertTrue("YS should match ysepan.com", m1.matches());
assertEquals("qaiu", m1.group("KEY"));
Matcher m2 = ysPattern.matcher("http://sohehe4.ys168.com");
assertTrue("YS should match ys168.com", m2.matches());
assertEquals("sohehe4", m2.group("KEY"));
// 备用域名
Matcher m3 = ysPattern.matcher("https://demo.cccpan.com/");
assertTrue("YS should match cccpan.com", m3.matches());
assertEquals("demo", m3.group("KEY"));
Matcher m4 = ysPattern.matcher("https://space.ysupan.com");
assertTrue("YS should match ysupan.com", m4.matches());
assertEquals("space", m4.group("KEY"));
Matcher m5 = ysPattern.matcher("https://user.uupan.net/");
assertTrue("YS should match uupan.net", m5.matches());
assertEquals("user", m5.group("KEY"));
Matcher m6 = ysPattern.matcher("https://ok.ysok.net");
assertTrue("YS should match ysok.net", m6.matches());
assertEquals("ok", m6.group("KEY"));
// 非空间子域 / 非白名单域名
assertFalse("YS should NOT match www.ysepan.com",
ysPattern.matcher("https://www.ysepan.com/").matches());
assertFalse("YS should NOT match api host c6.ysepan.com",
ysPattern.matcher("https://c6.ysepan.com/api/ml/mldq").matches());
assertFalse("YS should NOT match CDN ys-c.ysepan.com",
ysPattern.matcher("https://ys-c.ysepan.com/wap/qaiu/x").matches());
assertFalse("YS should NOT match unrelated domain",
ysPattern.matcher("https://qaiu.evil.com/").matches());
assertFalse("YS should NOT match ysepan.com without space subdomain",
ysPattern.matcher("https://ysepan.com/").matches());
}
@Test
public void testYsFromShareUrl() {
ParserCreate parserCreate = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
ShareLinkInfo info = parserCreate.getShareLinkInfo();
assertNotNull(info);
assertEquals("ys", info.getType());
assertEquals("永硕E盘", info.getPanName());
assertEquals("qaiu", info.getShareKey());
ParserCreate backup = ParserCreate.fromShareUrl("https://demo.cccpan.com/");
assertEquals("ys", backup.getShareLinkInfo().getType());
assertEquals("demo", backup.getShareLinkInfo().getShareKey());
}
@Test @Test
public void testFsFromShareUrl() { public void testFsFromShareUrl() {
// 测试文件链接解析 // 测试文件链接解析
@@ -0,0 +1,312 @@
package cn.qaiu.parser.impl;
import cn.qaiu.WebClientVertxInit;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.parser.PanDomainTemplate;
import cn.qaiu.parser.ParserCreate;
import cn.qaiu.util.CommonUtils;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.WebClient;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.Assert.*;
/**
* 永硕E盘解析测试(含示例空间联调 + 真实下载校验)
*/
public class YsToolTest {
private static Vertx vertx;
private static WebClient webClient;
@BeforeClass
public static void setUpClass() {
vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
webClient = WebClient.create(vertx);
}
@AfterClass
public static void tearDownClass() {
if (webClient != null) {
webClient.close();
}
if (vertx != null) {
vertx.close();
}
}
@Test
public void testPatternMatching() {
Pattern pattern = PanDomainTemplate.YS.getPattern();
Matcher m1 = pattern.matcher("https://qaiu.ysepan.com/");
assertTrue(m1.matches());
assertEquals("qaiu", m1.group("KEY"));
Matcher m2 = pattern.matcher("http://sohehe4.ysepan.com");
assertTrue(m2.matches());
assertEquals("sohehe4", m2.group("KEY"));
Matcher m3 = pattern.matcher("https://demo.ys168.com/");
assertTrue(m3.matches());
assertEquals("demo", m3.group("KEY"));
assertTrue(pattern.matcher("https://a.cccpan.com/").matches());
assertTrue(pattern.matcher("https://a.ysupan.com").matches());
assertTrue(pattern.matcher("https://a.uupan.net/").matches());
assertTrue(pattern.matcher("https://a.ysok.net").matches());
assertFalse(pattern.matcher("https://www.ysepan.com/").matches());
assertFalse(pattern.matcher("https://c6.ysepan.com/api/ml/mldq").matches());
assertFalse(pattern.matcher("https://ys-c.ysepan.com/wap/qaiu/x/y/z").matches());
assertFalse(pattern.matcher("https://zy.ysepan.com/assets/index.js").matches());
assertFalse(pattern.matcher("https://qaiu.evil.com/").matches());
}
@Test
public void testIdentifyShareUrl() {
ParserCreate create = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
assertEquals("ys", create.getShareLinkInfo().getType());
assertEquals("永硕E盘", create.getShareLinkInfo().getPanName());
assertEquals("qaiu", create.getShareLinkInfo().getShareKey());
}
@Test
public void testBuildDownloadUrl() {
String url = YsTool.buildDownloadUrl(
"yssl",
"A95UIe495EkSKE",
"Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC",
"L",
"lu20.jpg");
// 与官方一致:xzpz 前不加 "_"
assertEquals(
"https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg",
url);
assertFalse("force-download 前缀会导致 404", url.contains("/_"));
}
@Test
public void testOfficialSampleUrlRealDownload() throws Exception {
String official = "https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg";
String withForcePrefix = "https://ys-l.ysepan.com/wap/yssl/_A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg";
Buffer ok = download(official, "https://yssl.ysepan.com/");
assertTrue("官方直链应能下载到 JPEG", ok.length() > 1000);
assertEquals((byte) 0xFF, ok.getByte(0));
assertEquals((byte) 0xD8, ok.getByte(1));
int forceStatus = webClient.getAbs(withForcePrefix)
.putHeader("User-Agent", "Mozilla/5.0")
.putHeader("Referer", "https://yssl.ysepan.com/")
.send()
.toCompletionStage().toCompletableFuture()
.get(30, TimeUnit.SECONDS)
.statusCode();
assertNotEquals("带 _ 前缀的直链应失败(文件不存在)", 200, forceStatus);
}
@Test
public void testQaiuSpaceFileListAndRealDownload() throws Exception {
ParserCreate create = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
create.getShareLinkInfo().setSharePassword("qaiuys168");
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
List<FileInfo> dirs = create.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(dirs);
assertFalse("目录列表不应为空", dirs.isEmpty());
assertEquals("folder", dirs.get(0).getFileType());
String dirId = dirs.get(0).getFileId();
assertNotNull(dirId);
ParserCreate filesCreate = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
filesCreate.getShareLinkInfo().setSharePassword("qaiuys168");
filesCreate.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
filesCreate.getShareLinkInfo().getOtherParam().put("dirId", dirId);
List<FileInfo> files = filesCreate.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(files);
FileInfo zip = files.stream()
.filter(f -> "file".equals(f.getFileType()))
.filter(f -> f.getFileName() != null && f.getFileName().contains("Pycharm"))
.findFirst()
.orElse(null);
assertNotNull("应能列出 Pycharm 压缩包", zip);
assertTrue(zip.getSize() > 0);
assertNotNull(zip.getParserUrl());
String param = zip.getParserUrl().substring(zip.getParserUrl().lastIndexOf('/') + 1);
JsonObject paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
assertFalse("downloadUrl 不应含 force 前缀",
paramJson.getString("downloadUrl", "").contains("/_"));
ParserCreate byId = ParserCreate.fromType("ys").shareKey("qaiu");
byId.getShareLinkInfo().setSharePassword("qaiuys168");
byId.getShareLinkInfo().getOtherParam().put("paramJson", paramJson);
String downloadUrl = byId.createTool().parseById()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(downloadUrl);
assertFalse("解析直链不应含 _xzpz 前缀", downloadUrl.matches(".*/_[^/]+/.*"));
assertTrue(downloadUrl.contains("ysepan.com"));
Buffer body = download(downloadUrl, "https://qaiu.ysepan.com/");
assertEquals("真实下载大小应与列表一致", zip.getSize().longValue(), body.length());
// ZIP magic: PK
assertEquals('P', (char) body.getByte(0));
assertEquals('K', (char) body.getByte(1));
System.out.println("qaiu real download ok, url=" + downloadUrl + ", size=" + body.length());
}
@Test
public void testFufu1ZmlHierarchyAndUrlItems() throws Exception {
// https://fufu1.ysepan.com/ 无密码;游戏3 下应按 zml 展示子目录,再进子目录才是夸克/百度链接
ParserCreate create = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
List<FileInfo> roots = create.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(roots);
FileInfo game3 = roots.stream()
.filter(f -> "folder".equals(f.getFileType()))
.filter(f -> "游戏3".equals(f.getFileName()))
.findFirst()
.orElse(null);
assertNotNull("应有目录 游戏3", game3);
ParserCreate level2 = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
level2.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
level2.getShareLinkInfo().getOtherParam().put("dirId", game3.getFileId());
List<FileInfo> subdirs = level2.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(subdirs);
assertTrue("游戏3 下应是子目录列表", subdirs.size() > 10);
assertTrue("游戏3 下列表应全是 folder(zml 子目录)",
subdirs.stream().allMatch(f -> "folder".equals(f.getFileType())));
FileInfo sample = subdirs.stream()
.filter(f -> f.getFileName() != null && f.getFileName().contains("我是未来"))
.findFirst()
.orElse(subdirs.get(0));
// 从 parserUrl 提取 zml,或直接用 fileName
ParserCreate level3 = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
level3.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
level3.getShareLinkInfo().getOtherParam().put("dirId", game3.getFileId());
level3.getShareLinkInfo().getOtherParam().put("zml", sample.getFileName());
List<FileInfo> links = level3.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(links);
assertFalse(links.isEmpty());
assertTrue("子目录内应有 url 类型",
links.stream().anyMatch(f -> "url".equals(f.getFileType())));
assertTrue("应包含夸克/百度链接名",
links.stream().anyMatch(f -> "夸克".equals(f.getFileName()) || "百度".equals(f.getFileName())));
assertFalse("空占位 URL 不应出现",
links.stream().anyMatch(f -> f.getFileName() == null || f.getFileName().isBlank()));
assertTrue("URL 条目应带 previewUrl",
links.stream().filter(f -> "url".equals(f.getFileType()))
.allMatch(f -> f.getPreviewUrl() != null && f.getPreviewUrl().startsWith("http")));
System.out.println("fufu1 hierarchy ok: 游戏3 -> " + sample.getFileName()
+ " -> " + links.stream().map(FileInfo::getFileName).toList());
}
@Test
public void testSohehe4SpaceDirectories() throws Exception {
ParserCreate create = ParserCreate.fromShareUrl("https://sohehe4.ysepan.com/");
create.getShareLinkInfo().setSharePassword("1234");
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
List<FileInfo> dirs = create.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(dirs);
assertTrue("sohehe4 应有多个目录", dirs.size() >= 3);
assertTrue(dirs.stream().allMatch(d -> "folder".equals(d.getFileType())));
String dirId = dirs.stream()
.filter(d -> d.getFileName() != null && d.getFileName().contains("留言"))
.map(FileInfo::getFileId)
.findFirst()
.orElse(dirs.get(0).getFileId());
ParserCreate filesCreate = ParserCreate.fromShareUrl("https://sohehe4.ysepan.com/");
filesCreate.getShareLinkInfo().setSharePassword("1234");
filesCreate.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
filesCreate.getShareLinkInfo().getOtherParam().put("dirId", dirId);
List<FileInfo> files = filesCreate.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(files);
assertFalse(files.isEmpty());
FileInfo file = files.stream()
.filter(f -> "file".equals(f.getFileType()))
.filter(f -> f.getSize() != null && f.getSize() > 0 && f.getSize() < 5_000_000)
.findFirst()
.orElse(null);
if (file != null) {
String param = file.getParserUrl().substring(file.getParserUrl().lastIndexOf('/') + 1);
JsonObject paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
String downloadUrl = paramJson.getString("downloadUrl");
assertNotNull(downloadUrl);
assertFalse(downloadUrl.contains("/_"));
Buffer body = download(downloadUrl, "https://sohehe4.ysepan.com/");
assertEquals(file.getSize().longValue(), body.length());
System.out.println("sohehe4 real download ok, file=" + file.getFileName()
+ ", size=" + body.length());
} else {
System.out.println("sohehe4 dir=" + dirId + " entries=" + files.size()
+ " (no small file for real download sample)");
}
}
private static Buffer download(String url, String referer) throws Exception {
return webClient.getAbs(url)
.putHeader("User-Agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36")
.putHeader("Referer", referer)
.send()
.toCompletionStage().toCompletableFuture()
.thenApply(res -> {
assertEquals("下载 HTTP 状态码应为 200: " + url, 200, res.statusCode());
Buffer body = res.body();
assertNotNull(body);
assertTrue("下载内容为空: " + url, body.length() > 0);
String ct = res.getHeader("Content-Type");
assertFalse("不应返回 HTML 错误页: " + url,
ct != null && ct.toLowerCase().contains("text/html"));
return body;
})
.get(60, TimeUnit.SECONDS);
}
}
@@ -0,0 +1,90 @@
package cn.qaiu.util;
import org.junit.Test;
import org.openjdk.nashorn.api.scripting.ScriptObjectMirror;
import java.util.Map;
import static org.junit.Assert.*;
/**
* 新版蓝奏页面会调用更多 document / jQuery API,沙箱必须能跑完并抓住 $.ajax。
*/
public class JsExecUtilsLzTest {
@Test
public void testNewIframeAjaxWithDomApis() throws Exception {
String js = """
var lanosso = '';
var down_1 = '';
var wsk_sign = 'c20230908';
var wp_sign = 'SIGN_ABC';
var ajaxdata = 'MrBR';
var kdns = 1;
if (typeof(killdns)=='undefined'){
var kdns = 0;
}
document.cookie = 'x=1';
document.location.reload();
document.querySelector('#tourl');
document.createElement('div');
$.ajax({
type : 'post',
url : '/ajaxfile.php?file=150233466',
data : { 'action':'downprocess','websignkey':ajaxdata,'signs':ajaxdata,'sign':wp_sign,'websign':'','kd':kdns,'ves':1 },
dataType : 'json',
success:function(msg){
$("#tourl").html("ok");
$("#outime").css("display","block");
}
});
""";
ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, null);
assertNotNull(sign);
assertEquals("/ajaxfile.php?file=150233466", String.valueOf(sign.get("url")));
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) sign.get("data");
assertEquals("downprocess", String.valueOf(data.get("action")));
assertEquals("SIGN_ABC", String.valueOf(data.get("sign")));
assertEquals("MrBR", String.valueOf(data.get("websignkey")));
assertEquals("1", String.valueOf(data.get("kd")));
}
@Test
public void testPwdViaJqueryValAndDocument() throws Exception {
String js = """
function down_p(){
var pwd = $('#pwd').val();
var pwd2 = document.getElementById('pwd').value;
var pwd3 = document.querySelector('#pwd').value;
$(".passwdinput").focus();
$.ajax({
type : 'post',
url : '/ajaxm.php',
data : { 'action':'downprocess','sign':'S1','p':pwd,'p2':pwd2,'p3':pwd3 }
});
}
""";
ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, "down_p", "e4k4");
assertNotNull(sign);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) sign.get("data");
assertEquals("e4k4", String.valueOf(data.get("p")));
assertEquals("e4k4", String.valueOf(data.get("p2")));
assertEquals("e4k4", String.valueOf(data.get("p3")));
}
@Test
public void testReadyAndSuccessCallbackDoNotDropAjax() throws Exception {
String js = """
$(function(){
document.getElementById('rpt').style.display = 'none';
$.ajax({ url: '/ajaxm.php?file=1', data: { a: 1 } });
$("#tourl").html("x");
});
""";
ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, null);
assertNotNull(sign);
assertTrue(String.valueOf(sign.get("url")).contains("ajaxm.php"));
}
}
+3 -3
View File
@@ -17,7 +17,7 @@
</modules> </modules>
<properties> <properties>
<revision>0.4.1</revision> <revision>0.4.5</revision>
<java.version>17</java.version> <java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
@@ -33,9 +33,9 @@
<commons-lang3.version>3.18.0</commons-lang3.version> <commons-lang3.version>3.18.0</commons-lang3.version>
<commons-beanutils2.version>2.0.0</commons-beanutils2.version> <commons-beanutils2.version>2.0.0</commons-beanutils2.version>
<parserVersion>10.2.5</parserVersion> <parserVersion>10.2.5</parserVersion>
<jackson.version>2.18.6</jackson.version> <jackson.version>2.18.9</jackson.version>
<!-- Logback 最新稳定版 --> <!-- Logback 最新稳定版 -->
<logback.version>1.5.33</logback.version> <logback.version>1.5.34</logback.version>
<junit.version>4.13.2</junit.version> <junit.version>4.13.2</junit.version>
</properties> </properties>
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
亿方云 (FangCloud) 分享链接有效性验证脚本
用法: 直接 `python3 verify_fangcloud_share.py` 运行, 分享链接已写死在 SHARE_URL
里 (不走命令行传参), 要换链接就直接改这个常量。
根因(已用真实分享链接验证确认)
--------------------------------
GET https://v2.fangcloud.cn/apps/share_links/info/{uname}
返回的是 200 + JSON, 结构形如:
{
"process": {
"is_closed": false,
"is_expired": false,
"item": {"type": "file", "id": 45006535173, "name": "..."},
...
}
}
FcTool.java 原来的实现判断有效性时读的是响应体**顶层**的 "is_valid" 字段
(`json.getBoolean("is_valid")`), 但这个字段根本不存在于该接口的实际响应里
(有效性信息实际上是 process.is_closed / process.is_expired, 且没有任何
名叫 "is_valid" 的字段) —— 所以旧的判断逻辑其实从未真正读取过这个接口的
有效性判断, 而是走的另一套 HTML 抓取 typed_id 的流程, 分享失效时会得到一个
"未匹配到文件id(typed_id)" 这种令人困惑的技术报错, 而不是清晰的"分享已失效"
修复方案(已同步到 FcTool.java): 解析前先请求一次
https://v2.fangcloud.cn/apps/share_links/info/{uname}, 用
process.is_closed / process.is_expired 判断分享是否有效, 无效则直接返回
"分享已失效或不存在", 有效再继续走原来的 HTML + files/download 流程。
本脚本用于本地复现/回归验证这条判断逻辑, 并顺带跑一遍完整的取直链流程。
"""
import re
import sys
import requests
# 直接写死分享链接, 不通过命令行参数传入
SHARE_URL = "https://v2.fangcloud.cn/h5/share/ded6dc6b9c3672b40b769804bf"
# 如果分享有密码, 在这里填上, 没有就留空字符串
SHARE_PASSWORD = ""
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/html;q=0.9, */*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
# 修复后的正则, 额外兼容 /h5/share/ 移动端落地页路径 (对齐 PanDomainTemplate.FC)
FC_REGEX = re.compile(r"https://v2\.fangcloud\.(com|cn)/(?:h5/)?(s|share|sharing)/(?P<KEY>.+)")
SHARE_INFO_URL = "https://v2.fangcloud.cn/apps/share_links/info/{key}"
SHARE_URL_PREFIX = "https://v2.fangcloud.com/sharing/{key}"
DOWN_REQUEST_URL = "https://v2.fangcloud.cn/apps/files/download"
TYPED_ID_RE = re.compile(r'id="typed_id"\s+value="file_(\d+)"')
def extract_share_key(url: str) -> str:
m = FC_REGEX.search(url)
if not m:
raise ValueError(f"无法从链接中提取 shareKey (正则不匹配): {url}")
return m.group("KEY")
def check_is_valid(share_key: str, session: requests.Session) -> bool:
print("=" * 70)
print("Step 1. 请求 share_links/info 判断分享有效性 (对齐 FcTool.java 修复后的逻辑)")
print("=" * 70)
url = SHARE_INFO_URL.format(key=share_key)
r = session.get(url, timeout=15)
print(f"GET {url}")
print(f"状态码: {r.status_code}")
try:
data = r.json()
except Exception:
print("响应不是合法JSON => 判定分享已失效或不存在\n")
return False
process = data.get("process")
if not process:
print("响应中没有 process 字段 => 判定分享已失效或不存在\n")
return False
is_closed = bool(process.get("is_closed"))
is_expired = bool(process.get("is_expired"))
item = process.get("item")
print(f"is_closed = {is_closed}, is_expired = {is_expired}, item = {item}")
if is_closed or is_expired:
print("=> 分享已失效或不存在\n")
return False
print("=> 分享有效\n")
return True
def get_download_url(share_key: str, pwd: str, session: requests.Session) -> str:
print("=" * 70)
print("Step 2. 走原有 HTML + files/download 流程取直链")
print("=" * 70)
r = session.get(SHARE_URL_PREFIX.format(key=share_key), timeout=15)
print(f"GET {SHARE_URL_PREFIX.format(key=share_key)} -> {r.status_code}, 最终URL: {r.url}")
html = r.text
if pwd:
# 加密分享: 提交密码换取跳转后的落地页 (此处仅示意, 具体见 FcTool.java)
m = re.search(r'name="requesttoken"\s+value="([a-zA-Z0-9_+=]+)"', html)
if not m:
raise RuntimeError("未匹配到加密分享的密码输入页面的 requesttoken")
token = m.group(1)
r2 = session.post(
"https://v2.fangcloud.cn/sharing/" + share_key,
data={"requesttoken": token, "password": pwd},
timeout=15,
)
html = r2.text
m = TYPED_ID_RE.search(html)
if not m:
raise RuntimeError("未匹配到文件id(typed_id), 分享可能是文件夹或页面结构有变化")
fid = m.group(1)
print(f"提取到 file_id = {fid}")
r3 = session.get(
DOWN_REQUEST_URL,
params={"file_id": fid, "scenario": "share", "unique_name": share_key},
timeout=15,
allow_redirects=False,
)
print(f"GET {DOWN_REQUEST_URL} -> {r3.status_code}")
if r3.status_code in (301, 302) and r3.headers.get("Location"):
return r3.headers["Location"]
data = r3.json()
if not data.get("success"):
raise RuntimeError(f"取直链失败: {data}")
return data["download_url"]
def main() -> None:
share_key = extract_share_key(SHARE_URL)
print(f"分享链接: {SHARE_URL}")
print(f"提取到的 shareKey: {share_key}\n")
session = requests.Session()
session.headers.update(HEADERS)
if not check_is_valid(share_key, session):
print("结论: 分享无效, 不再继续取直链。")
sys.exit(1)
try:
url = get_download_url(share_key, SHARE_PASSWORD, session)
print(f"\n>>> 直链: {url}")
except Exception as e:
print(f"\n取直链失败: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nfd-web", "name": "nfd-web",
"version": "0.4.1", "version": "0.4.5",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
+194 -56
View File
@@ -24,7 +24,7 @@
:class="[getFileTypeClass(file), { 'selected': batchMode && isFileSelected(file) }]" :class="[getFileTypeClass(file), { 'selected': batchMode && isFileSelected(file) }]"
@click="batchMode ? onBatchClick(file) : handleFileClick(file)" @click="batchMode ? onBatchClick(file) : handleFileClick(file)"
> >
<div v-if="batchMode && file.fileType !== 'folder'" class="batch-checkbox" @click.stop="toggleFileSelect(file)"> <div v-if="batchMode && isDownloadableFile(file)" class="batch-checkbox" @click.stop="toggleFileSelect(file)">
<i :class="isFileSelected(file) ? 'fas fa-check-square' : 'far fa-square'" <i :class="isFileSelected(file) ? 'fas fa-check-square' : 'far fa-square'"
:style="{ color: isFileSelected(file) ? '#409eff' : '#c0c4cc' }"></i> :style="{ color: isFileSelected(file) ? '#409eff' : '#c0c4cc' }"></i>
</div> </div>
@@ -52,9 +52,10 @@
<div class="batch-right"> <div class="batch-right">
<el-button <el-button
type="primary" size="small" type="primary" size="small"
:disabled="selectedFiles.length === 0" :disabled="selectedFiles.length === 0 || batchBrowserDownloadDisabled"
:loading="batchDownloading" :loading="batchDownloading"
@click="batchBrowserDownload" @click="batchBrowserDownload"
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
> >
<i class="fas fa-download"></i> 浏览器下载 <i class="fas fa-download"></i> 浏览器下载
</el-button> </el-button>
@@ -127,9 +128,10 @@
<el-button <el-button
type="primary" type="primary"
size="small" size="small"
:disabled="selectedFiles.length === 0 || batchDownloading" :disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled"
:loading="batchDownloading" :loading="batchDownloading"
@click="batchBrowserDownload" @click="batchBrowserDownload"
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
> >
浏览器下载 浏览器下载
</el-button> </el-button>
@@ -166,25 +168,41 @@
</div> </div>
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4> <h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta"> <div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p> <p>类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p> <p v-if="selectedNode.fileType !== 'url'">大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl" class="file-detail-link">
链接: {{ selectedNode.previewUrl }}
</p>
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p> <p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p> <p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
</div> </div>
<div class="file-detail-actions"> <div class="file-detail-actions">
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)"> <el-button
v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl"
type="primary" size="small"
@click="openExternalLink(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开链接
</el-button>
<el-button
v-else-if="selectedNode.parserUrl || selectedNode.previewUrl"
size="small"
@click="previewFile(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开 <i class="fas fa-external-link-alt"></i> 打开
</el-button> </el-button>
<el-button <el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'" v-if="isDownloadableFile(selectedNode)"
type="success" size="small" type="success" size="small"
@click="handleDownload(selectedNode)" @click="handleDownload(selectedNode)"
:loading="downloadLoading" :loading="downloadLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器下载' : ''"
> >
<i class="fas fa-download"></i> 下载 <i class="fas fa-download"></i> 下载
</el-button> </el-button>
<el-button <el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'" v-if="isDownloadableFile(selectedNode)"
type="primary" size="small" type="primary" size="small"
@click="sendSingleToDownloader(selectedNode)" @click="sendSingleToDownloader(selectedNode)"
:loading="singleSendLoading" :loading="singleSendLoading"
@@ -192,10 +210,12 @@
<i class="fas fa-paper-plane"></i> 发送到下载器 <i class="fas fa-paper-plane"></i> 发送到下载器
</el-button> </el-button>
<el-button <el-button
v-if="selectedNode.parserUrl" v-if="isDownloadableFile(selectedNode)"
size="small" size="small"
@click="copyDirectLink(selectedNode)" @click="copyDirectLink(selectedNode)"
:loading="copyLinkLoading" :loading="copyLinkLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
> >
<i class="fas fa-link"></i> 复制直链 <i class="fas fa-link"></i> 复制直链
</el-button> </el-button>
@@ -243,25 +263,41 @@
</div> </div>
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4> <h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta"> <div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p> <p>类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p> <p v-if="selectedNode.fileType !== 'url'">大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl" class="file-detail-link">
链接: {{ selectedNode.previewUrl }}
</p>
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p> <p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p> <p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
</div> </div>
<div class="file-detail-actions"> <div class="file-detail-actions">
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)"> <el-button
v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl"
type="primary" size="small"
@click="openExternalLink(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开链接
</el-button>
<el-button
v-else-if="selectedNode.parserUrl || selectedNode.previewUrl"
size="small"
@click="previewFile(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开 <i class="fas fa-external-link-alt"></i> 打开
</el-button> </el-button>
<el-button <el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'" v-if="isDownloadableFile(selectedNode)"
type="success" size="small" type="success" size="small"
@click="handleDownload(selectedNode)" @click="handleDownload(selectedNode)"
:loading="downloadLoading" :loading="downloadLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器下载' : ''"
> >
<i class="fas fa-download"></i> 下载 <i class="fas fa-download"></i> 下载
</el-button> </el-button>
<el-button <el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'" v-if="isDownloadableFile(selectedNode)"
type="primary" size="small" type="primary" size="small"
@click="sendSingleToDownloader(selectedNode)" @click="sendSingleToDownloader(selectedNode)"
:loading="singleSendLoading" :loading="singleSendLoading"
@@ -269,10 +305,12 @@
<i class="fas fa-paper-plane"></i> 发送到下载器 <i class="fas fa-paper-plane"></i> 发送到下载器
</el-button> </el-button>
<el-button <el-button
v-if="selectedNode.parserUrl" v-if="isDownloadableFile(selectedNode)"
size="small" size="small"
@click="copyDirectLink(selectedNode)" @click="copyDirectLink(selectedNode)"
:loading="copyLinkLoading" :loading="copyLinkLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
> >
<i class="fas fa-link"></i> 复制直链 <i class="fas fa-link"></i> 复制直链
</el-button> </el-button>
@@ -290,7 +328,7 @@
<div v-if="batchMode" class="mobile-batch-footer"> <div v-if="batchMode" class="mobile-batch-footer">
<span class="tree-sidebar-count">已勾选 {{ selectedFiles.length }} 个文件</span> <span class="tree-sidebar-count">已勾选 {{ selectedFiles.length }} 个文件</span>
<div class="tree-sidebar-actions"> <div class="tree-sidebar-actions">
<el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading" :loading="batchDownloading" @click="batchBrowserDownload">浏览器下载</el-button> <el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled" :loading="batchDownloading" @click="batchBrowserDownload" :title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''">浏览器下载</el-button>
<el-button type="success" size="small" :disabled="selectedFiles.length === 0 || batchDownloading" :loading="batchDownloading" @click="batchSendToDownloader">发送到下载器</el-button> <el-button type="success" size="small" :disabled="selectedFiles.length === 0 || batchDownloading" :loading="batchDownloading" @click="batchSendToDownloader">发送到下载器</el-button>
<el-button size="small" @click="toggleBatchMode">取消</el-button> <el-button size="small" @click="toggleBatchMode">取消</el-button>
</div> </div>
@@ -323,19 +361,22 @@
</div> </div>
<span slot="footer" class="dialog-footer"> <span slot="footer" class="dialog-footer">
<el-button type="primary" @click="previewFile(selectedFile)">打开</el-button> <el-button type="primary" @click="previewFile(selectedFile)">
<!-- 弹窗下载按钮 --> {{ selectedFile?.fileType === 'url' ? '打开链接' : '打开' }}
</el-button>
<el-button <el-button
v-if="selectedFile && selectedFile.parserUrl" v-if="isDownloadableFile(selectedFile)"
type="success" type="success"
@click="handleDownload(selectedFile)" @click="handleDownload(selectedFile)"
style="margin-left: 8px;" style="margin-left: 8px;"
:loading="downloadLoading" :loading="downloadLoading"
:disabled="needsDownloader(selectedFile)"
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器下载' : ''"
> >
下载 下载
</el-button> </el-button>
<el-button <el-button
v-if="selectedFile && selectedFile.parserUrl" v-if="isDownloadableFile(selectedFile)"
type="primary" type="primary"
@click="sendSingleToDownloader(selectedFile)" @click="sendSingleToDownloader(selectedFile)"
style="margin-left: 8px;" style="margin-left: 8px;"
@@ -344,10 +385,12 @@
发送到下载器 发送到下载器
</el-button> </el-button>
<el-button <el-button
v-if="selectedFile && selectedFile.parserUrl" v-if="isDownloadableFile(selectedFile)"
@click="copyDirectLink(selectedFile)" @click="copyDirectLink(selectedFile)"
style="margin-left: 8px;" style="margin-left: 8px;"
:loading="copyLinkLoading" :loading="copyLinkLoading"
:disabled="needsDownloader(selectedFile)"
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器,无法直接复制直链' : ''"
> >
复制直链 复制直链
</el-button> </el-button>
@@ -394,6 +437,11 @@ export default {
type: String, type: String,
default: '' default: ''
}, },
// 加密 auth,用于子目录/下载请求透传(后端未带上时前端补齐)
auth: {
type: String,
default: ''
},
viewMode: { viewMode: {
type: String, type: String,
default: 'pane' // 'pane' or 'tree' default: 'pane' // 'pane' or 'tree'
@@ -460,6 +508,11 @@ export default {
lines.push(`更新时间: ${updateTime}`) lines.push(`更新时间: ${updateTime}`)
} }
return lines return lines
},
// 所选文件全部需要下载器时,禁用浏览器批量下载
batchBrowserDownloadDisabled() {
return this.selectedFiles.length > 0
&& this.selectedFiles.every(f => this.needsDownloader(f))
} }
}, },
watch: { watch: {
@@ -493,6 +546,13 @@ export default {
} }
return headers return headers
}, },
// 子目录/下载链接透传 auth,避免进入子目录变成 guest
// 注意:auth 可能已 encodeURIComponent,直接拼接,避免 searchParams 再编一层
withAuth(url) {
if (!url || !this.auth) return url
if (/[?&]auth=/.test(url)) return url
return url + (url.includes('?') ? '&' : '?') + 'auth=' + this.auth
},
buildApiUrl() { buildApiUrl() {
const baseUrl = `${window.location.origin}/v2/getFileList` const baseUrl = `${window.location.origin}/v2/getFileList`
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -501,14 +561,14 @@ export default {
if (this.password) { if (this.password) {
params.append('pwd', this.password) params.append('pwd', this.password)
} }
return `${baseUrl}?${params.toString()}` return this.withAuth(`${baseUrl}?${params.toString()}`)
}, },
// 懒加载子节点 // 懒加载子节点
loadNode(node, resolve) { loadNode(node, resolve) {
if (node.level === 0) { if (node.level === 0) {
resolve(this.treeData[0].children) resolve(this.treeData[0].children)
} else if (node.data.fileType === 'folder' && node.data.parserUrl) { } else if (node.data.fileType === 'folder' && node.data.parserUrl) {
axios.get(node.data.parserUrl, { headers: this.apiKeyHeaders() }).then(res => { axios.get(this.withAuth(node.data.parserUrl), { headers: this.apiKeyHeaders() }).then(res => {
if (res.data.code === 200) { if (res.data.code === 200) {
const children = (res.data.data || []).map(item => ({ const children = (res.data.data || []).map(item => ({
...item, ...item,
@@ -535,6 +595,8 @@ export default {
handleFileClick(file) { handleFileClick(file) {
if (file.fileType === 'folder') { if (file.fileType === 'folder') {
this.enterFolder(file) this.enterFolder(file)
} else if (file.fileType === 'url') {
this.openExternalLink(file)
} else if (this.viewMode === 'pane') { } else if (this.viewMode === 'pane') {
this.selectedFile = file this.selectedFile = file
this.fileDialogVisible = true this.fileDialogVisible = true
@@ -548,10 +610,11 @@ export default {
} }
try { try {
this.loading = true this.loading = true
const response = await axios.get(folder.parserUrl, { headers: this.apiKeyHeaders() }) const folderUrl = this.withAuth(folder.parserUrl)
const response = await axios.get(folderUrl, { headers: this.apiKeyHeaders() })
if (response.data.code === 200) { if (response.data.code === 200) {
const newDir = { const newDir = {
url: folder.parserUrl, url: folderUrl,
name: folder.fileName || '未命名文件夹' name: folder.fileName || '未命名文件夹'
} }
this.pathStack.push(newDir) this.pathStack.push(newDir)
@@ -585,7 +648,7 @@ export default {
} }
try { try {
this.loading = true this.loading = true
const response = await axios.get(currentDir.url, { headers: this.apiKeyHeaders() }) const response = await axios.get(this.withAuth(currentDir.url), { headers: this.apiKeyHeaders() })
if (response.data.code === 200) { if (response.data.code === 200) {
this.currentFileList = response.data.data || [] this.currentFileList = response.data.data || []
} else { } else {
@@ -599,8 +662,32 @@ export default {
this.loading = false this.loading = false
} }
}, },
isDownloadableFile(file) {
return !!(file && file.parserUrl && file.fileType !== 'folder' && file.fileType !== 'url')
},
// 需要下载器(带 cookie 等特殊头)时,浏览器直连/复制直链不可用
// UC/夸克目录文件始终走下载器
needsDownloader(file) {
if (!file) return false
if (file.extParameters && file.extParameters.needDownloader) return true
const pan = (file.panType || '').toLowerCase()
return pan === 'uc' || pan === 'qk'
},
openExternalLink(file) {
const link = file?.previewUrl || file?.description
if (!link) {
this.$message.warning('该条目暂无外链')
return
}
window.open(link, '_blank', 'noopener,noreferrer')
this.closeFileDialog()
},
// 预览文件 // 预览文件
previewFile(file) { previewFile(file) {
if (file?.fileType === 'url') {
this.openExternalLink(file)
return
}
if (file?.previewUrl || file?.parserUrl) { if (file?.previewUrl || file?.parserUrl) {
this.previewUrl = this.appendToken(file.previewUrl || file.parserUrl) this.previewUrl = this.appendToken(file.previewUrl || file.parserUrl)
this.isPreviewing = true this.isPreviewing = true
@@ -650,25 +737,18 @@ export default {
// 需要下载器,调用 getFileDownInfo 接口获取下载信息 // 需要下载器,调用 getFileDownInfo 接口获取下载信息
this.downloadLoading = true this.downloadLoading = true
try { try {
// 从 parserUrl 提取 type 和 param const tp = this.extractTypeParam(file)
// parserUrl 格式: /v2/redirectUrl/{type}/{param} 或 完整URL if (!tp) {
const url = new URL(file.parserUrl, window.location.origin)
const pathParts = url.pathname.split('/')
// 找到 redirectUrl 后面的部分
const redirectIdx = pathParts.indexOf('redirectUrl')
if (redirectIdx === -1 || redirectIdx + 2 >= pathParts.length) {
this.$message.error('无法解析下载参数') this.$message.error('无法解析下载参数')
return return
} }
const type = pathParts[redirectIdx + 1]
const param = pathParts[redirectIdx + 2]
const headers = {} const headers = {}
const apiKey = localStorage.getItem('nfd_user_api_key') const apiKey = localStorage.getItem('nfd_user_api_key')
if (apiKey) { if (apiKey) {
headers['X-API-Key'] = apiKey headers['X-API-Key'] = apiKey
} }
const response = await axios.get(`${window.location.origin}/v2/getFileDownInfo/${type}/${param}`, { headers }) const response = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
if (response.data.code === 200 && response.data.data) { if (response.data.code === 200 && response.data.data) {
const info = response.data.data const info = response.data.data
if (info.needDownloader) { if (info.needDownloader) {
@@ -759,10 +839,7 @@ export default {
const tp = this.extractTypeParam(file) const tp = this.extractTypeParam(file)
if (tp && file.extParameters && file.extParameters.needDownloader) { if (tp && file.extParameters && file.extParameters.needDownloader) {
const headers = this.apiKeyHeaders() const headers = this.apiKeyHeaders()
const resp = await axios.get( const resp = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
`${window.location.origin}/v2/getFileDownInfo/${tp.type}/${tp.param}`,
{ headers }
)
const info = resp.data.data || resp.data const info = resp.data.data || resp.data
if (info && info.downloadUrl) { if (info && info.downloadUrl) {
await addDownload(info.downloadUrl, info.downloadHeaders || {}, file.fileName) await addDownload(info.downloadUrl, info.downloadHeaders || {}, file.fileName)
@@ -870,7 +947,9 @@ export default {
}, },
fileMetaText(file) { fileMetaText(file) {
const parts = [] const parts = []
if (file.fileType !== 'folder') { if (file.fileType === 'url') {
parts.push('超链接')
} else if (file.fileType !== 'folder') {
parts.push(file.sizeStr || '0B') parts.push(file.sizeStr || '0B')
} }
const timeText = this.formatDate(file.createTime) const timeText = this.formatDate(file.createTime)
@@ -916,7 +995,7 @@ export default {
this.toggleFileSelect(file) this.toggleFileSelect(file)
}, },
selectAll() { selectAll() {
this.selectedFiles = this.currentFileList.filter(f => f.fileType !== 'folder' && f.parserUrl) this.selectedFiles = this.currentFileList.filter(f => this.isDownloadableFile(f))
}, },
deselectAll() { deselectAll() {
this.selectedFiles = [] this.selectedFiles = []
@@ -924,25 +1003,41 @@ export default {
onTreeCheckChange() { onTreeCheckChange() {
if (!this.$refs.fileTree) return if (!this.$refs.fileTree) return
const checked = this.$refs.fileTree.getCheckedNodes() const checked = this.$refs.fileTree.getCheckedNodes()
this.selectedFiles = checked.filter(n => n.fileType !== 'folder' && n.parserUrl) this.selectedFiles = checked.filter(n => this.isDownloadableFile(n))
}, },
extractTypeParam(file) { extractTypeParam(file) {
if (!file.parserUrl) return null if (!file.parserUrl) return null
try { try {
// pathname 已是解码后的 path;后端现用 URL-Safe Base64(无 %),可直接使用
const url = new URL(file.parserUrl, window.location.origin) const url = new URL(file.parserUrl, window.location.origin)
const parts = url.pathname.split('/') const m = url.pathname.match(/\/redirectUrl\/([^/]+)\/(.+)$/)
const idx = parts.indexOf('redirectUrl') if (!m) return null
if (idx === -1 || idx + 2 >= parts.length) return null let param = m[2]
return { type: parts[idx + 1], param: parts[idx + 2] } // 兼容历史「标准 Base64 + URLEncode」旧链接
if (/%[0-9A-Fa-f]{2}/.test(param)) {
try { param = decodeURIComponent(param) } catch { /* ignore */ }
}
return { type: m[1], param }
} catch { } catch {
return null return null
} }
}, },
// URL-Safe Base64 本身可进 pathencodeURIComponent 对 - _ 无影响,只 encode 一次
buildFileDownInfoUrl(type, param) {
return this.withAuth(
`${window.location.origin}/v2/getFileDownInfo/${type}/${encodeURIComponent(param)}`
)
},
async batchBrowserDownload() { async batchBrowserDownload() {
if (this.selectedFiles.length === 0) return if (this.selectedFiles.length === 0) return
const files = this.selectedFiles.filter(f => !this.needsDownloader(f))
if (files.length === 0) {
this.$message.warning('所选文件需使用「发送到下载器」下载')
return
}
this.batchDownloading = true this.batchDownloading = true
this.batchProgress = { current: 0, total: this.selectedFiles.length, failed: 0 } this.batchProgress = { current: 0, total: files.length, failed: 0 }
for (const file of this.selectedFiles) { for (const file of files) {
try { try {
const a = document.createElement('a') const a = document.createElement('a')
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl) const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
@@ -975,13 +1070,32 @@ export default {
const total = this.selectedFiles.length const total = this.selectedFiles.length
this.batchProgress = { current: 0, total, failed: 0 } this.batchProgress = { current: 0, total, failed: 0 }
// 所有文件统一发 parserUrl(302) + downloadHeaders 给下载器 // needDownloader 文件走 getFileDownInfo 拿直链+cookie;其余发 parserUrl + headers
const downloadTasks = [] const downloadTasks = []
const apiHeaders = this.apiKeyHeaders()
for (const file of this.selectedFiles) { for (const file of this.selectedFiles) {
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl) try {
const url = this.appendToken(rawUrl) if (this.needsDownloader(file)) {
const headers = (file.extParameters && file.extParameters.downloadHeaders) || {} const tp = this.extractTypeParam(file)
downloadTasks.push({ url, headers, fileName: file.fileName }) if (!tp) throw new Error('无法解析下载参数')
const resp = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers: apiHeaders })
const info = resp.data.data || resp.data
if (!info?.downloadUrl) throw new Error('获取下载信息失败')
downloadTasks.push({
url: info.downloadUrl,
headers: info.downloadHeaders || {},
fileName: file.fileName
})
} else {
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
const url = this.appendToken(rawUrl)
const headers = (file.extParameters && file.extParameters.downloadHeaders) || {}
downloadTasks.push({ url, headers, fileName: file.fileName })
}
} catch (e) {
console.error('准备下载任务失败:', file.fileName, e)
this.batchProgress.failed++
}
this.batchProgress.current++ this.batchProgress.current++
} }
@@ -1005,14 +1119,18 @@ export default {
}, },
renderContent(h, { node, data, store }) { renderContent(h, { node, data, store }) {
const isFolder = data.fileType === 'folder' const isFolder = data.fileType === 'folder'
const isUrl = data.fileType === 'url'
return h('div', { return h('div', {
class: 'custom-tree-node' class: 'custom-tree-node'
}, [ }, [
h('i', { h('i', {
class: [this.getFileIcon(data), { 'folder-icon': isFolder, 'file-icon': !isFolder }] class: [
this.getFileIcon(data),
{ 'folder-icon': isFolder, 'url-icon': isUrl, 'file-icon': !isFolder && !isUrl }
]
}), }),
h('span', { h('span', {
class: ['node-label', { 'folder-text': isFolder, 'file-text': !isFolder }] class: ['node-label', { 'folder-text': isFolder, 'url-text': isUrl, 'file-text': !isFolder && !isUrl }]
}, node.label) }, node.label)
]) ])
} }
@@ -1193,6 +1311,10 @@ html, body, #app, .main-container, .directory-tree, .content-card {
color: #27ae60; color: #27ae60;
} }
.url .file-icon {
color: #1a73e8;
}
.file-name { .file-name {
font-weight: 500; font-weight: 500;
font-size: 0.85rem; font-size: 0.85rem;
@@ -1438,6 +1560,22 @@ html, body, #app, .main-container, .directory-tree, .content-card {
color: #4a9eff !important; color: #4a9eff !important;
} }
.custom-tree-node .url-icon {
color: #1a73e8 !important;
}
.dark-theme .custom-tree-node .url-icon {
color: #8ab4f8 !important;
}
.custom-tree-node .url-text {
color: #1a73e8 !important;
}
.dark-theme .custom-tree-node .url-text {
color: #8ab4f8 !important;
}
.custom-tree-node .folder-text { .custom-tree-node .folder-text {
color: #409eff !important; color: #409eff !important;
font-weight: 500; font-weight: 500;
+13 -2
View File
@@ -246,8 +246,8 @@
storage: 'hash' storage: 'hash'
}, },
'123pan': { '123pan': {
reg: /((?:https?:\/\/)?www\.(123pan|123865|123684)\.com\/s\/[\w-]{6,})/, reg: /((?:https?:\/\/)?(?:[a-zA-Z\d-]+\.(?:m?share)\.123pan\.cn\/123pan\/[\w-]+|(?:www\.)?(?:123panpay|123pan|123\d{3})\.(?:com|cn)\/s\/[\w-]{6,}(?:\.html)?)(?:\?[^#\s]*)?)/i,
host: /www\.123pan\.com/, host: /(?:[a-zA-Z\d-]+\.(?:m?share)\.123pan\.cn|(?:www\.)?(?:123panpay|123pan|123\d{3})\.(?:com|cn))/i,
input: ['.ca-fot input', ".appinput .appinput"], input: ['.ca-fot input', ".appinput .appinput"],
button: ['.ca-fot button', ".appinput button"], button: ['.ca-fot button', ".appinput button"],
name: '123云盘', name: '123云盘',
@@ -356,6 +356,13 @@
host: /(fast|drive)\.uc\.cn/, host: /(fast|drive)\.uc\.cn/,
name: 'UC网盘' name: 'UC网盘'
}, },
// 永硕E盘:主 ysepan.com/ys168.com,备 cccpan.com/ysupan.com/uupan.net/ysok.net
ysepan: {
reg: /https?:\/\/(?!(?:www|zy|ht|api|c\d+|ys-[a-zA-Z0-9]+)\.)[a-zA-Z\d-]+\.(?:ysepan\.com|ys168\.com|cccpan\.com|ysupan\.com|uupan\.net|ysok\.net)\/?/,
host: /[a-zA-Z\d-]+\.(?:ysepan\.com|ys168\.com|cccpan\.com|ysupan\.com|uupan\.net|ysok\.net)/,
name: '永硕E盘',
storage: 'hash'
},
other: { other: {
reg: /https:\/\/([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}\/s\/.+/, reg: /https:\/\/([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}\/s\/.+/,
@@ -370,6 +377,7 @@
parseLink(text = '') { parseLink(text = '') {
let obj = {name: '', link: '', storage: '', storagePwdName: ''}; let obj = {name: '', link: '', storage: '', storagePwdName: ''};
if (text) { if (text) {
text = String(text).trim();
try { try {
text = decodeURIComponent(text); text = decodeURIComponent(text);
} catch { } catch {
@@ -383,6 +391,9 @@
let matches = text.match(val.reg); let matches = text.match(val.reg);
obj.name = val.name; obj.name = val.name;
obj.link = matches[0]; obj.link = matches[0];
if (obj.link && !/^https?:\/\//i.test(obj.link)) {
obj.link = 'https://' + obj.link.replace(/^\/\//, '');
}
obj.storage = val.storage; obj.storage = val.storage;
obj.storagePwdName = val.storagePwdName || null; obj.storagePwdName = val.storagePwdName || null;
if (val.replaceHost) { if (val.replaceHost) {
+3
View File
@@ -5,6 +5,7 @@ const fileTypeUtils = {
}, },
getFileTypeClass(file) { getFileTypeClass(file) {
if (file.fileType === 'folder') return 'folder' if (file.fileType === 'folder') return 'folder'
if (file.fileType === 'url') return 'url'
const ext = this.getFileExtension(file.fileName) const ext = this.getFileExtension(file.fileName)
const fileTypes = { const fileTypes = {
'image': ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'], 'image': ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'],
@@ -23,6 +24,8 @@ const fileTypeUtils = {
}, },
getFileIcon(file) { getFileIcon(file) {
if (file.fileType === 'folder') return 'fas fa-folder' if (file.fileType === 'folder') return 'fas fa-folder'
// 永硕等网盘的外链/公告条目
if (file.fileType === 'url') return 'fas fa-link'
const ext = this.getFileExtension(file.fileName) const ext = this.getFileExtension(file.fileName)
const iconMap = { const iconMap = {
'jpg': 'fas fa-file-image', 'jpeg': 'fas fa-file-image', 'png': 'fas fa-file-image', 'jpg': 'fas fa-file-image', 'jpeg': 'fas fa-file-image', 'png': 'fas fa-file-image',
+65 -20
View File
@@ -90,7 +90,7 @@
<!-- 开关按钮控制是否自动读取剪切板 --> <!-- 开关按钮控制是否自动读取剪切板 -->
<el-switch v-model="autoReadClipboard" active-text="自动识别剪切板"></el-switch> <el-switch v-model="autoReadClipboard" active-text="自动识别剪切板"></el-switch>
<el-input placeholder="请粘贴分享链接(http://或https://)" v-model="link" id="url" @paste="onPaste"> <el-input placeholder="请粘贴分享链接(http://或https://)" v-model="link" id="url" @paste="onPaste" @blur="normalizeShortcutInput">
<template #prepend>分享链接</template> <template #prepend>分享链接</template>
<template #append v-if="!autoReadClipboard"> <template #append v-if="!autoReadClipboard">
<el-button @click="getPaste(true)">读取剪切板</el-button> <el-button @click="getPaste(true)">读取剪切板</el-button>
@@ -131,10 +131,15 @@
<div style="display: flex; align-items: center; justify-content: space-between;"> <div style="display: flex; align-items: center; justify-content: space-between;">
<span>下载链接</span> <span>下载链接</span>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
<el-button @click="openUrl(downloadUrl)" type="primary" size="small"> <el-tooltip :disabled="!needsDownloader"
<el-icon style="margin-right: 4px;"><Download /></el-icon> 下载 content="该网盘需使用下载器下载" placement="top">
</el-button> <el-button @click="openUrl(downloadUrl)" type="primary" size="small"
<el-button @click="openUrl(getPreviewLink())" type="default" size="small"> :disabled="needsDownloader">
<el-icon style="margin-right: 4px;"><Download /></el-icon> 下载
</el-button>
</el-tooltip>
<el-button @click="openUrl(getPreviewLink())" type="default" size="small"
:disabled="needsDownloader">
<el-icon style="margin-right: 4px;"><View /></el-icon> 预览 <el-icon style="margin-right: 4px;"><View /></el-icon> 预览
</el-button> </el-button>
<el-tooltip :disabled="aria2Connected" <el-tooltip :disabled="aria2Connected"
@@ -150,10 +155,14 @@
</template> </template>
<el-input :value="downloadUrl" readonly> <el-input :value="downloadUrl" readonly>
<template #append> <template #append>
<el-button v-clipboard:copy="downloadUrl" v-clipboard:success="onCopy" <el-tooltip :disabled="!needsDownloader"
v-clipboard:error="onError" style="padding: 0 14px;"> content="该网盘需使用下载器,无法直接复制直链" placement="top">
<el-icon><CopyDocument/></el-icon> <el-button v-clipboard:copy="downloadUrl" v-clipboard:success="onCopy"
</el-button> v-clipboard:error="onError" style="padding: 0 14px;"
:disabled="needsDownloader">
<el-icon><CopyDocument/></el-icon>
</el-button>
</el-tooltip>
</template> </template>
</el-input> </el-input>
<!-- 文件元信息 --> <!-- 文件元信息 -->
@@ -417,6 +426,7 @@
:file-list="directoryData" :file-list="directoryData"
:share-url="link" :share-url="link"
:password="password" :password="password"
:auth="directoryAuth"
:view-mode="directoryViewMode" :view-mode="directoryViewMode"
@file-click="handleFileClick" @file-click="handleFileClick"
/> />
@@ -649,6 +659,7 @@ export default {
// 目录树 // 目录树
showDirectoryTree: false, showDirectoryTree: false,
directoryData: [], directoryData: [],
directoryAuth: '', // 目录解析时的加密 auth,透传给子目录/下载
// 统计信息 // 统计信息
node1Info: {}, node1Info: {},
@@ -760,6 +771,16 @@ export default {
thunder: '迅雷' thunder: '迅雷'
} }
return map[this.aria2ConfigForm.downloaderType] || 'Aria2' return map[this.aria2ConfigForm.downloaderType] || 'Aria2'
},
// 需要下载器(带 cookie 等特殊头)时禁用浏览器下载/复制直链;UC/夸克始终需要
needsDownloader() {
const pan = (this.getCurrentPanType() || '').toLowerCase()
if (pan === 'uc' || pan === 'qk') return true
const data = this.parseResult?.data
if (!data) return false
if (data.needDownloader || data.otherParam?.needDownloader) return true
const headers = data.downloadHeaders || data.otherParam?.downloadHeaders
return !!(headers && (headers.cookie || headers.Cookie))
} }
}, },
methods: { methods: {
@@ -1122,10 +1143,24 @@ export default {
normalizeShortcutInput() { normalizeShortcutInput() {
if (!this.link) return if (!this.link) return
const trimmed = this.link.trim() const trimmed = this.link.trim()
if (!trimmed) return if (!trimmed) {
this.link = ''
return
}
this.link = trimmed
// 已经是直接链接,跳过 // 已经是直接链接:仍尝试从整段文本中抽出标准分享地址
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
const linkInfo = parserUrl.parseLink(trimmed)
if (linkInfo.link) {
this.link = linkInfo.link
const pwd = parserUrl.parsePwd(trimmed)
if (!this.password && pwd) {
this.password = pwd
}
}
return
}
// 尝试短格式 // 尝试短格式
const shortInfo = this.expandShortFormat(trimmed) const shortInfo = this.expandShortFormat(trimmed)
@@ -1168,6 +1203,7 @@ export default {
this.statisticsData = {} this.statisticsData = {}
this.showDirectoryTree = false this.showDirectoryTree = false
this.directoryData = [] this.directoryData = []
this.directoryAuth = ''
}, },
// 统一API调用(自动添加认证参数) // 统一API调用(自动添加认证参数)
@@ -1176,10 +1212,12 @@ export default {
this.errorBadgeVisible = false this.errorBadgeVisible = false
try { try {
this.isLoading = true this.isLoading = true
// 添加认证参数(异步获取 // 添加认证参数(已有则不覆盖,便于目录树透传同一份 auth
const authParam = await this.generateAuthParam() if (!params.auth) {
if (authParam) { const authParam = await this.generateAuthParam()
params.auth = authParam if (authParam) {
params.auth = authParam
}
} }
const response = await axios.get(`${this.baseAPI}${endpoint}`, { params }) const response = await axios.get(`${this.baseAPI}${endpoint}`, { params })
@@ -1267,11 +1305,13 @@ export default {
// 更新智能直链(包含认证参数) // 更新智能直链(包含认证参数)
this.updateDirectLink() this.updateDirectLink()
// 如果需要下载器(含特殊头),弹出下载器对话框 // 如果需要下载器(含特殊头),弹出下载器对话框
if (result.data?.needDownloader) { const needDownloader = !!(result.data?.needDownloader || otherParam.needDownloader
|| otherParam.downloadHeaders?.cookie || otherParam.downloadHeaders?.Cookie)
if (needDownloader) {
this.downloadDialogInfo = { this.downloadDialogInfo = {
downloadUrl: result.data.directLink, downloadUrl: result.data.directLink,
fileName: result.data.fileName || '', fileName: result.data.fileInfo?.fileName || result.data.fileName || '',
downloadHeaders: result.data.downloadHeaders || {}, downloadHeaders: result.data.downloadHeaders || otherParam.downloadHeaders || {},
aria2Command: this.aria2Command, aria2Command: this.aria2Command,
curlCommand: this.curlCommand, curlCommand: this.curlCommand,
aria2JsonRpc: this.aria2JsonRpc, aria2JsonRpc: this.aria2JsonRpc,
@@ -1291,6 +1331,11 @@ export default {
this.validateInput() this.validateInput()
const params = { url: this.link } const params = { url: this.link }
if (this.password) params.pwd = this.password if (this.password) params.pwd = this.password
// 预先生成 auth,既给本次请求用,也透传给 DirectoryTree 子目录/下载
this.directoryAuth = await this.generateAuthParam()
if (this.directoryAuth) {
params.auth = this.directoryAuth
}
// 直接调用 getFileList,让后端返回错误(不做客户端类型检查) // 直接调用 getFileList,让后端返回错误(不做客户端类型检查)
const directoryResult = await this.callAPI('/v2/getFileList', params) const directoryResult = await this.callAPI('/v2/getFileList', params)
@@ -1389,7 +1434,7 @@ export default {
// 获取剪切板内容 // 获取剪切板内容
async getPaste(isManual = false) { async getPaste(isManual = false) {
try { try {
const text = await navigator.clipboard.readText() const text = (await navigator.clipboard.readText() || '').trim()
const shortInfo = this.expandShortFormat(text) const shortInfo = this.expandShortFormat(text)
if (shortInfo) { if (shortInfo) {
+12 -1
View File
@@ -30,6 +30,16 @@
<groupId>cn.qaiu</groupId> <groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId> <artifactId>parser</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
@@ -81,7 +91,8 @@
${project.basedir}/src/main/generated ${project.basedir}/src/main/generated
</generatedSourcesDirectory> </generatedSourcesDirectory>
<compilerArgs> <compilerArgs>
<arg>-AoutputDirectory=${project.basedir}/src/main -Xlint:unchecked</arg> <arg>-AoutputDirectory=${project.basedir}/src/main</arg>
<arg>-Xlint:unchecked</arg>
</compilerArgs> </compilerArgs>
</configuration> </configuration>
</plugin> </plugin>
@@ -22,6 +22,8 @@ public class ParserAuthUtil {
public static final String SKIP_CLIENT_LINKS = "_skipClientLinks"; public static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
public static final String TEMP_AUTH_ADDED = "__TEMP_AUTH_ADDED"; public static final String TEMP_AUTH_ADDED = "__TEMP_AUTH_ADDED";
public static final String DONATED_ACCOUNT_TOKEN = "__AUTO_DONATED_ACCOUNT_TOKEN"; public static final String DONATED_ACCOUNT_TOKEN = "__AUTO_DONATED_ACCOUNT_TOKEN";
/** 原始加密 auth 查询串,供目录子链透传(避免进入子目录丢失认证) */
public static final String AUTH_QUERY = "_authQuery";
private ParserAuthUtil() { private ParserAuthUtil() {
} }
@@ -41,6 +43,8 @@ public class ParserAuthUtil {
} }
if (StringUtils.isNotBlank(auth)) { if (StringUtils.isNotBlank(auth)) {
// 保留原始 auth,供 getFileList 子目录 parserUrl 透传
otherParam.put(AUTH_QUERY, auth);
AuthParam authParam = AuthParamCodec.decode(auth); AuthParam authParam = AuthParamCodec.decode(auth);
if (authParam != null && authParam.hasValidAuth()) { if (authParam != null && authParam.hasValidAuth()) {
otherParam.put("authType", authParam.getAuthType()); otherParam.put("authType", authParam.getAuthType());
@@ -68,9 +68,10 @@ public class URLParamUtil {
boolean firstParam = !decodedUrl.contains("?"); boolean firstParam = !decodedUrl.contains("?");
for (String paramName : params.names()) { for (String paramName : params.names()) {
// 忽略 "url", "pwd", "dirId", "uuid", "auth", "stoken" 参数(这些参数单独处理,不应拼接到分享URL中 // 忽略单独处理的参数,不应拼接到分享URL中
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId") if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
&& !paramName.equals("uuid") && !paramName.equals("auth") && !paramName.equals("stoken")) { && !paramName.equals("uuid") && !paramName.equals("auth")
&& !paramName.equals("stoken") && !paramName.equals("zml")) {
if (firstParam) { if (firstParam) {
urlBuilder.append("?"); urlBuilder.append("?");
firstParam = false; firstParam = false;
@@ -16,7 +16,9 @@ import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.parser.PanDomainTemplate; import cn.qaiu.parser.PanDomainTemplate;
import cn.qaiu.parser.IPanTool; import cn.qaiu.parser.IPanTool;
import cn.qaiu.parser.ParserCreate; import cn.qaiu.parser.ParserCreate;
import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory;
import cn.qaiu.parser.clientlink.ClientLinkType; import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.vx.core.annotaions.RouteHandler; import cn.qaiu.vx.core.annotaions.RouteHandler;
import cn.qaiu.vx.core.annotaions.RouteMapping; import cn.qaiu.vx.core.annotaions.RouteMapping;
import cn.qaiu.vx.core.enums.RouteMethod; import cn.qaiu.vx.core.enums.RouteMethod;
@@ -161,7 +163,7 @@ public class ParserApi {
@RouteMapping("/getFileList") @RouteMapping("/getFileList")
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid, public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid,
String stoken, String auth) { String stoken, String zml, String auth) {
String url = URLParamUtil.parserParams(request); String url = URLParamUtil.parserParams(request);
ParserCreate parserCreate; ParserCreate parserCreate;
try { try {
@@ -182,6 +184,9 @@ public class ParserApi {
if (StringUtils.isNotBlank(uuid)) { if (StringUtils.isNotBlank(uuid)) {
parserCreate.getShareLinkInfo().getOtherParam().put("uuid", uuid); parserCreate.getShareLinkInfo().getOtherParam().put("uuid", uuid);
} }
if (StringUtils.isNotBlank(zml)) {
parserCreate.getShareLinkInfo().getOtherParam().put("zml", zml);
}
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService) return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
.compose(v -> { .compose(v -> {
URLParamUtil.addParam(parserCreate); URLParamUtil.addParam(parserCreate);
@@ -207,7 +212,14 @@ public class ParserApi {
return promise.future(); return promise.future();
} }
String paramStr = new String(Base64.getDecoder().decode(param)); final String paramStr;
try {
paramStr = CommonUtils.urlBase64Decode(param);
} catch (Exception e) {
Promise<String> promise = Promise.promise();
promise.fail("下载参数解码失败: " + e.getMessage());
return promise.future();
}
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo(); ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
shareLinkInfo.getOtherParam().put("paramJson", new JsonObject(paramStr)); shareLinkInfo.getOtherParam().put("paramJson", new JsonObject(paramStr));
@@ -242,6 +254,131 @@ public class ParserApi {
return promise.future(); return promise.future();
} }
/**
* 目录文件下载信息(供前端下载器使用):返回直链、请求头及命令行
*/
@RouteMapping("/getFileDownInfo/:type/:param")
public Future<JsonObject> getFileDownInfo(HttpServerRequest request, String type, String param, String auth) {
ParserCreate parserCreate;
try {
parserCreate = ParserCreate.fromType(type).shareKey("-").setShareLinkInfoPwd("-");
} catch (Exception e) {
return Future.failedFuture(e);
}
if (param == null || param.isEmpty()) {
return Future.failedFuture("下载参数为空");
}
final JsonObject paramJson;
try {
paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
} catch (Exception e) {
return Future.failedFuture("下载参数解码失败: " + e.getMessage());
}
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
shareLinkInfo.getOtherParam().put("paramJson", paramJson);
String linkPrefix = getLinkPrefix(request);
JsonObject otherParam = ParserAuthUtil.buildOtherParam(request, auth, linkPrefix);
shareLinkInfo.getOtherParam().put("domainName", linkPrefix);
shareLinkInfo.getOtherParam().put("_requestOrigin", linkPrefix);
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
.compose(v -> {
URLParamUtil.addParam(parserCreate);
IPanTool tool = parserCreate.createTool();
return IPanTool.closeAfter(tool, tool::parseById)
.onFailure(t -> {
ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t);
ParserAuthUtil.recordAutoDonatedFailureIfNeeded(dbService,
parserCreate.getShareLinkInfo(), t);
})
.map(downloadUrl -> buildFileDownInfo(shareLinkInfo, paramJson, downloadUrl));
});
}
@SuppressWarnings("unchecked")
private static JsonObject buildFileDownInfo(ShareLinkInfo shareLinkInfo, JsonObject paramJson, String downloadUrl) {
Map<String, String> downloadHeaders = new HashMap<>();
// 入口参数里可能已带 cookie(目录解析时写入),先作为底稿
mergeDownloadHeaders(downloadHeaders, paramJson.getJsonObject("downloadHeaders"));
// 解析器运行时生成的请求头优先覆盖(如刷新后的 cookie),但跳过 null
Object headersObj = shareLinkInfo.getOtherParam().get("downloadHeaders");
if (headersObj instanceof Map) {
mergeDownloadHeaders(downloadHeaders, (Map<?, ?>) headersObj);
}
String fileName = paramJson.getString("fileName", "");
if (StringUtils.isBlank(fileName)) {
Object fn = shareLinkInfo.getOtherParam().get("fileName");
if (fn != null) {
fileName = fn.toString();
}
}
boolean needDownloader = Boolean.TRUE.equals(paramJson.getBoolean("needDownloader"))
|| !downloadHeaders.isEmpty();
shareLinkInfo.getOtherParam().put("downloadUrl", downloadUrl);
if (!downloadHeaders.isEmpty()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
}
JsonObject result = new JsonObject()
.put("downloadUrl", downloadUrl)
.put("fileName", fileName)
.put("needDownloader", needDownloader)
.put("downloadHeaders", downloadHeaders);
try {
Map<ClientLinkType, String> clientLinks = ClientLinkGeneratorFactory.generateAll(shareLinkInfo);
if (clientLinks.containsKey(ClientLinkType.CURL)) {
result.put("curlCommand", clientLinks.get(ClientLinkType.CURL));
}
if (clientLinks.containsKey(ClientLinkType.ARIA2)) {
result.put("aria2Command", clientLinks.get(ClientLinkType.ARIA2));
}
if (clientLinks.containsKey(ClientLinkType.THUNDER)) {
result.put("thunderLink", clientLinks.get(ClientLinkType.THUNDER));
}
} catch (Exception e) {
log.warn("生成下载命令失败: {}", e.getMessage());
}
return result;
}
/**
* 合并下载请求头,忽略 null/空值,避免运行时 null 覆盖入口参数中的 cookie。
*/
private static void mergeDownloadHeaders(Map<String, String> target, JsonObject source) {
if (source == null || source.isEmpty()) {
return;
}
for (String key : source.fieldNames()) {
Object val = source.getValue(key);
if (val != null && StringUtils.isNotBlank(val.toString())) {
target.put(key, val.toString());
}
}
}
private static void mergeDownloadHeaders(Map<String, String> target, Map<?, ?> source) {
if (source == null || source.isEmpty()) {
return;
}
for (Map.Entry<?, ?> e : source.entrySet()) {
if (e.getKey() == null || e.getValue() == null) {
continue;
}
String val = e.getValue().toString();
if (StringUtils.isNotBlank(val)) {
target.put(e.getKey().toString(), val);
}
}
}
/** /**
* 预览媒体文件 * 预览媒体文件
@@ -326,11 +463,14 @@ public class ParserApi {
// 获取版本号 // 获取版本号
@RouteMapping("/build-version") @RouteMapping("/build-version")
public String getVersion() { public String getVersion() {
return CommonUtil.getAppVersion() String version = CommonUtil.getAppVersion();
if (version == null || version.isBlank()) {
return "unknown";
}
return version
.replace("-", "") .replace("-", "")
.replace("Z", "") .replace("Z", "")
.replace("T", "_") .replace("T", "_")
.replace("-", "")
.replace(":", ""); .replace(":", "");
} }
@@ -186,6 +186,11 @@ public class CacheServiceImpl implements CacheService {
// 传递 downloadHeaders 到两个对象 // 传递 downloadHeaders 到两个对象
cacheLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders); cacheLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
result.getOtherParam().put("downloadHeaders", downloadHeaders); result.getOtherParam().put("downloadHeaders", downloadHeaders);
// 有特殊下载头时标记需要下载器(浏览器无法带 cookie 直连)
if (!downloadHeaders.isEmpty()) {
cacheLinkInfo.getOtherParam().put("needDownloader", true);
result.getOtherParam().put("needDownloader", true);
}
// 使用已有的工具类生成下载命令 // 使用已有的工具类生成下载命令
generateCommandsFromShareLinkInfo(shareLinkInfo, cacheLinkInfo, result); generateCommandsFromShareLinkInfo(shareLinkInfo, cacheLinkInfo, result);