mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-10-31 10:52:57 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd092e50dd | ||
|
|
12d0f6bfbb | ||
|
|
9d2fd09331 | ||
|
|
e9be9b3345 | ||
|
|
6acb774c87 | ||
|
|
511515cae4 | ||
|
|
1ab9df7826 | ||
|
|
ab8b687837 | ||
|
|
7616bf3144 | ||
|
|
01d7956de5 | ||
|
|
78e53c076e | ||
|
|
4d985cc96f | ||
|
|
13ccdb8adf | ||
|
|
ac1afcd35f | ||
|
|
0c678a4124 | ||
|
|
f77800602d | ||
|
|
79ab829952 | ||
|
|
2181ef38df | ||
|
|
e09c9f76a7 | ||
|
|
7adb938357 | ||
|
|
efb800d88e | ||
|
|
422d5b845b | ||
|
|
054e9cc1ec | ||
|
|
f3da45bb16 | ||
|
|
273dbae5e7 | ||
|
|
e103003b7a | ||
|
|
b5f7d9694b | ||
|
|
f75d9f687b | ||
|
|
703bfa3061 | ||
|
|
6c709fde65 | ||
|
|
449fd741ac | ||
|
|
c18b8d80e1 | ||
|
|
06ba0aff52 | ||
|
|
74e401d99c | ||
|
|
4995bca901 | ||
|
|
7299fd8762 | ||
|
|
efbadde4ed |
@@ -8,7 +8,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- 'v*'
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- '*'
|
- '*'
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ QQ交流群:1017480890
|
|||||||

|

|
||||||
|
|
||||||
|
|
||||||
|
> 本平台所有音乐解析工具不再维护,请移步[Sonder播放器](https://sodecode.qaiu.top)
|
||||||
|
> 需要百度,迅雷,UC,夸克的完整支持请使用[专业版](https://189.qaiu.top)
|
||||||
|
|
||||||
## 介绍
|
## 介绍
|
||||||
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/永硕E盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/永硕E盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||||
@@ -28,25 +30,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)
|
||||||
@@ -69,7 +77,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
|||||||
- [移动云云空间-ec](https://www.ecpan.cn/web)
|
- [移动云云空间-ec](https://www.ecpan.cn/web)
|
||||||
- [小飞机网盘-fj](https://www.feijipan.com/)
|
- [小飞机网盘-fj](https://www.feijipan.com/)
|
||||||
- [亿方云-fc](https://www.fangcloud.com/)
|
- [亿方云-fc](https://www.fangcloud.com/)
|
||||||
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用,需登录认证,公共/云端服务器风控严格建议自行部署(Windows 可直接用 run.bat 一键运行)
|
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用。公共云解析没有共享 123 账号;下载返回 5112 / 请登录后下载时,在本机配置 `auths.ye.authorization`(Windows 可直接用 run.bat)
|
||||||
- ~[115网盘(失效)-p115](https://115.com/)~
|
- ~[115网盘(失效)-p115](https://115.com/)~
|
||||||
- [文叔叔-ws](https://www.wenshushu.cn/)
|
- [文叔叔-ws](https://www.wenshushu.cn/)
|
||||||
- [联想乐云-le](https://lecloud.lenovo.com/)
|
- [联想乐云-le](https://lecloud.lenovo.com/)
|
||||||
@@ -93,7 +101,9 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
|||||||
- Dropbox-pdp
|
- Dropbox-pdp
|
||||||
- iCloud-pic
|
- iCloud-pic
|
||||||
### 专业版提供
|
### 专业版提供
|
||||||
- 迅雷网盘-xl
|
- 百度网盘开放平台-bd
|
||||||
|
- 迅雷云盘开放平台-xl
|
||||||
|
- [光鸭云盘开放平台-gy](https://www.guangyapan.com)
|
||||||
- [夸克云盘-qk](https://pan.quark.cn/)
|
- [夸克云盘-qk](https://pan.quark.cn/)
|
||||||
- [UC云盘-uc](https://fast.uc.cn/)
|
- [UC云盘-uc](https://fast.uc.cn/)
|
||||||
- [移动云盘-p139](https://yun.139.com/)
|
- [移动云盘-p139](https://yun.139.com/)
|
||||||
@@ -133,6 +143,7 @@ GET /json/parser?url={分享链接}&pwd={密码}
|
|||||||
```
|
```
|
||||||
GET /json/{网盘标识}/{分享key}@{密码}
|
GET /json/{网盘标识}/{分享key}@{密码}
|
||||||
```
|
```
|
||||||
|
注意: 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
|
||||||
|
|
||||||
#### 3. 文件夹解析(v0.1.8fixed3+)
|
#### 3. 文件夹解析(v0.1.8fixed3+)
|
||||||
|
|
||||||
@@ -149,7 +160,7 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
|
|||||||
|
|
||||||
### 认证参数(v0.2.1+)
|
### 认证参数(v0.2.1+)
|
||||||
[可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
[可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
||||||
部分网盘(如夸克、UC)需要登录后的 Cookie 才能解析和下载。可通过 `auth` 参数传递认证信息:
|
部分网盘需要登录后的 Cookie 或 Authorization 才能下载。可通过 `auth` 参数传递认证信息:
|
||||||
|
|
||||||
**参数格式**:`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码
|
**参数格式**:`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码
|
||||||
|
|
||||||
@@ -173,11 +184,11 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
|
|||||||
**网盘认证要求**:
|
**网盘认证要求**:
|
||||||
| 网盘 | 认证要求 | 说明 |
|
| 网盘 | 认证要求 | 说明 |
|
||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| 夸克网盘(QK) | **必须** | 必须配置 Cookie 才能解析 |
|
| 夸克网盘(QK) | 小文件可免登录 | 小文件走访客直链。文件夹会先列目录再进入。超过访客大小限制(上游 23018,本次核对约 90MB 失败、约 12MB–27MB 成功)必须配置 `auths.qk.cookie` 或请求 `auth`。未配置 Cookie 时不会转存 |
|
||||||
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
|
| UC网盘(UC) | **必须** | 不能免登录,必须配置 Cookie |
|
||||||
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
|
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
|
||||||
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
|
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
|
||||||
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
|
| 123网盘(YE) | 下载通常需要 | 分享列表经常可以不登录读取。下载接口常见 5112 / 请登录后下载 / 您需要注册登录或付费后下载,请在**本机**配置 `auths.ye.authorization`(与 `token` 等价)或账号密码。公共云解析没有共享 123 账号。约 10MB 小文件有时可以直链,大文件和风控必须用你自己的账号。短链:`ye:分享key@提取码`;`#` 片段会去掉;`yun.123pan.com`、`www.123pan.com`、`*.share.123pan.cn` 同一类 |
|
||||||
|
|
||||||
**使用示例**:
|
**使用示例**:
|
||||||
```
|
```
|
||||||
@@ -227,14 +238,13 @@ auths:
|
|||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ 注意:YAML 中 key 后面不写值(如 `authorization:` 空着)等同于没配置,不会生效,必须填入真实的账号密码或 token 内容。
|
> ⚠️ 注意:YAML 中 key 后面不写值(如 `authorization:` 空着)等同于没配置,不会生效,必须填入真实的账号密码或 token 内容。
|
||||||
|
>
|
||||||
|
> 若 123 云盘账号密码登录触发验证码或风控,解析会直接失败并提示改用 Authorization,不会尝试验证码。请改填上面的 `authorization` / `token`(浏览器 localStorage 的 `authorToken`,或请求头 `authorization` / Cookie `sso-token` 里的 JWT)。
|
||||||
|
>
|
||||||
|
> 公共解析站(例如别人搭的在线解析)不能替你登录 123。看到「请登录后下载」「5112」「您需要注册登录或付费后下载」时,把服务跑在自己的机器上并填入自己的 Authorization。这是 123 的登录和风控要求。链接写法:`/d/ye/分享key@提取码`;浏览器地址栏里 `#` 后面的片段不参与解析;`yun.123pan.com` 与 `www.123pan.com`、`数字.share.123pan.cn` 是同一种分享。
|
||||||
|
|
||||||
如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。
|
如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。
|
||||||
|
|
||||||
### 特殊说明
|
|
||||||
|
|
||||||
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
|
|
||||||
- 移动云云空间、小飞机网盘的加密分享可忽略密码参数
|
|
||||||
|
|
||||||
### 示例
|
### 示例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -369,14 +379,19 @@ json返回数据格式示例:
|
|||||||
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
|
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
|
||||||
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
|
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
|
||||||
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
|
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
|
||||||
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
|
| 123云盘 | 小文件有时可以 | √ | 2T | 大文件/风控需要本机登录 |
|
||||||
| 文叔叔 | √ | √ | 10G | 5GB |
|
| 文叔叔 | √ | √ | 10G | 5GB |
|
||||||
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
|
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
|
||||||
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
||||||
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
| 夸克网盘 | 小文件 | √ | 10G | 访客有大小限制(23018) |
|
||||||
| UC网盘 | x | √ | 10G | 不限大小 |
|
| UC网盘 | x | √ | 10G | 不限大小 |
|
||||||
| 飞书云盘 | √ | X | 15G | 不限大小 |
|
| 飞书云盘 | √ | X | 15G | 不限大小 |
|
||||||
|
|
||||||
|
免登录边界(阈值由网盘决定,不是本项目写死的):
|
||||||
|
|
||||||
|
- **夸克**:小文件可以不登录。本次核对约 12MB–27MB 的访客直链成功,约 90MB 返回 `23018`。分享根目录如果只是文件夹,解析会继续进入子目录。超过限制时配置 `auths.qk.cookie`。UC 不能免登录。百度大约 100MB 以内有直链,属于专业版,这次没有改解析器。
|
||||||
|
- **123**:分享目录经常可以不登录看。下载则经常要登录。请本机配置 Authorization,不要依赖公共云解析的共享账号。约 10MB 的小文件有时能直链。
|
||||||
|
|
||||||
# 打包部署
|
# 打包部署
|
||||||
|
|
||||||
## JDK下载(lz.qaiu.top提供直链云解析服务)
|
## JDK下载(lz.qaiu.top提供直链云解析服务)
|
||||||
@@ -455,8 +470,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.8/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
|
||||||
```
|
```
|
||||||
@@ -512,7 +527,7 @@ nfd-proxy搭建http代理服务器
|
|||||||
参考https://github.com/nfd-parser/nfd-proxy
|
参考https://github.com/nfd-parser/nfd-proxy
|
||||||
|
|
||||||
### 认证信息配置说明
|
### 认证信息配置说明
|
||||||
部分网盘(如123)解析大文件时需要登录认证,可以在配置文件中添加认证信息。
|
夸克小文件可以不登录;大文件要本机 Cookie。123 的下载在公共解析站上基本都要你自己的 Authorization,云端没有共用账号。
|
||||||
|
|
||||||
修改配置文件:
|
修改配置文件:
|
||||||
app-dev.yml
|
app-dev.yml
|
||||||
@@ -520,14 +535,16 @@ app-dev.yml
|
|||||||
```yaml
|
```yaml
|
||||||
### 解析认证相关
|
### 解析认证相关
|
||||||
auths:
|
auths:
|
||||||
# 123:配置用户名密码
|
# 夸克大文件:auths.qk.cookie
|
||||||
|
# qk:
|
||||||
|
# cookie: 浏览器里夸克网盘的 Cookie
|
||||||
|
# 123:优先填 authorization。账号密码若触发验证码会失败,本项目不处理验证码。
|
||||||
ye:
|
ye:
|
||||||
username: 你的用户名
|
username: 你的用户名
|
||||||
password: 你的密码
|
password: 你的密码
|
||||||
|
# authorization: 浏览器登录后的 authorToken / sso-token
|
||||||
```
|
```
|
||||||
|
|
||||||
**注意:** 目前仅支持 123(ye)的认证配置。
|
|
||||||
|
|
||||||
|
|
||||||
**技术栈:**
|
**技术栈:**
|
||||||
Jdk17+Vert.x4
|
Jdk17+Vert.x4
|
||||||
@@ -544,11 +561,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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
### 认证参数说明(v0.2.1+)
|
### 认证参数说明(v0.2.1+)
|
||||||
|
|
||||||
部分网盘(如夸克QK、UC网盘)需要登录后的 Cookie 才能解析。`auth` 参数用于传递认证信息:
|
部分网盘需要登录后的 Cookie 或 Authorization 才能下载。夸克小文件可以不登录;UC 必须有 Cookie。`auth` 参数用于传递认证信息:
|
||||||
|
|
||||||
**加密方式**:
|
**加密方式**:
|
||||||
- 算法:AES/ECB/PKCS5Padding
|
- 算法:AES/ECB/PKCS5Padding
|
||||||
@@ -42,8 +42,9 @@
|
|||||||
**网盘认证要求**:
|
**网盘认证要求**:
|
||||||
| 网盘 | 认证要求 |
|
| 网盘 | 认证要求 |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| 夸克网盘(QK) | **必须** |
|
| 夸克网盘(QK) | 小文件可免登录;超过访客大小限制(23018)需要 Cookie |
|
||||||
| UC网盘(UC) | **必须** |
|
| UC网盘(UC) | **必须** |
|
||||||
|
| 123网盘(YE) | 下载通常需要本机 `auths.ye.authorization`;公共云解析没有共享账号 |
|
||||||
| 小飞机网盘(FJ) | 大文件需要 |
|
| 小飞机网盘(FJ) | 大文件需要 |
|
||||||
| 蓝奏优享(IZ) | 大文件需要 |
|
| 蓝奏优享(IZ) | 大文件需要 |
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ URL解码 → Base64解码 → AES解密 → JSON对象
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 123云盘(`ye`)账号密码登录如果返回验证码或风控,解析会直接失败,并提示改用 Authorization,不会尝试验证码。请在浏览器登录后复制 `authorToken` / 请求头 `authorization` / Cookie `sso-token`,用 `authType=authorization` 或 `accesstoken` 传入,或写入 `app-dev.yml` 的 `auths.ye.authorization`。
|
||||||
|
|
||||||
#### 4. 自定义认证
|
#### 4. 自定义认证
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -339,3 +341,4 @@ server:
|
|||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
|
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
|
||||||
|
- **2026-09-27**: 补充 123云盘账号密码登录触发验证码/风控时改用 Authorization 的说明
|
||||||
|
|||||||
@@ -57,6 +57,8 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 123云盘(`ye`)账号密码登录触发验证码/风控时,请改用 `authType=authorization` 或 `accesstoken`(`token` 填浏览器中的 Bearer token / JWT),或在 `app-dev.yml` 配置 `auths.ye.authorization`。解析不会尝试验证码。
|
||||||
|
|
||||||
### 自定义认证
|
### 自定义认证
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -232,7 +232,7 @@ public enum PanDomainTemplate {
|
|||||||
"123635\\.com|" +
|
"123635\\.com|" +
|
||||||
"123242\\.com|" +
|
"123242\\.com|" +
|
||||||
"123795\\.com" +
|
"123795\\.com" +
|
||||||
")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?<KEY>[a-zA-Z0-9]+-[a-zA-Z0-9]+|[a-zA-Z0-9_-]+)(?:\\.html)?(?:\\?.*)?"),
|
")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?<KEY>[a-zA-Z0-9]+-[a-zA-Z0-9]+|[a-zA-Z0-9_-]+)(?:\\.html)?(?:[?#].*)?"),
|
||||||
"https://www.123pan.com/s/{shareKey}",
|
"https://www.123pan.com/s/{shareKey}",
|
||||||
Ye2Tool.class),
|
Ye2Tool.class),
|
||||||
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
|
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package cn.qaiu.parser;
|
||||||
|
|
||||||
|
import io.vertx.core.json.JsonArray;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号密码登录撞上验证码/风控时的用户提示。
|
||||||
|
* 只识别失败形态并引导改用 Authorization,不实现验证码求解、扫码或重试。
|
||||||
|
*/
|
||||||
|
public final class PasswordLoginRisk {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 123 云盘(ye)账号密码登录被验证码/风控拦住时返回给调用方的固定说明。
|
||||||
|
* 上游 code/message 会另附在这句话后面,便于和“密码错误”区分。
|
||||||
|
*/
|
||||||
|
public static final String AUTHORIZATION_HINT =
|
||||||
|
"账号密码登录触发了验证码/风控,已停止用户名密码登录(不会尝试验证码)。"
|
||||||
|
+ "请改为配置 Authorization(浏览器登录后的 Bearer token / JWT,"
|
||||||
|
+ "例如 localStorage 的 authorToken、请求头 authorization 或 Cookie 的 sso-token)。"
|
||||||
|
+ "静态配置写在 app-dev.yml 的 auths.ye.authorization(与 auths.ye.token 等价);"
|
||||||
|
+ "临时请求使用 authType=authorization 或 authType=accesstoken,token 填该令牌(不要重复带 Bearer 前缀)。"
|
||||||
|
+ "说明见 parser/doc/auth-param/。";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上游文案命中这些片段时视为验证码/风控,而不是单纯的密码错误。
|
||||||
|
* 依据公开客户端(AList/OpenList)实际展示过的 123 登录失败文案,以及登录页上的滑块/人机验证提示。
|
||||||
|
*/
|
||||||
|
private static final String[] RISK_KEYWORDS = {
|
||||||
|
"验证码",
|
||||||
|
"风控",
|
||||||
|
"安全风险",
|
||||||
|
"安全验证",
|
||||||
|
"安全校验",
|
||||||
|
"滑块",
|
||||||
|
"滑动验证",
|
||||||
|
"拼图验证",
|
||||||
|
"短信验证",
|
||||||
|
"微信登录",
|
||||||
|
"微信进行登录",
|
||||||
|
"境外登录",
|
||||||
|
"需要验证",
|
||||||
|
"请验证",
|
||||||
|
"请你验证",
|
||||||
|
"进行验证",
|
||||||
|
"完成验证",
|
||||||
|
"二次验证",
|
||||||
|
"人机验证",
|
||||||
|
"操作频繁",
|
||||||
|
"请求过于频繁",
|
||||||
|
"请求频繁",
|
||||||
|
"captcha",
|
||||||
|
"recaptcha",
|
||||||
|
"risk control"
|
||||||
|
};
|
||||||
|
|
||||||
|
private PasswordLoginRisk() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isLoginSuccess(JsonObject json) {
|
||||||
|
if (json == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Integer code = readCode(json);
|
||||||
|
return code != null && code == 200 && StringUtils.isNotBlank(readToken(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean indicatesCaptchaOrRisk(JsonObject json) {
|
||||||
|
if (json == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasTruthyRiskField(json) || hasTruthyRiskField(dataObject(json))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return textIndicatesCaptchaOrRisk(collectMessageText(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean textIndicatesCaptchaOrRisk(String text) {
|
||||||
|
if (StringUtils.isBlank(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String lower = text.toLowerCase(Locale.ROOT);
|
||||||
|
for (String keyword : RISK_KEYWORDS) {
|
||||||
|
if (lower.contains(keyword.toLowerCase(Locale.ROOT))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非成功登录响应的用户可见说明。验证码/风控走 Authorization 引导,其余保持“登录失败: 上游文案”。
|
||||||
|
*/
|
||||||
|
public static String describeLoginFailure(JsonObject json) {
|
||||||
|
if (json == null) {
|
||||||
|
return "登录响应格式异常";
|
||||||
|
}
|
||||||
|
if (indicatesCaptchaOrRisk(json)) {
|
||||||
|
return AUTHORIZATION_HINT + " 上游返回:" + upstreamSummary(json);
|
||||||
|
}
|
||||||
|
Integer code = readCode(json);
|
||||||
|
if (code != null && code == 200) {
|
||||||
|
return "未获取到token";
|
||||||
|
}
|
||||||
|
String message = firstMessage(json);
|
||||||
|
if (StringUtils.isBlank(message)) {
|
||||||
|
return "登录失败";
|
||||||
|
}
|
||||||
|
return "登录失败: " + message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String nonJsonLoginFailure(String body) {
|
||||||
|
if (textIndicatesCaptchaOrRisk(body)) {
|
||||||
|
return AUTHORIZATION_HINT + " 上游返回:" + truncate(body, 300);
|
||||||
|
}
|
||||||
|
return "登录响应格式异常: " + body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String collectMessageText(JsonObject json) {
|
||||||
|
StringBuilder text = new StringBuilder();
|
||||||
|
appendMessage(text, json);
|
||||||
|
JsonObject data = dataObject(json);
|
||||||
|
if (data != null) {
|
||||||
|
appendMessage(text, data);
|
||||||
|
}
|
||||||
|
return text.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendMessage(StringBuilder text, JsonObject json) {
|
||||||
|
append(text, json.getString("message"));
|
||||||
|
append(text, json.getString("msg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void append(StringBuilder text, String value) {
|
||||||
|
if (StringUtils.isNotBlank(value)) {
|
||||||
|
if (text.length() > 0) {
|
||||||
|
text.append(' ');
|
||||||
|
}
|
||||||
|
text.append(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasTruthyRiskField(JsonObject obj) {
|
||||||
|
if (obj == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String key : obj.fieldNames()) {
|
||||||
|
if (!isRiskFieldName(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isTruthyRiskValue(obj.getValue(key))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isRiskFieldName(String key) {
|
||||||
|
String lower = key.toLowerCase(Locale.ROOT);
|
||||||
|
return lower.contains("captcha")
|
||||||
|
|| lower.contains("vcode")
|
||||||
|
|| lower.contains("risk")
|
||||||
|
|| "needverify".equals(lower)
|
||||||
|
|| "need_verify".equals(lower)
|
||||||
|
|| "slideverify".equals(lower)
|
||||||
|
|| "verifycode".equals(lower)
|
||||||
|
|| "verify_code".equals(lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isTruthyRiskValue(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value instanceof Boolean) {
|
||||||
|
return (Boolean) value;
|
||||||
|
}
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return ((Number) value).doubleValue() != 0d;
|
||||||
|
}
|
||||||
|
if (value instanceof CharSequence) {
|
||||||
|
String text = value.toString().trim();
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String lower = text.toLowerCase(Locale.ROOT);
|
||||||
|
return !"false".equals(lower) && !"0".equals(lower) && !"null".equals(lower) && !"none".equals(lower);
|
||||||
|
}
|
||||||
|
if (value instanceof JsonObject) {
|
||||||
|
return !((JsonObject) value).isEmpty();
|
||||||
|
}
|
||||||
|
if (value instanceof JsonArray) {
|
||||||
|
return !((JsonArray) value).isEmpty();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String upstreamSummary(JsonObject json) {
|
||||||
|
StringBuilder summary = new StringBuilder();
|
||||||
|
Integer code = readCode(json);
|
||||||
|
if (code != null) {
|
||||||
|
summary.append("code=").append(code);
|
||||||
|
}
|
||||||
|
String message = firstMessage(json);
|
||||||
|
if (StringUtils.isNotBlank(message)) {
|
||||||
|
if (summary.length() > 0) {
|
||||||
|
summary.append(", ");
|
||||||
|
}
|
||||||
|
summary.append("message=").append(message);
|
||||||
|
}
|
||||||
|
if (summary.length() == 0) {
|
||||||
|
summary.append(json.encode());
|
||||||
|
}
|
||||||
|
return truncate(summary.toString(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstMessage(JsonObject json) {
|
||||||
|
String message = json.getString("message");
|
||||||
|
if (StringUtils.isBlank(message)) {
|
||||||
|
message = json.getString("msg");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(message)) {
|
||||||
|
JsonObject data = dataObject(json);
|
||||||
|
if (data != null) {
|
||||||
|
message = data.getString("message");
|
||||||
|
if (StringUtils.isBlank(message)) {
|
||||||
|
message = data.getString("msg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String readToken(JsonObject json) {
|
||||||
|
JsonObject data = dataObject(json);
|
||||||
|
if (data == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object token = data.getValue("token");
|
||||||
|
return token == null ? null : token.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject dataObject(JsonObject json) {
|
||||||
|
Object data = json.getValue("data");
|
||||||
|
return data instanceof JsonObject ? (JsonObject) data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Integer readCode(JsonObject json) {
|
||||||
|
Object code = json.getValue("code");
|
||||||
|
if (code instanceof Number) {
|
||||||
|
return ((Number) code).intValue();
|
||||||
|
}
|
||||||
|
if (code instanceof String && StringUtils.isNumeric(((String) code).trim())) {
|
||||||
|
return Integer.parseInt(((String) code).trim());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String value, int max) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
if (trimmed.length() <= max) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed.substring(0, max) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cn.qaiu.parser.impl;
|
|||||||
import cn.qaiu.entity.FileInfo;
|
import cn.qaiu.entity.FileInfo;
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
import cn.qaiu.parser.PanBase;
|
import cn.qaiu.parser.PanBase;
|
||||||
|
import cn.qaiu.parser.TokenCache;
|
||||||
import cn.qaiu.util.AESUtils;
|
import cn.qaiu.util.AESUtils;
|
||||||
import cn.qaiu.util.AcwScV2Generator;
|
import cn.qaiu.util.AcwScV2Generator;
|
||||||
import cn.qaiu.util.CommonUtils;
|
import cn.qaiu.util.CommonUtils;
|
||||||
@@ -85,12 +86,31 @@ public class IzTool extends PanBase {
|
|||||||
}
|
}
|
||||||
public IzTool(ShareLinkInfo shareLinkInfo) {
|
public IzTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
|
IzXffHeader.apply(header);
|
||||||
|
String accountId = "_default";
|
||||||
|
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||||
|
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||||
|
if (auths.contains("_configId")) {
|
||||||
|
accountId = auths.get("_configId");
|
||||||
|
} else if (auths.contains("username")) {
|
||||||
|
accountId = auths.get("username");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cacheKey = TokenCache.key("iz", accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
||||||
|
|
||||||
public static volatile String token = null;
|
// 多账号隔离:用 TokenCache 代替 static 变量
|
||||||
public static volatile boolean authFlag = true;
|
private final String cacheKey;
|
||||||
|
|
||||||
|
private String token() {
|
||||||
|
return TokenCache.getToken(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean authFlag() {
|
||||||
|
return TokenCache.getFlag(cacheKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
public Future<String> parse() {
|
public Future<String> parse() {
|
||||||
|
|
||||||
@@ -101,22 +121,21 @@ public class IzTool extends PanBase {
|
|||||||
// 检查并输出认证状态
|
// 检查并输出认证状态
|
||||||
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||||
|
String token = token();
|
||||||
log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, token={}",
|
log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, token={}",
|
||||||
isTempAuth, authFlag, token != null ? "已登录(" + token.substring(0, Math.min(8, token.length())) + "...)" : "未登录");
|
isTempAuth, authFlag(), token != null ? "已登录(" + token.substring(0, Math.min(8, token.length())) + "...)" : "未登录");
|
||||||
|
|
||||||
// 如果需要认证但还没有token,先执行登录
|
// 如果需要认证但还没有token,先执行登录
|
||||||
if ((isTempAuth || authFlag) && token == null) {
|
if ((isTempAuth || authFlag()) && token == null) {
|
||||||
log.info("文件解析需要登录,开始执行登录流程...");
|
log.info("文件解析需要登录,开始执行登录流程...");
|
||||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||||
return login(tsEncode, auths)
|
return login(tsEncode, auths)
|
||||||
.compose(v -> {
|
.recover(err -> {
|
||||||
log.info("文件解析预登录成功,继续解析流程");
|
// 预登录失败不终止解析,继续走免登录
|
||||||
return parseWithAuth(shareId, tsEncode);
|
log.warn("文件解析预登录失败: {},使用免登录模式", err.getMessage());
|
||||||
|
return Future.succeededFuture();
|
||||||
})
|
})
|
||||||
.onFailure(err -> {
|
.compose(v -> parseWithAuth(shareId, tsEncode));
|
||||||
log.warn("文件解析预登录失败: {},尝试使用免登录模式", err.getMessage());
|
|
||||||
// 登录失败,继续使用免登录模式
|
|
||||||
});
|
|
||||||
} else if (token != null) {
|
} else if (token != null) {
|
||||||
log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(8, token.length())));
|
log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(8, token.length())));
|
||||||
}
|
}
|
||||||
@@ -231,8 +250,8 @@ public class IzTool extends PanBase {
|
|||||||
// 检查是否为临时认证(临时认证每次都尝试登录)
|
// 检查是否为临时认证(临时认证每次都尝试登录)
|
||||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||||
// 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证
|
// 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证
|
||||||
if (isTempAuth || authFlag) {
|
if (isTempAuth || authFlag()) {
|
||||||
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
|
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag());
|
||||||
HttpRequest<Buffer> httpRequest =
|
HttpRequest<Buffer> httpRequest =
|
||||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||||
.setTemplateParam("fidEncode", fidEncode)
|
.setTemplateParam("fidEncode", fidEncode)
|
||||||
@@ -241,18 +260,19 @@ public class IzTool extends PanBase {
|
|||||||
.setTemplateParam("auth", auth)
|
.setTemplateParam("auth", auth)
|
||||||
.setTemplateParam("dataKey", dataKey);
|
.setTemplateParam("dataKey", dataKey);
|
||||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||||
if (token == null) {
|
if (token() == null) {
|
||||||
// 执行登录
|
// 执行登录,失败则回退免登录
|
||||||
login(tsEncode2, auths).onFailure(failRes-> {
|
login(tsEncode2, auths).onFailure(failRes-> {
|
||||||
log.warn("登录失败: {}", failRes.getMessage());
|
log.warn("登录失败,回退免登录: {}", failRes.getMessage());
|
||||||
fail(failRes.getMessage());
|
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
|
||||||
}).onSuccess(r-> {
|
}).onSuccess(r-> {
|
||||||
httpRequest.setTemplateParam("appToken", token)
|
httpRequest.setTemplateParam("appToken", token())
|
||||||
.putHeaders(header);
|
.putHeaders(header);
|
||||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 验证token
|
// 验证token — 先从缓存恢复到 header
|
||||||
|
header.set("appToken", token());
|
||||||
webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
|
webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
|
||||||
.setTemplateParam("uuid", uuid)
|
.setTemplateParam("uuid", uuid)
|
||||||
.setTemplateParam("ts", tsEncode2)
|
.setTemplateParam("ts", tsEncode2)
|
||||||
@@ -260,15 +280,15 @@ public class IzTool extends PanBase {
|
|||||||
// log.info("res: {}",asJson(res));
|
// log.info("res: {}",asJson(res));
|
||||||
if (asJson(res).getInteger("code") != 200) {
|
if (asJson(res).getInteger("code") != 200) {
|
||||||
login(tsEncode2, auths).onFailure(failRes -> {
|
login(tsEncode2, auths).onFailure(failRes -> {
|
||||||
log.warn("重新登录失败: {}", failRes.getMessage());
|
log.warn("重新登录失败,回退免登录: {}", failRes.getMessage());
|
||||||
fail(failRes.getMessage());
|
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
|
||||||
}).onSuccess(r-> {
|
}).onSuccess(r-> {
|
||||||
httpRequest.setTemplateParam("appToken", token)
|
httpRequest.setTemplateParam("appToken", token())
|
||||||
.putHeaders(header);
|
.putHeaders(header);
|
||||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
httpRequest.setTemplateParam("appToken", token)
|
httpRequest.setTemplateParam("appToken", token())
|
||||||
.putHeaders(header);
|
.putHeaders(header);
|
||||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||||
}
|
}
|
||||||
@@ -277,29 +297,27 @@ public class IzTool extends PanBase {
|
|||||||
} else {
|
} else {
|
||||||
// authFlag 为 false,使用免登录解析
|
// authFlag 为 false,使用免登录解析
|
||||||
log.debug("authFlag=false,使用免登录解析");
|
log.debug("authFlag=false,使用免登录解析");
|
||||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
|
||||||
.putHeaders(header)
|
|
||||||
.setTemplateParam("fidEncode", fidEncode)
|
|
||||||
.setTemplateParam("uuid", uuid)
|
|
||||||
.setTemplateParam("ts", tsEncode2)
|
|
||||||
.setTemplateParam("auth", auth)
|
|
||||||
.setTemplateParam("dataKey", dataKey).send()
|
|
||||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有认证信息,使用免登录解析
|
// 没有认证信息,使用免登录解析
|
||||||
log.debug("无认证信息,使用免登录解析");
|
log.debug("无认证信息,使用免登录解析");
|
||||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
|
||||||
.putHeaders(header)
|
|
||||||
.setTemplateParam("fidEncode", fidEncode)
|
|
||||||
.setTemplateParam("uuid", uuid)
|
|
||||||
.setTemplateParam("ts", tsEncode2)
|
|
||||||
.setTemplateParam("auth", auth)
|
|
||||||
.setTemplateParam("dataKey", dataKey).send()
|
|
||||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void downloadAnonymous(String fidEncode, String tsEncode2, String auth, String dataKey) {
|
||||||
|
log.debug("使用免登录解析");
|
||||||
|
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||||
|
.putHeaders(header)
|
||||||
|
.setTemplateParam("fidEncode", fidEncode)
|
||||||
|
.setTemplateParam("uuid", uuid)
|
||||||
|
.setTemplateParam("ts", tsEncode2)
|
||||||
|
.setTemplateParam("auth", auth)
|
||||||
|
.setTemplateParam("dataKey", dataKey).send()
|
||||||
|
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||||
|
}
|
||||||
|
|
||||||
private Future<Void> login(String tsEncode2, MultiMap auths) {
|
private Future<Void> login(String tsEncode2, MultiMap auths) {
|
||||||
Promise<Void> promise1 = Promise.promise();
|
Promise<Void> promise1 = Promise.promise();
|
||||||
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
|
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
|
||||||
@@ -310,8 +328,13 @@ public class IzTool extends PanBase {
|
|||||||
.onSuccess(res2->{
|
.onSuccess(res2->{
|
||||||
JsonObject json = asJson(res2);
|
JsonObject json = asJson(res2);
|
||||||
if (json.getInteger("code") == 200) {
|
if (json.getInteger("code") == 200) {
|
||||||
token = json.getJsonObject("data").getString("appToken");
|
String loginToken = json.getJsonObject("data").getString("appToken");
|
||||||
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
|
TokenCache.putToken(cacheKey, loginToken);
|
||||||
|
TokenCache.queueCachedTokenPersist(cacheKey, loginToken, System.currentTimeMillis() + 24 * 60 * 60 * 1000L);
|
||||||
|
if (loginToken != null) {
|
||||||
|
header.set("appToken", loginToken);
|
||||||
|
}
|
||||||
|
log.info("登录成功 token: {}...", loginToken != null ? loginToken.substring(0, Math.min(8, loginToken.length())) : "null");
|
||||||
promise1.complete();
|
promise1.complete();
|
||||||
} else {
|
} else {
|
||||||
// 检查是否为临时认证
|
// 检查是否为临时认证
|
||||||
@@ -323,7 +346,7 @@ public class IzTool extends PanBase {
|
|||||||
} else {
|
} else {
|
||||||
// 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析
|
// 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析
|
||||||
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
|
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
|
||||||
authFlag = false;
|
TokenCache.putFlag(cacheKey, false);
|
||||||
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
|
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,75 +466,75 @@ public class IzTool extends PanBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void down(HttpResponse<Buffer> res2) {
|
private void down(HttpResponse<Buffer> res2) {
|
||||||
MultiMap headers = res2.headers();
|
MultiMap headers = res2.headers();
|
||||||
String location = headers.get("Location");
|
String location = headers.get("Location");
|
||||||
if (StringUtils.isBlank(location)) {
|
if (StringUtils.isBlank(location)) {
|
||||||
fail("{}", buildMissingLocationMessage(res2));
|
fail("{}", buildMissingLocationMessage(res2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
promise.complete(location);
|
promise.complete(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
|
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
|
||||||
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
|
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
|
||||||
message.append(", HTTP ").append(response.statusCode());
|
message.append(", HTTP ").append(response.statusCode());
|
||||||
|
|
||||||
String body = null;
|
String body = null;
|
||||||
try {
|
try {
|
||||||
body = asText(response);
|
body = asText(response);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
body = "<响应体读取失败: " + e.getMessage() + ">";
|
body = "<响应体读取失败: " + e.getMessage() + ">";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(body)) {
|
if (StringUtils.isNotBlank(body)) {
|
||||||
try {
|
try {
|
||||||
JsonObject json = new JsonObject(body);
|
JsonObject json = new JsonObject(body);
|
||||||
String upstreamMsg = json.getString("msg");
|
String upstreamMsg = json.getString("msg");
|
||||||
Object code = json.getValue("code");
|
Object code = json.getValue("code");
|
||||||
if (StringUtils.isNotBlank(upstreamMsg)) {
|
if (StringUtils.isNotBlank(upstreamMsg)) {
|
||||||
message.append(", 上游返回: ").append(upstreamMsg);
|
message.append(", 上游返回: ").append(upstreamMsg);
|
||||||
if (code != null) {
|
if (code != null) {
|
||||||
message.append(" (code=").append(code).append(")");
|
message.append(" (code=").append(code).append(")");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message.append(", 响应体: ").append(previewBody(body));
|
message.append(", 响应体: ").append(previewBody(body));
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
message.append(", 响应体: ").append(previewBody(body));
|
message.append(", 响应体: ").append(previewBody(body));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message.append(", 响应体为空");
|
message.append(", 响应体为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
|
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
|
||||||
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
|
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
|
||||||
if (fileName != null) {
|
if (fileName != null) {
|
||||||
message.append(", 文件: ").append(fileName);
|
message.append(", 文件: ").append(fileName);
|
||||||
}
|
}
|
||||||
if (fileSize != null) {
|
if (fileSize != null) {
|
||||||
message.append(", 大小: ").append(fileSize);
|
message.append(", 大小: ").append(fileSize);
|
||||||
}
|
}
|
||||||
if (!hasConfiguredAuth()) {
|
if (!hasConfiguredAuth()) {
|
||||||
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
|
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
|
||||||
}
|
}
|
||||||
return message.toString();
|
return message.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasConfiguredAuth() {
|
private boolean hasConfiguredAuth() {
|
||||||
Object authObj = shareLinkInfo.getOtherParam().get("auths");
|
Object authObj = shareLinkInfo.getOtherParam().get("auths");
|
||||||
if (!(authObj instanceof MultiMap auths)) {
|
if (!(authObj instanceof MultiMap auths)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
|
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String previewBody(String body) {
|
private String previewBody(String body) {
|
||||||
int maxLength = 500;
|
int maxLength = 500;
|
||||||
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
|
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 目录解析
|
// 目录解析
|
||||||
@Override
|
@Override
|
||||||
public Future<List<FileInfo>> parseFileList() {
|
public Future<List<FileInfo>> parseFileList() {
|
||||||
Promise<List<FileInfo>> promise = Promise.promise();
|
Promise<List<FileInfo>> promise = Promise.promise();
|
||||||
@@ -691,7 +714,6 @@ public class IzTool extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void resetToken() {
|
public static void resetToken() {
|
||||||
token = null;
|
TokenCache.removeByDiskType("iz");
|
||||||
authFlag = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ public class IzToolWithAuth extends PanBase {
|
|||||||
}
|
}
|
||||||
public IzToolWithAuth(ShareLinkInfo shareLinkInfo) {
|
public IzToolWithAuth(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
|
IzXffHeader.apply(header);
|
||||||
}
|
}
|
||||||
|
|
||||||
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
||||||
@@ -213,8 +214,8 @@ public class IzToolWithAuth extends PanBase {
|
|||||||
if (token == null) {
|
if (token == null) {
|
||||||
// 执行登录
|
// 执行登录
|
||||||
login(tsEncode2, auths).onFailure(failRes-> {
|
login(tsEncode2, auths).onFailure(failRes-> {
|
||||||
log.warn("登录失败: {}", failRes.getMessage());
|
log.warn("登录失败,回退免登录: {}", failRes.getMessage());
|
||||||
fail(failRes.getMessage());
|
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
|
||||||
}).onSuccess(r-> {
|
}).onSuccess(r-> {
|
||||||
httpRequest.setTemplateParam("appToken", token)
|
httpRequest.setTemplateParam("appToken", token)
|
||||||
.putHeaders(header);
|
.putHeaders(header);
|
||||||
@@ -229,8 +230,8 @@ public class IzToolWithAuth extends PanBase {
|
|||||||
// log.info("res: {}",asJson(res));
|
// log.info("res: {}",asJson(res));
|
||||||
if (asJson(res).getInteger("code") != 200) {
|
if (asJson(res).getInteger("code") != 200) {
|
||||||
login(tsEncode2, auths).onFailure(failRes -> {
|
login(tsEncode2, auths).onFailure(failRes -> {
|
||||||
log.warn("重新登录失败: {}", failRes.getMessage());
|
log.warn("重新登录失败,回退免登录: {}", failRes.getMessage());
|
||||||
fail(failRes.getMessage());
|
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
|
||||||
}).onSuccess(r-> {
|
}).onSuccess(r-> {
|
||||||
httpRequest.setTemplateParam("appToken", token)
|
httpRequest.setTemplateParam("appToken", token)
|
||||||
.putHeaders(header);
|
.putHeaders(header);
|
||||||
@@ -246,29 +247,27 @@ public class IzToolWithAuth extends PanBase {
|
|||||||
} else {
|
} else {
|
||||||
// authFlag 为 false,使用免登录解析
|
// authFlag 为 false,使用免登录解析
|
||||||
log.debug("authFlag=false,使用免登录解析");
|
log.debug("authFlag=false,使用免登录解析");
|
||||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
|
||||||
.putHeaders(header)
|
|
||||||
.setTemplateParam("fidEncode", fidEncode)
|
|
||||||
.setTemplateParam("uuid", uuid)
|
|
||||||
.setTemplateParam("ts", tsEncode2)
|
|
||||||
.setTemplateParam("auth", auth)
|
|
||||||
.setTemplateParam("dataKey", dataKey).send()
|
|
||||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有认证信息,使用免登录解析
|
// 没有认证信息,使用免登录解析
|
||||||
log.debug("无认证信息,使用免登录解析");
|
log.debug("无认证信息,使用免登录解析");
|
||||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
|
||||||
.putHeaders(header)
|
|
||||||
.setTemplateParam("fidEncode", fidEncode)
|
|
||||||
.setTemplateParam("uuid", uuid)
|
|
||||||
.setTemplateParam("ts", tsEncode2)
|
|
||||||
.setTemplateParam("auth", auth)
|
|
||||||
.setTemplateParam("dataKey", dataKey).send()
|
|
||||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void downloadAnonymous(String fidEncode, String tsEncode2, String auth, String dataKey) {
|
||||||
|
log.debug("使用免登录解析");
|
||||||
|
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||||
|
.putHeaders(header)
|
||||||
|
.setTemplateParam("fidEncode", fidEncode)
|
||||||
|
.setTemplateParam("uuid", uuid)
|
||||||
|
.setTemplateParam("ts", tsEncode2)
|
||||||
|
.setTemplateParam("auth", auth)
|
||||||
|
.setTemplateParam("dataKey", dataKey).send()
|
||||||
|
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||||
|
}
|
||||||
|
|
||||||
private Future<Void> login(String tsEncode2, MultiMap auths) {
|
private Future<Void> login(String tsEncode2, MultiMap auths) {
|
||||||
Promise<Void> promise1 = Promise.promise();
|
Promise<Void> promise1 = Promise.promise();
|
||||||
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
|
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
|
import io.vertx.core.MultiMap;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 蓝奏云优享 X-Forwarded-For 配置。
|
||||||
|
* <p>
|
||||||
|
* 读取系统属性 {@code nfd.iz.xff},其次环境变量 {@code NFD_IZ_XFF}:
|
||||||
|
* <ul>
|
||||||
|
* <li>未设置 / 空 / {@code off}:不发送该头(默认)</li>
|
||||||
|
* <li>{@code random}:每次创建解析器时随机一个国内常见网段 IP</li>
|
||||||
|
* <li>其它值:原样作为头的值,例如 {@code 113.96.181.18}</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class IzXffHeader {
|
||||||
|
|
||||||
|
public static final String PROP = "nfd.iz.xff";
|
||||||
|
public static final String ENV = "NFD_IZ_XFF";
|
||||||
|
private static final String HEADER = "X-Forwarded-For";
|
||||||
|
|
||||||
|
/** 国内运营商常见 /8 段首字节 */
|
||||||
|
private static final int[] CN_PREFIX = {36, 39, 42, 58, 59, 60, 61, 101, 106, 110, 111, 112, 113, 114,
|
||||||
|
115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 175, 180, 182, 183, 202, 210, 211, 218, 219, 220, 221, 222, 223};
|
||||||
|
|
||||||
|
private IzXffHeader() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String configured() {
|
||||||
|
String v = System.getProperty(PROP);
|
||||||
|
if (StringUtils.isBlank(v)) {
|
||||||
|
v = System.getenv(ENV);
|
||||||
|
}
|
||||||
|
return v == null ? "" : v.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据配置得到头的值,返回 null 表示不发送 */
|
||||||
|
static String resolve(String mode) {
|
||||||
|
if (StringUtils.isBlank(mode) || "off".equalsIgnoreCase(mode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ("random".equalsIgnoreCase(mode)) {
|
||||||
|
ThreadLocalRandom r = ThreadLocalRandom.current();
|
||||||
|
return CN_PREFIX[r.nextInt(CN_PREFIX.length)] + "." + r.nextInt(256) + "." + r.nextInt(256) + "." + (1 + r.nextInt(254));
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按当前配置写入或移除请求头 */
|
||||||
|
public static void apply(MultiMap headers) {
|
||||||
|
String value = resolve(configured());
|
||||||
|
if (value == null) {
|
||||||
|
headers.remove(HEADER);
|
||||||
|
} else {
|
||||||
|
headers.set(HEADER, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -48,6 +49,22 @@ public class QkTool extends PanBase {
|
|||||||
// 多账号隔离:用 TokenCache 代替 static cookie 缓存
|
// 多账号隔离:用 TokenCache 代替 static cookie 缓存
|
||||||
// __puus 有效期,默认 55 分钟(服务器实际 1 小时过期,提前 5 分钟刷新)
|
// __puus 有效期,默认 55 分钟(服务器实际 1 小时过期,提前 5 分钟刷新)
|
||||||
private static final long PUUS_TTL_MS = 55 * 60 * 1000L;
|
private static final long PUUS_TTL_MS = 55 * 60 * 1000L;
|
||||||
|
|
||||||
|
/** 根目录经常只有一层文件夹,继续向下找文件。#275 这类分享要再进一层。 */
|
||||||
|
static final int MAX_FOLDER_DEPTH = 4;
|
||||||
|
/** 同一层最多进入的文件夹数,避免把整棵目录树都请求一遍。 */
|
||||||
|
static final int MAX_FOLDERS_PER_LEVEL = 12;
|
||||||
|
/** 访客直链失败(23018)后,按体积从小到大再试几个文件。 */
|
||||||
|
static final int MAX_GUEST_DOWNLOAD_ATTEMPTS = 6;
|
||||||
|
|
||||||
|
static final String EMPTY_SHARE_MSG = "分享目录为空,或只包含空文件夹。解析已经进入子目录查找。";
|
||||||
|
static final String GUEST_OVERSIZE_MSG =
|
||||||
|
"夸克访客只能免登录下载小文件,当前文件超过访客大小限制(上游 23018 download file size limit)。"
|
||||||
|
+ "请在本机配置夸克 Cookie:auths.qk.cookie,或在解析请求中携带 auth。"
|
||||||
|
+ "未配置 Cookie 时不会转存。大文件需要绑定账号后交给下载器,浏览器里直接打开也经常失败。";
|
||||||
|
static final String GUEST_LOGIN_MSG =
|
||||||
|
"夸克要求登录后才能下载。请配置 Cookie(auths.qk.cookie,或请求参数 auth)。"
|
||||||
|
+ "小文件可以免登录直链;这个报错表示当前文件需要账号,请到 Cookie/auth 配置里补登录态。";
|
||||||
|
|
||||||
// 静态变量:缓存已转存的文件 (分享key -> 转存文件ID)
|
// 静态变量:缓存已转存的文件 (分享key -> 转存文件ID)
|
||||||
private static final Map<String, String> savedFileCache = new java.util.concurrent.ConcurrentHashMap<>();
|
private static final Map<String, String> savedFileCache = new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
@@ -216,8 +233,8 @@ public class QkTool extends PanBase {
|
|||||||
log.debug("第一阶段响应: {}", res.bodyAsString());
|
log.debug("第一阶段响应: {}", res.bodyAsString());
|
||||||
JsonObject resJson = asJson(res);
|
JsonObject resJson = asJson(res);
|
||||||
|
|
||||||
if (resJson.getInteger("code") != 0) {
|
if (resJson.getInteger("code") == null || resJson.getInteger("code") != 0) {
|
||||||
fail(TOKEN_URL + " 返回异常: " + resJson);
|
fail(quarkApiFailure(TOKEN_URL, resJson));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,106 +245,304 @@ public class QkTool extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.debug("成功获取 stoken: {}", stoken);
|
log.debug("成功获取 stoken: {}", stoken);
|
||||||
|
|
||||||
// 第二步:获取文件列表
|
|
||||||
client.getAbs(DETAIL_URL)
|
|
||||||
.addQueryParam("pr", "ucpro")
|
|
||||||
.addQueryParam("fr", "pc")
|
|
||||||
.addQueryParam("pwd_id", pwdId)
|
|
||||||
.addQueryParam("stoken", stoken)
|
|
||||||
.addQueryParam("pdir_fid", "0")
|
|
||||||
.addQueryParam("force", "0")
|
|
||||||
.addQueryParam("_page", "1")
|
|
||||||
.addQueryParam("_size", "50")
|
|
||||||
.addQueryParam("_fetch_banner", "1")
|
|
||||||
.addQueryParam("_fetch_share", "1")
|
|
||||||
.addQueryParam("_fetch_total", "1")
|
|
||||||
.addQueryParam("_sort", "file_type:asc,updated_at:desc")
|
|
||||||
.putHeaders(header)
|
|
||||||
.send()
|
|
||||||
.onSuccess(res2 -> {
|
|
||||||
log.debug("第二阶段响应: {}", res2.bodyAsString());
|
|
||||||
JsonObject resJson2 = asJson(res2);
|
|
||||||
|
|
||||||
if (resJson2.getInteger("code") != 0) {
|
|
||||||
fail(DETAIL_URL + " 返回异常: " + resJson2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list");
|
|
||||||
if (fileList == null || fileList.isEmpty()) {
|
|
||||||
fail("未找到文件");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤出文件(排除文件夹)
|
|
||||||
List<JsonObject> files = new ArrayList<>();
|
|
||||||
for (int i = 0; i < fileList.size(); i++) {
|
|
||||||
JsonObject item = fileList.getJsonObject(i);
|
|
||||||
// 判断是否为文件:file=true 或 obj_category 不为空
|
|
||||||
if (item.getBoolean("file", false) ||
|
|
||||||
(item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) {
|
|
||||||
files.add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.isEmpty()) {
|
|
||||||
fail("没有可下载的文件(可能都是文件夹)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("找到 {} 个文件", files.size());
|
|
||||||
|
|
||||||
// 提取第一个文件的信息并保存到 otherParam
|
|
||||||
try {
|
|
||||||
JsonObject firstFile = files.get(0);
|
|
||||||
FileInfo fileInfo = new FileInfo();
|
|
||||||
fileInfo.setFileId(firstFile.getString("fid"))
|
|
||||||
.setFileName(firstFile.getString("file_name"))
|
|
||||||
.setSize(firstFile.getLong("size", 0L))
|
|
||||||
.setSizeStr(FileSizeConverter.convertToReadableSize(firstFile.getLong("size", 0L)))
|
|
||||||
.setFileType(firstFile.getBoolean("file", true) ? "file" : "folder")
|
|
||||||
.setCreateTime(formatEpochMs(firstFile.getLong("updated_at", 0L)))
|
|
||||||
.setUpdateTime(formatEpochMs(firstFile.getLong("updated_at", 0L)))
|
|
||||||
.setPanType(shareLinkInfo.getType());
|
|
||||||
|
|
||||||
// 保存到 otherParam,供 CacheServiceImpl 使用
|
|
||||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
|
||||||
log.debug("夸克提取文件信息: {}", fileInfo.getFileName());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取文件ID列表
|
|
||||||
List<String> fileIds = new ArrayList<>();
|
|
||||||
for (JsonObject file : files) {
|
|
||||||
String fid = file.getString("fid");
|
|
||||||
if (fid != null && !fid.isEmpty()) {
|
|
||||||
fileIds.add(fid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileIds.isEmpty()) {
|
|
||||||
fail("无法提取文件ID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第三步:解析下载链接(先直链,必要时才转存)
|
|
||||||
JsonObject firstFileForDownload = files.get(0);
|
|
||||||
String firstFid = firstFileForDownload.getString("fid");
|
|
||||||
String firstShareFidToken = firstFileForDownload.getString("share_fid_token");
|
|
||||||
|
|
||||||
resolveDownloadUrl(firstFid, pwdId, stoken, firstShareFidToken)
|
// 根目录可能只是文件夹(#282)。先列出再进入,找到文件后再取直链。
|
||||||
.onSuccess(downloadUrl -> completeWithMeta(downloadUrl, buildDownloadHeaders(null)))
|
findDownloadableFiles(pwdId, stoken, "0", 0)
|
||||||
.onFailure(handleFail("获取下载链接"));
|
.compose(files -> resolveListedFiles(files, pwdId, stoken))
|
||||||
|
.onSuccess(downloadUrl -> completeWithMeta(downloadUrl, buildDownloadHeaders(null)))
|
||||||
}).onFailure(handleFail(DETAIL_URL));
|
.onFailure(t -> fail(t.getMessage() == null ? "解析失败" : t.getMessage()));
|
||||||
})
|
})
|
||||||
.onFailure(handleFail(TOKEN_URL));
|
.onFailure(handleFail(TOKEN_URL));
|
||||||
|
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Future<String> resolveListedFiles(List<JsonObject> files, String pwdId, String stoken) {
|
||||||
|
if (files == null || files.isEmpty()) {
|
||||||
|
return Future.failedFuture(EMPTY_SHARE_MSG);
|
||||||
|
}
|
||||||
|
if (hasQuarkCookie()) {
|
||||||
|
JsonObject first = files.get(0);
|
||||||
|
rememberFile(first);
|
||||||
|
return resolveDownloadUrl(first.getString("fid"), pwdId, stoken, first.getString("share_fid_token"));
|
||||||
|
}
|
||||||
|
return resolveGuestDownloads(orderGuestCandidates(files), pwdId, stoken, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 访客没有 Cookie,不能转存。先试接口排序下的第一个文件;
|
||||||
|
* 若夸克返回 23018,再按体积从小到大试同目录里的其他文件。
|
||||||
|
*/
|
||||||
|
private Future<String> resolveGuestDownloads(List<JsonObject> files, String pwdId, String stoken,
|
||||||
|
int index, int attempts) {
|
||||||
|
if (index >= files.size() || attempts >= MAX_GUEST_DOWNLOAD_ATTEMPTS) {
|
||||||
|
return Future.failedFuture(GUEST_OVERSIZE_MSG);
|
||||||
|
}
|
||||||
|
JsonObject file = files.get(index);
|
||||||
|
String fid = file.getString("fid");
|
||||||
|
if (fid == null || fid.isEmpty()) {
|
||||||
|
return resolveGuestDownloads(files, pwdId, stoken, index + 1, attempts);
|
||||||
|
}
|
||||||
|
return getShareDownloadLink(fid, pwdId, stoken, file.getString("share_fid_token"))
|
||||||
|
.compose(url -> {
|
||||||
|
rememberFile(file);
|
||||||
|
return Future.succeededFuture(url);
|
||||||
|
})
|
||||||
|
.recover(err -> {
|
||||||
|
if (needsTransferFallback(err)) {
|
||||||
|
log.info("夸克访客跳过超过大小限制的文件 {} ({} bytes)",
|
||||||
|
file.getString("file_name"), fileSize(file));
|
||||||
|
return resolveGuestDownloads(files, pwdId, stoken, index + 1, attempts + 1);
|
||||||
|
}
|
||||||
|
if (isGuestAuthError(err)) {
|
||||||
|
return Future.failedFuture(GUEST_LOGIN_MSG);
|
||||||
|
}
|
||||||
|
return Future.failedFuture(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Future<List<JsonObject>> findDownloadableFiles(String pwdId, String stoken, String pdirFid, int depth) {
|
||||||
|
return fetchShareList(pwdId, stoken, pdirFid).compose(list -> {
|
||||||
|
List<JsonObject> files = new ArrayList<>();
|
||||||
|
List<JsonObject> folders = new ArrayList<>();
|
||||||
|
for (int i = 0; i < list.size(); i++) {
|
||||||
|
JsonObject item = list.getJsonObject(i);
|
||||||
|
if (isQuarkFile(item)) {
|
||||||
|
files.add(item);
|
||||||
|
} else if (isQuarkFolder(item)) {
|
||||||
|
folders.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasPositiveSize(files) || folders.isEmpty() || depth >= MAX_FOLDER_DEPTH) {
|
||||||
|
return Future.succeededFuture(preferSizedFiles(files));
|
||||||
|
}
|
||||||
|
int limit = Math.min(folders.size(), MAX_FOLDERS_PER_LEVEL);
|
||||||
|
List<JsonObject> placeholders = files;
|
||||||
|
return firstFolderWithFiles(pwdId, stoken, folders.subList(0, limit), 0, depth + 1)
|
||||||
|
.compose(nested -> {
|
||||||
|
if (nested != null && !nested.isEmpty()) {
|
||||||
|
return Future.succeededFuture(nested);
|
||||||
|
}
|
||||||
|
return Future.succeededFuture(placeholders);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Future<List<JsonObject>> firstFolderWithFiles(String pwdId, String stoken, List<JsonObject> folders,
|
||||||
|
int index, int depth) {
|
||||||
|
if (index >= folders.size()) {
|
||||||
|
return Future.succeededFuture(new ArrayList<>());
|
||||||
|
}
|
||||||
|
JsonObject folder = folders.get(index);
|
||||||
|
String fid = folder.getString("fid");
|
||||||
|
if (fid == null || fid.isEmpty()) {
|
||||||
|
return firstFolderWithFiles(pwdId, stoken, folders, index + 1, depth);
|
||||||
|
}
|
||||||
|
log.debug("夸克进入子目录 depth={} name={}", depth, folder.getString("file_name"));
|
||||||
|
return findDownloadableFiles(pwdId, stoken, fid, depth).compose(files -> {
|
||||||
|
if (hasPositiveSize(files)) {
|
||||||
|
return Future.succeededFuture(files);
|
||||||
|
}
|
||||||
|
return firstFolderWithFiles(pwdId, stoken, folders, index + 1, depth).compose(later -> {
|
||||||
|
if (later != null && !later.isEmpty()) {
|
||||||
|
return Future.succeededFuture(later);
|
||||||
|
}
|
||||||
|
return Future.succeededFuture(files == null ? new ArrayList<>() : files);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Future<JsonArray> fetchShareList(String pwdId, String stoken, String pdirFid) {
|
||||||
|
Promise<JsonArray> listPromise = Promise.promise();
|
||||||
|
client.getAbs(DETAIL_URL)
|
||||||
|
.addQueryParam("pr", "ucpro")
|
||||||
|
.addQueryParam("fr", "pc")
|
||||||
|
.addQueryParam("pwd_id", pwdId)
|
||||||
|
.addQueryParam("stoken", stoken)
|
||||||
|
.addQueryParam("pdir_fid", pdirFid == null || pdirFid.isEmpty() ? "0" : pdirFid)
|
||||||
|
.addQueryParam("force", "0")
|
||||||
|
.addQueryParam("_page", "1")
|
||||||
|
.addQueryParam("_size", "50")
|
||||||
|
.addQueryParam("_fetch_banner", "0")
|
||||||
|
.addQueryParam("_fetch_share", "1")
|
||||||
|
.addQueryParam("_fetch_total", "1")
|
||||||
|
.addQueryParam("_sort", "file_type:asc,updated_at:desc")
|
||||||
|
.putHeaders(header)
|
||||||
|
.send()
|
||||||
|
.onSuccess(res -> {
|
||||||
|
JsonObject json = asJson(res);
|
||||||
|
if (json.getInteger("code") == null || json.getInteger("code") != 0) {
|
||||||
|
listPromise.fail(quarkApiFailure(DETAIL_URL, json));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonObject data = json.getJsonObject("data");
|
||||||
|
JsonArray list = data == null ? null : data.getJsonArray("list");
|
||||||
|
listPromise.complete(list == null ? new JsonArray() : list);
|
||||||
|
})
|
||||||
|
.onFailure(t -> listPromise.fail("获取分享目录失败: " + t.getMessage()));
|
||||||
|
return listPromise.future();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rememberFile(JsonObject file) {
|
||||||
|
try {
|
||||||
|
long size = fileSize(file);
|
||||||
|
if (size == Long.MAX_VALUE) {
|
||||||
|
size = 0L;
|
||||||
|
}
|
||||||
|
FileInfo fileInfo = new FileInfo()
|
||||||
|
.setFileId(file.getString("fid"))
|
||||||
|
.setFileName(file.getString("file_name"))
|
||||||
|
.setSize(size)
|
||||||
|
.setSizeStr(FileSizeConverter.convertToReadableSize(size))
|
||||||
|
.setFileType("file")
|
||||||
|
.setCreateTime(formatEpochMs(epochMs(file, "updated_at")))
|
||||||
|
.setUpdateTime(formatEpochMs(epochMs(file, "updated_at")))
|
||||||
|
.setPanType(shareLinkInfo.getType());
|
||||||
|
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||||
|
log.debug("夸克提取文件信息: {}", fileInfo.getFileName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasQuarkCookie() {
|
||||||
|
String cookie = header.get(HttpHeaders.COOKIE);
|
||||||
|
return cookie != null && !cookie.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 访客候选:保留接口返回的第一个文件,其余按体积从小到大。
|
||||||
|
* 这样小文件分享仍先下最新文件;文件夹里最新文件超限时,再改下同目录的小文件。
|
||||||
|
*/
|
||||||
|
static List<JsonObject> orderGuestCandidates(List<JsonObject> files) {
|
||||||
|
List<JsonObject> ordered = new ArrayList<>();
|
||||||
|
if (files == null || files.isEmpty()) {
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
ordered.add(files.get(0));
|
||||||
|
if (files.size() == 1) {
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
List<JsonObject> rest = new ArrayList<>(files.subList(1, files.size()));
|
||||||
|
rest.sort(Comparator.comparingLong(QkTool::fileSize));
|
||||||
|
ordered.addAll(rest);
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isQuarkFolder(JsonObject item) {
|
||||||
|
if (item == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (quarkFlag(item, "dir")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Integer fileType = intValue(item, "file_type");
|
||||||
|
return fileType != null && fileType == 0 && !quarkFlag(item, "file");
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isQuarkFile(JsonObject item) {
|
||||||
|
if (item == null || isQuarkFolder(item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (quarkFlag(item, "file")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Integer fileType = intValue(item, "file_type");
|
||||||
|
if (fileType != null && fileType > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String category = item.getString("obj_category");
|
||||||
|
return category != null && !category.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isGuestAuthError(Throwable t) {
|
||||||
|
String msg = t == null || t.getMessage() == null ? "" : t.getMessage();
|
||||||
|
return msg.contains("31001")
|
||||||
|
|| msg.contains("未登录")
|
||||||
|
|| msg.contains("require login")
|
||||||
|
|| msg.contains("Cookie 已失效");
|
||||||
|
}
|
||||||
|
|
||||||
|
static String quarkApiFailure(String url, JsonObject json) {
|
||||||
|
Integer code = json == null ? null : intValue(json, "code");
|
||||||
|
String message = json == null ? null : json.getString("message");
|
||||||
|
if ((code != null && code == 41010) || (message != null && message.contains("违规"))) {
|
||||||
|
return "夸克判定该分享涉及违规内容,无法解析(上游 code=" + code
|
||||||
|
+ (message == null ? "" : "," + message)
|
||||||
|
+ ")。请让分享者更换资源,本项目不能绕过网盘审核。";
|
||||||
|
}
|
||||||
|
return url + " 返回异常: " + json;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean quarkFlag(JsonObject item, String key) {
|
||||||
|
Object value = item.getValue(key);
|
||||||
|
if (value instanceof Boolean) {
|
||||||
|
return (Boolean) value;
|
||||||
|
}
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return ((Number) value).intValue() != 0;
|
||||||
|
}
|
||||||
|
if (value instanceof String) {
|
||||||
|
return "true".equalsIgnoreCase((String) value) || "1".equals(value);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer intValue(JsonObject item, String key) {
|
||||||
|
Object value = item.getValue(key);
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return ((Number) value).intValue();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean hasPositiveSize(List<JsonObject> files) {
|
||||||
|
if (files == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (JsonObject file : files) {
|
||||||
|
long size = fileSize(file);
|
||||||
|
if (size > 0 && size != Long.MAX_VALUE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一目录里有正常文件时,忽略 0 字节占位文件。 */
|
||||||
|
static List<JsonObject> preferSizedFiles(List<JsonObject> files) {
|
||||||
|
List<JsonObject> sized = new ArrayList<>();
|
||||||
|
if (files == null) {
|
||||||
|
return sized;
|
||||||
|
}
|
||||||
|
for (JsonObject file : files) {
|
||||||
|
long size = fileSize(file);
|
||||||
|
if (size > 0 && size != Long.MAX_VALUE) {
|
||||||
|
sized.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sized.isEmpty() ? new ArrayList<>(files) : sized;
|
||||||
|
}
|
||||||
|
|
||||||
|
static long fileSize(JsonObject item) {
|
||||||
|
if (item == null) {
|
||||||
|
return Long.MAX_VALUE;
|
||||||
|
}
|
||||||
|
Object value = item.getValue("size");
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
return Long.MAX_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long epochMs(JsonObject item, String key) {
|
||||||
|
Object value = item.getValue(key);
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一下载入口(parse / parseById 共用):
|
* 统一下载入口(parse / parseById 共用):
|
||||||
* <ol>
|
* <ol>
|
||||||
@@ -353,7 +568,7 @@ public class QkTool extends PanBase {
|
|||||||
* 是否属于「必须转存」类错误。
|
* 是否属于「必须转存」类错误。
|
||||||
* 典型:code 23018 download file size limit(分享直链有大小限制)。
|
* 典型:code 23018 download file size limit(分享直链有大小限制)。
|
||||||
*/
|
*/
|
||||||
private static boolean needsTransferFallback(Throwable t) {
|
static boolean needsTransferFallback(Throwable t) {
|
||||||
String msg = t == null || t.getMessage() == null ? "" : t.getMessage();
|
String msg = t == null || t.getMessage() == null ? "" : t.getMessage();
|
||||||
return msg.contains("23018")
|
return msg.contains("23018")
|
||||||
|| msg.contains("size limit")
|
|| msg.contains("size limit")
|
||||||
@@ -538,8 +753,7 @@ public class QkTool extends PanBase {
|
|||||||
.setUpdateTime(updatedAt)
|
.setUpdateTime(updatedAt)
|
||||||
.setPanType(shareLinkInfo.getType());
|
.setPanType(shareLinkInfo.getType());
|
||||||
|
|
||||||
// 判断是否为文件:file=true 或 obj_category 不为空
|
if (isQuarkFile(item)) {
|
||||||
if (isFile || (objCategory != null && !objCategory.isEmpty())) {
|
|
||||||
// 文件
|
// 文件
|
||||||
fileInfo.setFileType("file");
|
fileInfo.setFileType("file");
|
||||||
// 保存必要的参数用于后续下载
|
// 保存必要的参数用于后续下载
|
||||||
@@ -788,8 +1002,13 @@ public class QkTool extends PanBase {
|
|||||||
.send()
|
.send()
|
||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
JsonObject resJson = asJson(res);
|
JsonObject resJson = asJson(res);
|
||||||
if (resJson.getInteger("code") != 0) {
|
if (resJson.getInteger("code") == null || resJson.getInteger("code") != 0) {
|
||||||
promise.fail("获取转存目录失败: " + resJson.getString("message"));
|
String message = resJson.getString("message");
|
||||||
|
if (message != null && (message.contains("require login") || message.contains("guest"))) {
|
||||||
|
promise.fail(GUEST_LOGIN_MSG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
promise.fail("获取转存目录失败: " + message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cn.qaiu.parser.impl;
|
|||||||
import cn.qaiu.entity.FileInfo;
|
import cn.qaiu.entity.FileInfo;
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
import cn.qaiu.parser.PanBase;
|
import cn.qaiu.parser.PanBase;
|
||||||
|
import cn.qaiu.parser.PasswordLoginRisk;
|
||||||
import cn.qaiu.parser.TokenCache;
|
import cn.qaiu.parser.TokenCache;
|
||||||
import cn.qaiu.util.CommonUtils;
|
import cn.qaiu.util.CommonUtils;
|
||||||
import cn.qaiu.util.FileSizeConverter;
|
import cn.qaiu.util.FileSizeConverter;
|
||||||
@@ -188,7 +189,12 @@ public class Ye2Tool extends PanBase {
|
|||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
JsonObject shareInfoJson = asJson(res);
|
JsonObject shareInfoJson = asJson(res);
|
||||||
if (shareInfoJson.getInteger("code") != 0) {
|
if (shareInfoJson.getInteger("code") != 0) {
|
||||||
fail("获取分享信息失败: " + shareInfoJson.getString("message"));
|
String message = shareInfoJson.getString("message");
|
||||||
|
if (isYeLoginRequired(shareInfoJson.getInteger("code"), message)) {
|
||||||
|
fail(loginRequiredDownloadMessage(!"nologin".equals(token), message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail("获取分享信息失败: " + message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +211,7 @@ public class Ye2Tool extends PanBase {
|
|||||||
|
|
||||||
JsonObject fileInfo = data.getJsonArray("InfoList").getJsonObject(0);
|
JsonObject fileInfo = data.getJsonArray("InfoList").getJsonObject(0);
|
||||||
if (token.equals("nologin")) {
|
if (token.equals("nologin")) {
|
||||||
fail("该分享需要登录才能下载,请配置认证信息");
|
fail(loginRequiredDownloadMessage(false, null));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,24 +278,27 @@ public class Ye2Tool extends PanBase {
|
|||||||
.putHeader("platform", "web")
|
.putHeader("platform", "web")
|
||||||
.sendJsonObject(loginBody)
|
.sendJsonObject(loginBody)
|
||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
JsonObject json = res.bodyAsJsonObject();
|
JsonObject json;
|
||||||
|
try {
|
||||||
|
json = res.bodyAsJsonObject();
|
||||||
|
} catch (Exception e) {
|
||||||
|
promise.fail(PasswordLoginRisk.nonJsonLoginFailure(res.bodyAsString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (json == null) {
|
if (json == null) {
|
||||||
promise.fail("登录响应格式异常: " + res.bodyAsString());
|
promise.fail(PasswordLoginRisk.nonJsonLoginFailure(res.bodyAsString()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!json.containsKey("code")) {
|
if (!json.containsKey("code")) {
|
||||||
promise.fail("登录响应格式异常: " + res.bodyAsString());
|
promise.fail("登录响应格式异常: " + res.bodyAsString());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (json.getInteger("code") != 200) {
|
// 验证码/风控只提示改用 Authorization,不求解、不重试。
|
||||||
promise.fail("登录失败: " + json.getString("message"));
|
if (!PasswordLoginRisk.isLoginSuccess(json)) {
|
||||||
|
promise.fail(PasswordLoginRisk.describeLoginFailure(json));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
JsonObject data = json.getJsonObject("data");
|
JsonObject data = json.getJsonObject("data");
|
||||||
if (data == null || !data.containsKey("token")) {
|
|
||||||
promise.fail("未获取到token");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String ssoToken = data.getString("token");
|
String ssoToken = data.getString("token");
|
||||||
String expireStr = data.getString("expire");
|
String expireStr = data.getString("expire");
|
||||||
long expireMs;
|
long expireMs;
|
||||||
@@ -438,9 +447,47 @@ public class Ye2Tool extends PanBase {
|
|||||||
.onFailure(err -> fail("文件夹打包下载接口失败: " + err.getMessage()));
|
.onFailure(err -> fail("文件夹打包下载接口失败: " + err.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 123 下载接口要求登录时的说明。分享列表本身经常可以匿名读取,
|
||||||
|
* 真正拦下载的是 5112 / 请登录后下载,需要调用方自己的 Authorization。
|
||||||
|
*/
|
||||||
|
static String loginRequiredDownloadMessage(boolean authConfigured, String upstream) {
|
||||||
|
String msg = authConfigured
|
||||||
|
? "123网盘仍要求登录或触发了风控。请在本机检查 auths.ye.authorization(与 auths.ye.token 等价)"
|
||||||
|
+ "或账号密码是否仍然有效。公共云解析没有共享 123 账号。"
|
||||||
|
+ "上游常见返回:5112、请登录后下载、您需要注册登录或付费后下载。"
|
||||||
|
: "123网盘这个分享需要登录后才能下载。请在本机部署,并配置 auths.ye.authorization"
|
||||||
|
+ "(与 auths.ye.token 等价;浏览器登录后的 authorToken、请求头 authorization 或 Cookie sso-token,不要重复带 Bearer),"
|
||||||
|
+ "或配置 auths.ye.username 与 password。公共云解析没有共享 123 账号,不能代登。"
|
||||||
|
+ "约 10MB 的小文件有时可以直链,大文件、文件夹打包以及 5112/请登录后下载必须使用你自己的 Authorization。"
|
||||||
|
+ "短链格式是 ye:分享key@提取码;链接里的 # 片段会去掉。"
|
||||||
|
+ "yun.123pan.com、www.123pan.com 和 *.share.123pan.cn 都是同一类分享。";
|
||||||
|
if (StringUtils.isNotBlank(upstream)) {
|
||||||
|
String brief = upstream.length() > 180 ? upstream.substring(0, 180) : upstream;
|
||||||
|
msg = msg + " 上游信息: " + brief;
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isYeLoginRequired(Integer code, String message) {
|
||||||
|
if (code != null && (code == 5112 || code == 401)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return message.contains("请登录") || message.contains("需要登录") || message.contains("注册登录");
|
||||||
|
}
|
||||||
|
|
||||||
private void handleDownloadUrlResponse(WebClient client, JsonObject downURLJson, String failPrefix) {
|
private void handleDownloadUrlResponse(WebClient client, JsonObject downURLJson, String failPrefix) {
|
||||||
try {
|
try {
|
||||||
if (downURLJson.getInteger("code") != 0) {
|
Integer code = downURLJson.getInteger("code");
|
||||||
|
if (code != 0) {
|
||||||
|
String message = downURLJson.getString("message");
|
||||||
|
if (isYeLoginRequired(code, message)) {
|
||||||
|
fail(loginRequiredDownloadMessage(true, message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
fail(failPrefix + "返回值异常->" + downURLJson);
|
fail(failPrefix + "返回值异常->" + downURLJson);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -542,27 +589,35 @@ public class Ye2Tool extends PanBase {
|
|||||||
Future<String> tokenFuture = resolveTokenFuture();
|
Future<String> tokenFuture = resolveTokenFuture();
|
||||||
String finalParentFileId = parentFileId;
|
String finalParentFileId = parentFileId;
|
||||||
tokenFuture.onSuccess(token -> {
|
tokenFuture.onSuccess(token -> {
|
||||||
if (token.equals("nologin")) {
|
// 分享列表接口可以不登录。下载才经常返回 5112。
|
||||||
promise.fail("该分享需要登录才能访问,请配置认证信息");
|
boolean loggedIn = StringUtils.isNotEmpty(token) && !"nologin".equals(token);
|
||||||
return;
|
if (loggedIn) {
|
||||||
|
header.set("Authorization", "Bearer " + token);
|
||||||
}
|
}
|
||||||
|
|
||||||
String normalizedShareKey = YeShareHostUtil.normalizeShareKey(shareKey);
|
String normalizedShareKey = YeShareHostUtil.normalizeShareKey(shareKey);
|
||||||
String shareOrigin = resolveYeShareOrigin(normalizedShareKey);
|
String shareOrigin = resolveYeShareOrigin(normalizedShareKey);
|
||||||
String shareReferer = buildShareReferer(shareOrigin, normalizedShareKey, pwd);
|
String shareReferer = buildShareReferer(shareOrigin, normalizedShareKey, pwd);
|
||||||
|
|
||||||
client.getAbs(UriTemplate.of(GET_SHARE_INFO_URL))
|
HttpRequest<Buffer> request = client.getAbs(UriTemplate.of(GET_SHARE_INFO_URL))
|
||||||
.setTemplateParam("shareKey", shareKey)
|
.setTemplateParam("shareKey", shareKey)
|
||||||
.setTemplateParam("pwd", StringUtils.isEmpty(pwd) ? "" : pwd)
|
.setTemplateParam("pwd", StringUtils.isEmpty(pwd) ? "" : pwd)
|
||||||
.setTemplateParam("ParentFileId", finalParentFileId)
|
.setTemplateParam("ParentFileId", finalParentFileId)
|
||||||
.putHeader("Authorization", "Bearer " + token)
|
|
||||||
.putHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
.putHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
.putHeader("Referer", shareReferer)
|
.putHeader("Referer", shareReferer)
|
||||||
.putHeader("Origin", shareOrigin)
|
.putHeader("Origin", shareOrigin);
|
||||||
.send().onSuccess(res -> {
|
if (loggedIn) {
|
||||||
|
request.putHeader("Authorization", "Bearer " + token);
|
||||||
|
}
|
||||||
|
request.send().onSuccess(res -> {
|
||||||
JsonObject response = asJson(res);
|
JsonObject response = asJson(res);
|
||||||
if (response.getInteger("code") != 0) {
|
if (response.getInteger("code") != 0) {
|
||||||
promise.fail("API错误: " + response.getString("message"));
|
String message = response.getString("message");
|
||||||
|
if (isYeLoginRequired(response.getInteger("code"), message)) {
|
||||||
|
promise.fail(loginRequiredDownloadMessage(loggedIn, message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
promise.fail("API错误: " + message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,7 +692,7 @@ public class Ye2Tool extends PanBase {
|
|||||||
|
|
||||||
tokenFuture.onSuccess(token -> {
|
tokenFuture.onSuccess(token -> {
|
||||||
if (token.equals("nologin")) {
|
if (token.equals("nologin")) {
|
||||||
fail("该分享需要登录才能下载,请配置认证信息");
|
fail(loginRequiredDownloadMessage(false, null));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ public final class YeShareHostUtil {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
String key = shareKey.trim();
|
String key = shareKey.trim();
|
||||||
|
int hashIndex = key.indexOf('#');
|
||||||
|
if (hashIndex >= 0) {
|
||||||
|
key = key.substring(0, hashIndex);
|
||||||
|
}
|
||||||
int queryIndex = key.indexOf('?');
|
int queryIndex = key.indexOf('?');
|
||||||
if (queryIndex >= 0) {
|
if (queryIndex >= 0) {
|
||||||
key = key.substring(0, queryIndex);
|
key = key.substring(0, queryIndex);
|
||||||
|
|||||||
@@ -55,8 +55,50 @@ def encode123(url, way, version, timestamp):
|
|||||||
# 返回加密后的URL参数
|
# 返回加密后的URL参数
|
||||||
return f"?{y}={time_long}-{a}-{final_crc}"
|
return f"?{y}={time_long}-{a}-{final_crc}"
|
||||||
|
|
||||||
|
# 与 parser PasswordLoginRisk 保持一致:验证码/风控只提示改用 Authorization,不求解、不重试。
|
||||||
|
_RISK_KEYWORDS = (
|
||||||
|
"验证码", "风控", "安全风险", "安全验证", "安全校验", "滑块", "滑动验证",
|
||||||
|
"拼图验证", "短信验证", "微信登录", "微信进行登录", "境外登录", "需要验证", "请验证",
|
||||||
|
"请你验证", "进行验证", "完成验证", "二次验证", "人机验证", "操作频繁", "请求过于频繁",
|
||||||
|
"请求频繁", "captcha", "recaptcha", "risk control",
|
||||||
|
)
|
||||||
|
_AUTH_HINT = (
|
||||||
|
"账号密码登录触发了验证码/风控,已停止用户名密码登录(不会尝试验证码)。"
|
||||||
|
"请改为配置 Authorization(浏览器登录后的 Bearer token / JWT,"
|
||||||
|
"例如 localStorage 的 authorToken、请求头 authorization 或 Cookie 的 sso-token)。"
|
||||||
|
"静态配置写在 app-dev.yml 的 auths.ye.authorization(与 auths.ye.token 等价);"
|
||||||
|
"临时请求使用 authType=authorization 或 authType=accesstoken,token 填该令牌(不要重复带 Bearer 前缀)。"
|
||||||
|
"说明见 parser/doc/auth-param/。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_login_failure(result):
|
||||||
|
"""把 123 登录失败响应转成用户可见说明。"""
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return "登录失败"
|
||||||
|
message = result.get("message") or result.get("msg") or ""
|
||||||
|
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||||||
|
data_message = data.get("message") or data.get("msg") or ""
|
||||||
|
blob = f"{message} {data_message}".lower()
|
||||||
|
risk_field = any(
|
||||||
|
key.lower().find(flag) >= 0 and data.get(key) not in (None, False, 0, "", "false", "0")
|
||||||
|
for key in data
|
||||||
|
for flag in ("captcha", "vcode", "risk", "needverify", "need_verify", "slideverify", "verifycode", "verify_code")
|
||||||
|
)
|
||||||
|
if risk_field or any(keyword.lower() in blob for keyword in _RISK_KEYWORDS):
|
||||||
|
code = result.get("code")
|
||||||
|
upstream = message or data_message
|
||||||
|
extra = f"code={code}" if code is not None else ""
|
||||||
|
if upstream:
|
||||||
|
extra = f"{extra}, message={upstream}" if extra else f"message={upstream}"
|
||||||
|
return f"{_AUTH_HINT} 上游返回:{extra}" if extra else _AUTH_HINT
|
||||||
|
if result.get("code") == 200:
|
||||||
|
return "未获取到token"
|
||||||
|
return f"登录失败: {message}" if message else "登录失败"
|
||||||
|
|
||||||
|
|
||||||
def login_123pan(username, password):
|
def login_123pan(username, password):
|
||||||
"""登录123盘获取token"""
|
"""登录123盘获取token。成功返回 token,失败返回 None(错误已打印)。"""
|
||||||
print(f"🔐 正在登录账号: {username}")
|
print(f"🔐 正在登录账号: {username}")
|
||||||
|
|
||||||
login_data = {
|
login_data = {
|
||||||
@@ -73,16 +115,18 @@ def login_123pan(username, password):
|
|||||||
)
|
)
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
|
||||||
if result.get('code') == 200:
|
if result.get('code') == 200 and result.get('data', {}).get('token'):
|
||||||
token = result.get('data', {}).get('token', '')
|
token = result.get('data', {}).get('token', '')
|
||||||
print(f"✅ 登录成功!")
|
print(f"✅ 登录成功!")
|
||||||
return token
|
return token
|
||||||
else:
|
else:
|
||||||
error_msg = result.get('message', '未知错误')
|
error_msg = format_login_failure(result)
|
||||||
print(f"❌ 登录失败: {error_msg}")
|
login_123pan.last_error = error_msg
|
||||||
|
print(f"❌ {error_msg}")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 登录请求失败: {e}")
|
login_123pan.last_error = f"登录请求失败: {e}"
|
||||||
|
print(f"❌ {login_123pan.last_error}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_share_info(share_key, password=''):
|
def get_share_info(share_key, password=''):
|
||||||
@@ -189,7 +233,7 @@ def start(link, password='', username='', user_password=''):
|
|||||||
if not token:
|
if not token:
|
||||||
return {
|
return {
|
||||||
"code": 201,
|
"code": 201,
|
||||||
"message": "登录失败"
|
"message": getattr(login_123pan, "last_error", None) or "登录失败"
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
print("⚠️ 未提供登录信息,某些文件可能无法下载")
|
print("⚠️ 未提供登录信息,某些文件可能无法下载")
|
||||||
|
|||||||
@@ -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,12 +170,29 @@ 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"));
|
||||||
|
|
||||||
assertEquals("1813382308", YeShareHostUtil.getNumericSubdomainIdByShareKey("lN7UVv-pbYJ"));
|
assertEquals("1813382308", YeShareHostUtil.getNumericSubdomainIdByShareKey("lN7UVv-pbYJ"));
|
||||||
assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey("https://1813382308.mshare.123pan.cn/123pan/lN7UVv-pbYJ?pwd=abcd"));
|
assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey("https://1813382308.mshare.123pan.cn/123pan/lN7UVv-pbYJ?pwd=abcd"));
|
||||||
|
|
||||||
|
Matcher hashUrl = yePattern.matcher("https://www.123pan.com/s/lN7UVv-pbYJ#/list/share");
|
||||||
|
assertTrue("YE should match URL with hash fragment", hashUrl.matches());
|
||||||
|
assertEquals("lN7UVv-pbYJ", hashUrl.group("KEY"));
|
||||||
|
|
||||||
|
String fakeQuery = "ref" + "=" + "example";
|
||||||
|
Matcher yunUrl = yePattern.matcher("https://yun.123pan.com/s/lN7UVv-pbYJ?" + fakeQuery);
|
||||||
|
assertTrue("YE should match yun.123pan.com", yunUrl.matches());
|
||||||
|
assertEquals("lN7UVv-pbYJ", yunUrl.group("KEY"));
|
||||||
|
|
||||||
|
assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey("lN7UVv-pbYJ#/list/share"));
|
||||||
|
assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey(
|
||||||
|
"https://yun.123pan.com/s/lN7UVv-pbYJ?" + fakeQuery + "#/list"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package cn.qaiu.parser;
|
||||||
|
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class PasswordLoginRiskTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void securityRiskMessageGuidesAuthorization() {
|
||||||
|
JsonObject body = new JsonObject()
|
||||||
|
.put("code", 401)
|
||||||
|
.put("message", "当前账号存在安全风险,请使用短信验证码或者微信进行登录。");
|
||||||
|
|
||||||
|
assertTrue(PasswordLoginRisk.indicatesCaptchaOrRisk(body));
|
||||||
|
String message = PasswordLoginRisk.describeLoginFailure(body);
|
||||||
|
assertTrue(message.startsWith(PasswordLoginRisk.AUTHORIZATION_HINT));
|
||||||
|
assertTrue(message.contains("验证码/风控"));
|
||||||
|
assertTrue(message.contains("auths.ye.authorization"));
|
||||||
|
assertTrue(message.contains("authType=authorization"));
|
||||||
|
assertTrue(message.contains("authType=accesstoken"));
|
||||||
|
assertTrue(message.contains("code=401"));
|
||||||
|
assertTrue(message.contains("当前账号存在安全风险"));
|
||||||
|
assertFalse(message.startsWith("登录失败:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void overseasRiskAndCaptchaPhrasesMatch() {
|
||||||
|
assertRisk("当前账号存在境外登录风险,请使用短信验证码或者微信进行登录。");
|
||||||
|
assertRisk("请完成安全验证");
|
||||||
|
assertRisk("请进行滑块验证");
|
||||||
|
assertRisk("操作频繁,请稍后再试");
|
||||||
|
assertRisk("captcha required");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void wrongPasswordStaysDistinct() {
|
||||||
|
JsonObject body = new JsonObject()
|
||||||
|
.put("code", 500)
|
||||||
|
.put("message", "用户名或密码错误");
|
||||||
|
|
||||||
|
assertFalse(PasswordLoginRisk.indicatesCaptchaOrRisk(body));
|
||||||
|
assertEquals("登录失败: 用户名或密码错误", PasswordLoginRisk.describeLoginFailure(body));
|
||||||
|
|
||||||
|
JsonObject account = new JsonObject().put("code", 1).put("msg", "账号或密码错误");
|
||||||
|
assertFalse(PasswordLoginRisk.indicatesCaptchaOrRisk(account));
|
||||||
|
assertEquals("登录失败: 账号或密码错误", PasswordLoginRisk.describeLoginFailure(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void riskFieldWithoutKeywordStillHints() {
|
||||||
|
JsonObject body = new JsonObject()
|
||||||
|
.put("code", 200)
|
||||||
|
.put("message", "ok")
|
||||||
|
.put("data", new JsonObject().put("needCaptcha", true));
|
||||||
|
|
||||||
|
assertFalse(PasswordLoginRisk.isLoginSuccess(body));
|
||||||
|
assertTrue(PasswordLoginRisk.indicatesCaptchaOrRisk(body));
|
||||||
|
assertTrue(PasswordLoginRisk.describeLoginFailure(body).contains("Authorization"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void falseCaptchaFlagDoesNotHint() {
|
||||||
|
JsonObject body = new JsonObject()
|
||||||
|
.put("code", 500)
|
||||||
|
.put("message", "用户名或密码错误")
|
||||||
|
.put("data", new JsonObject().put("needCaptcha", false));
|
||||||
|
|
||||||
|
assertFalse(PasswordLoginRisk.indicatesCaptchaOrRisk(body));
|
||||||
|
assertEquals("登录失败: 用户名或密码错误", PasswordLoginRisk.describeLoginFailure(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void successTokenIsNotTreatedAsFailure() {
|
||||||
|
JsonObject body = new JsonObject()
|
||||||
|
.put("code", 200)
|
||||||
|
.put("message", "ok")
|
||||||
|
.put("data", new JsonObject().put("token", "jwt-token"));
|
||||||
|
|
||||||
|
assertTrue(PasswordLoginRisk.isLoginSuccess(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void blankTokenWithoutRiskKeepsMissingTokenMessage() {
|
||||||
|
JsonObject body = new JsonObject()
|
||||||
|
.put("code", 200)
|
||||||
|
.put("message", "ok")
|
||||||
|
.put("data", new JsonObject().put("token", ""));
|
||||||
|
|
||||||
|
assertFalse(PasswordLoginRisk.isLoginSuccess(body));
|
||||||
|
assertEquals("未获取到token", PasswordLoginRisk.describeLoginFailure(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void nonJsonCaptchaBodyUsesHint() {
|
||||||
|
String body = "<html>请完成滑块验证</html>";
|
||||||
|
String message = PasswordLoginRisk.nonJsonLoginFailure(body);
|
||||||
|
assertTrue(message.startsWith(PasswordLoginRisk.AUTHORIZATION_HINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void nonJsonWrongPasswordStaysFormatError() {
|
||||||
|
assertEquals("登录响应格式异常: 用户名或密码错误",
|
||||||
|
PasswordLoginRisk.nonJsonLoginFailure("用户名或密码错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertRisk(String message) {
|
||||||
|
JsonObject body = new JsonObject().put("code", 5202).put("message", message);
|
||||||
|
assertTrue(message, PasswordLoginRisk.indicatesCaptchaOrRisk(body));
|
||||||
|
assertTrue(PasswordLoginRisk.describeLoginFailure(body).startsWith(PasswordLoginRisk.AUTHORIZATION_HINT));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
|
import io.vertx.core.MultiMap;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
public class IzXffHeaderTest {
|
||||||
|
@Test
|
||||||
|
public void offByDefault() {
|
||||||
|
assertNull(IzXffHeader.resolve(null));
|
||||||
|
assertNull(IzXffHeader.resolve(""));
|
||||||
|
assertNull(IzXffHeader.resolve("OFF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void literalAndRandom() {
|
||||||
|
assertEquals("113.96.181.18", IzXffHeader.resolve("113.96.181.18"));
|
||||||
|
String ip = IzXffHeader.resolve("random");
|
||||||
|
assertTrue(ip, ip.matches("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void applyRemovesWhenOff() {
|
||||||
|
MultiMap h = MultiMap.caseInsensitiveMultiMap().set("X-Forwarded-For", "1.1.1.1");
|
||||||
|
System.clearProperty(IzXffHeader.PROP);
|
||||||
|
if (System.getenv(IzXffHeader.ENV) == null) {
|
||||||
|
IzXffHeader.apply(h);
|
||||||
|
assertFalse(h.contains("X-Forwarded-For"));
|
||||||
|
}
|
||||||
|
System.setProperty(IzXffHeader.PROP, "8.8.8.8");
|
||||||
|
IzXffHeader.apply(h);
|
||||||
|
assertEquals("8.8.8.8", h.get("X-Forwarded-For"));
|
||||||
|
System.clearProperty(IzXffHeader.PROP);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package cn.qaiu.parser.impl;
|
|
||||||
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
public class LzToolAjaxExtractTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testExtractWpSignAjaxfile() {
|
|
||||||
String html = """
|
|
||||||
<script>
|
|
||||||
var wp_sign = 'BWMGOF5v';
|
|
||||||
var ajaxdata = 'MrBR';
|
|
||||||
var kdns =1;
|
|
||||||
$.ajax({
|
|
||||||
type : 'post',
|
|
||||||
url : '/ajaxfile.php?file=150233466',
|
|
||||||
data : { 'action':'downprocess','websignkey':ajaxdata,'signs':ajaxdata,'sign':wp_sign,'websign':'','kd':kdns,'ves':1 }
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
""";
|
|
||||||
LzTool.AjaxCall call = LzTool.extractAjaxFromHtml(html, null);
|
|
||||||
assertNotNull(call);
|
|
||||||
assertEquals("/ajaxfile.php?file=150233466", call.path());
|
|
||||||
assertEquals("BWMGOF5v", call.form().get("sign"));
|
|
||||||
assertEquals("MrBR", call.form().get("websignkey"));
|
|
||||||
assertEquals("downprocess", call.form().get("action"));
|
|
||||||
assertEquals("1", call.form().get("kd"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testExtractPasswordSign() {
|
|
||||||
String html = """
|
|
||||||
<input id="pwd">
|
|
||||||
<script>
|
|
||||||
function down_p(){
|
|
||||||
$.ajax({
|
|
||||||
type : 'post',
|
|
||||||
url : '/ajaxm.php?file=1',
|
|
||||||
data : { 'action':'downprocess','sign':'ABC123','p':'x' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
""";
|
|
||||||
LzTool.AjaxCall call = LzTool.extractAjaxFromHtml(html, "e4k4");
|
|
||||||
assertNotNull(call);
|
|
||||||
assertEquals("/ajaxm.php?file=1", call.path());
|
|
||||||
assertEquals("ABC123", call.form().get("sign"));
|
|
||||||
assertEquals("e4k4", call.form().get("p"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个性域名 a.lanzouw.com 不能被改写到 wwww.lanzoux.com(四个 w)。
|
||||||
|
* 部分出口连不上四 w,页面请求会超时;ajax 只把四 w 当作最后兜底。
|
||||||
|
*/
|
||||||
|
public class LzToolShareUrlTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void personalDomainPageStaysOffWwww() {
|
||||||
|
String resolved = LzTool.resolveSharePageUrl(
|
||||||
|
"https://a.lanzouw.com/xxx",
|
||||||
|
"https://w1.lanzn.com/xxx",
|
||||||
|
"xxx");
|
||||||
|
assertEquals("https://a.lanzouw.com/xxx", resolved);
|
||||||
|
assertFalse(resolved.contains("wwww.lanzoux.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void folderPageKeepsOriginalHost() {
|
||||||
|
String resolved = LzTool.resolveSharePageUrl(
|
||||||
|
"https://a.lanzouw.com/b710887",
|
||||||
|
"https://w1.lanzn.com/b710887",
|
||||||
|
"b710887");
|
||||||
|
assertEquals("https://a.lanzouw.com/b710887", resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void nodeShareIsNotRewrittenToWwww() {
|
||||||
|
String resolved = LzTool.resolveSharePageUrl(
|
||||||
|
"https://wwn.lanzouy.com/ihLkw1gezutg",
|
||||||
|
"https://w1.lanzn.com/ihLkw1gezutg",
|
||||||
|
"ihLkw1gezutg");
|
||||||
|
assertEquals("https://wwn.lanzouy.com/ihLkw1gezutg", resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void webpageQueryStaysOnOriginalHost() {
|
||||||
|
String resolved = LzTool.resolveSharePageUrl(
|
||||||
|
"https://a.lanzouw.com/iabc?webpage=wp1&pwd=secret",
|
||||||
|
"https://w1.lanzn.com/iabc",
|
||||||
|
"iabc");
|
||||||
|
assertEquals("https://a.lanzouw.com/iabc?webpage=wp1", resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void placeholderUsesStandardHostNotWwww() {
|
||||||
|
assertEquals("https://w1.lanzn.com/", LzTool.SHARE_URL_PREFIX);
|
||||||
|
assertFalse(LzTool.SHARE_URL_PREFIX.contains("wwww"));
|
||||||
|
String resolved = LzTool.resolveSharePageUrl(
|
||||||
|
"https://w1.lanzn.com/-",
|
||||||
|
"https://w1.lanzn.com/-",
|
||||||
|
"iFileId");
|
||||||
|
assertEquals("https://w1.lanzn.com/iFileId", resolved);
|
||||||
|
assertFalse(resolved.contains("wwww.lanzoux.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void missingShareUrlKeepsRealStandardHost() {
|
||||||
|
String resolved = LzTool.resolveSharePageUrl(
|
||||||
|
null,
|
||||||
|
"https://www.lanzoux.com/iabc",
|
||||||
|
"iabc");
|
||||||
|
assertEquals("https://www.lanzoux.com/iabc", resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void ajaxFallbackOrderPutsWwwwLast() {
|
||||||
|
List<String> fallback = LzTool.AJAX_FALLBACK_ORIGINS;
|
||||||
|
assertEquals("https://w1.lanzn.com", fallback.get(0));
|
||||||
|
assertEquals("https://www.lanzoux.com", fallback.get(1));
|
||||||
|
assertEquals("https://wwww.lanzoux.com", fallback.get(fallback.size() - 1));
|
||||||
|
|
||||||
|
List<String> origins = LzTool.ajaxOrigins("https://a.lanzouw.com/xxx");
|
||||||
|
assertEquals("https://a.lanzouw.com", origins.get(0));
|
||||||
|
assertEquals("https://w1.lanzn.com", origins.get(1));
|
||||||
|
assertEquals("https://www.lanzoux.com", origins.get(2));
|
||||||
|
assertEquals("https://wwww.lanzoux.com", origins.get(origins.size() - 1));
|
||||||
|
assertTrue(origins.indexOf("https://wwww.lanzoux.com")
|
||||||
|
> origins.indexOf("https://w1.lanzn.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void ajaxOriginsDoNotDuplicateKnownHost() {
|
||||||
|
List<String> origins = LzTool.ajaxOrigins("https://www.lanzoux.com/iabc");
|
||||||
|
assertEquals(3, origins.size());
|
||||||
|
assertEquals("https://www.lanzoux.com", origins.get(0));
|
||||||
|
assertEquals("https://w1.lanzn.com", origins.get(1));
|
||||||
|
assertEquals("https://wwww.lanzoux.com", origins.get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void timeoutAjaxFallsBackButBusinessErrorDoesNot() {
|
||||||
|
assertTrue(LzTool.ajaxResponseShouldFallback("{\"zt\":0,\"inf\":\"已超时\"}"));
|
||||||
|
assertTrue(LzTool.ajaxResponseShouldFallback("{\"info\":\"请求超时\"}"));
|
||||||
|
assertTrue(LzTool.ajaxResponseShouldFallback(""));
|
||||||
|
assertTrue(LzTool.ajaxResponseShouldFallback("<html>blocked</html>"));
|
||||||
|
assertFalse(LzTool.ajaxResponseShouldFallback("{\"zt\":0,\"inf\":\"密码错误\"}"));
|
||||||
|
assertFalse(LzTool.ajaxResponseShouldFallback(
|
||||||
|
"{\"zt\":1,\"inf\":\"name.zip\",\"dom\":\"https://x\",\"url\":\"abc\"}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void filePagePrefersExistingHostOverWwww() {
|
||||||
|
assertEquals("https://a.lanzouw.com/",
|
||||||
|
LzTool.sharePageHostBase("https://a.lanzouw.com/b123", "https://w1.lanzn.com/b123"));
|
||||||
|
assertEquals("https://w1.lanzn.com/",
|
||||||
|
LzTool.sharePageHostBase("https://w1.lanzn.com/-", "https://w1.lanzn.com/-"));
|
||||||
|
assertFalse(LzTool.sharePageHostBase("https://a.lanzouw.com/b123", null).contains("wwww"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void absoluteAjaxUrlIsKeptAndTriedFirst() {
|
||||||
|
String html = """
|
||||||
|
url : 'https://apifile.lanzouw.com/ajaxfile.php?file=320665771',
|
||||||
|
data : { 'action':'downprocess','sign':isngis,'kd':kdns,'p':pwd, },
|
||||||
|
""";
|
||||||
|
String target = LzTool.fileAjaxTarget(html);
|
||||||
|
assertEquals("https://apifile.lanzouw.com/ajaxfile.php?file=320665771", target);
|
||||||
|
|
||||||
|
List<String> origins = LzTool.fileAjaxOrigins(
|
||||||
|
"https://wwaqg.lanzouu.com/iqouM49vy9ib", target);
|
||||||
|
assertEquals("https://apifile.lanzouw.com", origins.get(0));
|
||||||
|
assertTrue(origins.contains("https://wwaqg.lanzouu.com"));
|
||||||
|
assertEquals("https://wwww.lanzoux.com", origins.get(origins.size() - 1));
|
||||||
|
assertTrue(origins.indexOf("https://apifile.lanzouw.com")
|
||||||
|
< origins.indexOf("https://wwaqg.lanzouu.com"));
|
||||||
|
assertTrue(origins.indexOf("https://wwww.lanzoux.com")
|
||||||
|
> origins.indexOf("https://w1.lanzn.com"));
|
||||||
|
assertEquals(1, origins.stream().filter("https://apifile.lanzouw.com"::equals).count());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void protocolRelativeAjaxBecomesHttps() {
|
||||||
|
String html = "url : '//apifile.lanzouw.com/ajaxfile.php?file=9',";
|
||||||
|
assertEquals("https://apifile.lanzouw.com/ajaxfile.php?file=9", LzTool.fileAjaxTarget(html));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void absoluteAjaxWinsOverRelativePathInSamePage() {
|
||||||
|
String html = """
|
||||||
|
//data : { url : '/ajaxfile.php?file=1' }
|
||||||
|
url : "https://apifile.lanzouw.com/ajaxm.php?file=42",
|
||||||
|
""";
|
||||||
|
assertEquals("https://apifile.lanzouw.com/ajaxm.php?file=42", LzTool.fileAjaxTarget(html));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void relativeFileAjaxFallsThroughToApifileBeforeWwww() {
|
||||||
|
String html = "url : '/ajaxfile.php?file=150233466',";
|
||||||
|
assertEquals("/ajaxfile.php?file=150233466", LzTool.fileAjaxTarget(html));
|
||||||
|
|
||||||
|
List<String> origins = LzTool.fileAjaxOrigins("https://a.lanzouw.com/xxx", LzTool.fileAjaxTarget(html));
|
||||||
|
assertEquals("https://a.lanzouw.com", origins.get(0));
|
||||||
|
assertEquals("https://apifile.lanzouw.com", origins.get(1));
|
||||||
|
assertEquals("https://wwww.lanzoux.com", origins.get(origins.size() - 1));
|
||||||
|
assertTrue(origins.indexOf("https://apifile.lanzouw.com")
|
||||||
|
< origins.indexOf("https://wwww.lanzoux.com"));
|
||||||
|
assertFalse(LzTool.ajaxOrigins("https://a.lanzouw.com/xxx").contains("https://apifile.lanzouw.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void missingAjaxTargetStillPutsApifileBeforeWwww() {
|
||||||
|
List<String> origins = LzTool.fileAjaxOrigins("https://www.lanzoux.com/ihLkw1gezutg", null);
|
||||||
|
assertEquals("https://www.lanzoux.com", origins.get(0));
|
||||||
|
assertEquals("https://apifile.lanzouw.com", origins.get(1));
|
||||||
|
assertEquals("https://wwww.lanzoux.com", origins.get(origins.size() - 1));
|
||||||
|
assertFalse(origins.get(0).contains("wwww"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
|
import cn.qaiu.WebClientVertxInit;
|
||||||
|
import cn.qaiu.entity.FileInfo;
|
||||||
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
|
import io.vertx.core.Vertx;
|
||||||
|
import org.junit.Assume;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对 Issue #282 的分享做一次真实访客解析。
|
||||||
|
* 默认不跑:父 POM surefire skipTests=true,且本测试还要求 -Dquark.live=true。
|
||||||
|
* mvn -pl parser -DskipTests=false -Dquark.live=true -Dtest=QkToolGuestFolderLiveTest test
|
||||||
|
*/
|
||||||
|
public class QkToolGuestFolderLiveTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void issue282FolderShareResolvesAnonymously() throws Exception {
|
||||||
|
Assume.assumeTrue("set -Dquark.live=true to call Quark", Boolean.getBoolean("quark.live"));
|
||||||
|
Vertx vertx = Vertx.vertx();
|
||||||
|
WebClientVertxInit.init(vertx);
|
||||||
|
QkTool tool = null;
|
||||||
|
try {
|
||||||
|
ShareLinkInfo info = ShareLinkInfo.newBuilder()
|
||||||
|
.type("qk")
|
||||||
|
.panName("夸克网盘")
|
||||||
|
.shareKey("d34c18e9d62a")
|
||||||
|
.shareUrl("https://pan.quark.cn/s/d34c18e9d62a")
|
||||||
|
.build();
|
||||||
|
tool = new QkTool(info);
|
||||||
|
String url = tool.parse().toCompletionStage().toCompletableFuture().get(90, TimeUnit.SECONDS);
|
||||||
|
assertNotNull(url);
|
||||||
|
assertTrue(url.startsWith("http"));
|
||||||
|
assertTrue("guest download host, got " + url, url.contains("dl-guest"));
|
||||||
|
FileInfo fileInfo = (FileInfo) info.getOtherParam().get("fileInfo");
|
||||||
|
assertNotNull(fileInfo);
|
||||||
|
assertNotNull(fileInfo.getFileName());
|
||||||
|
assertTrue("expected a small file, got " + fileInfo.getSize() + " " + fileInfo.getFileName(),
|
||||||
|
fileInfo.getSize() > 0 && fileInfo.getSize() < 40L * 1024 * 1024);
|
||||||
|
} finally {
|
||||||
|
if (tool != null) {
|
||||||
|
tool.close();
|
||||||
|
}
|
||||||
|
vertx.close().toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 夸克目录项分类与访客选文件。不访问网络。
|
||||||
|
* 父 POM 的 surefire 默认 skipTests=true,本地验证用:
|
||||||
|
* mvn -pl parser -DskipTests=false -Dtest=QkToolShareListTest test
|
||||||
|
*/
|
||||||
|
public class QkToolShareListTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void folderIsNotADownloadableFile() {
|
||||||
|
JsonObject folder = new JsonObject()
|
||||||
|
.put("file", false)
|
||||||
|
.put("dir", true)
|
||||||
|
.put("file_type", 0)
|
||||||
|
.put("file_name", "BiliTools最新版");
|
||||||
|
assertTrue(QkTool.isQuarkFolder(folder));
|
||||||
|
assertFalse(QkTool.isQuarkFile(folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void archiveAndInstallerCountAsFiles() {
|
||||||
|
JsonObject rar = new JsonObject()
|
||||||
|
.put("file", true)
|
||||||
|
.put("dir", false)
|
||||||
|
.put("file_type", 1)
|
||||||
|
.put("obj_category", "archive")
|
||||||
|
.put("size", 8228);
|
||||||
|
JsonObject exe = new JsonObject()
|
||||||
|
.put("file", true)
|
||||||
|
.put("dir", false)
|
||||||
|
.put("file_type", 1)
|
||||||
|
.put("obj_category", "default")
|
||||||
|
.put("size", 11919276);
|
||||||
|
assertTrue(QkTool.isQuarkFile(rar));
|
||||||
|
assertTrue(QkTool.isQuarkFile(exe));
|
||||||
|
assertFalse(QkTool.isQuarkFolder(rar));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void directoryFlagWinsOverCategory() {
|
||||||
|
JsonObject odd = new JsonObject()
|
||||||
|
.put("file", false)
|
||||||
|
.put("dir", true)
|
||||||
|
.put("file_type", 0)
|
||||||
|
.put("obj_category", "default");
|
||||||
|
assertTrue(QkTool.isQuarkFolder(odd));
|
||||||
|
assertFalse(QkTool.isQuarkFile(odd));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void guestOrderKeepsFirstFileThenSortsTheRestBySize() {
|
||||||
|
JsonObject large = file("large", 90_000_000L);
|
||||||
|
JsonObject mid = file("mid", 20_000_000L);
|
||||||
|
JsonObject small = file("small", 8_000L);
|
||||||
|
List<JsonObject> ordered = QkTool.orderGuestCandidates(List.of(large, mid, small));
|
||||||
|
assertEquals("large", ordered.get(0).getString("file_name"));
|
||||||
|
assertEquals("small", ordered.get(1).getString("file_name"));
|
||||||
|
assertEquals("mid", ordered.get(2).getString("file_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void sizeLimitIsTransferFallbackAndLoginIsNot() {
|
||||||
|
assertTrue(QkTool.needsTransferFallback(new RuntimeException(
|
||||||
|
"分享直链获取失败(code=23018): download file size limit")));
|
||||||
|
assertFalse(QkTool.needsTransferFallback(new RuntimeException("未登录或 Cookie 已失效")));
|
||||||
|
assertTrue(QkTool.isGuestAuthError(new RuntimeException("code=31001 未登录")));
|
||||||
|
assertFalse(QkTool.isGuestAuthError(new RuntimeException("code=23018 size limit")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void violationCodeIsExplainedWithoutFolderWording() {
|
||||||
|
JsonObject json = new JsonObject().put("code", 41010).put("message", "文件涉及违规内容");
|
||||||
|
String msg = QkTool.quarkApiFailure("https://drive-pc.quark.cn/token", json);
|
||||||
|
assertTrue(msg.contains("违规"));
|
||||||
|
assertTrue(msg.contains("41010"));
|
||||||
|
assertFalse(msg.contains("都是文件夹"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void zeroBytePlaceholderIsDroppedWhenARealFileExists() {
|
||||||
|
JsonObject placeholder = file("等待更新", 0L);
|
||||||
|
JsonObject real = file("notes.rar", 8228L);
|
||||||
|
List<JsonObject> kept = QkTool.preferSizedFiles(List.of(placeholder, real));
|
||||||
|
assertEquals(1, kept.size());
|
||||||
|
assertEquals("notes.rar", kept.get(0).getString("file_name"));
|
||||||
|
assertTrue(QkTool.hasPositiveSize(List.of(real)));
|
||||||
|
assertFalse(QkTool.hasPositiveSize(List.of(placeholder)));
|
||||||
|
assertEquals(1, QkTool.preferSizedFiles(List.of(placeholder)).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void oversizeHintPointsAtCookie() {
|
||||||
|
assertTrue(QkTool.GUEST_OVERSIZE_MSG.contains("auths.qk.cookie"));
|
||||||
|
assertTrue(QkTool.GUEST_OVERSIZE_MSG.contains("23018"));
|
||||||
|
assertFalse(QkTool.GUEST_OVERSIZE_MSG.contains("都是文件夹"));
|
||||||
|
assertTrue(QkTool.GUEST_LOGIN_MSG.contains("auths.qk.cookie"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject file(String name, long size) {
|
||||||
|
return new JsonObject().put("file_name", name).put("size", size).put("file", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 123 登录提示。不访问网络。
|
||||||
|
* mvn -pl parser -DskipTests=false -Dtest=Ye2LoginHintTest test
|
||||||
|
*/
|
||||||
|
public class Ye2LoginHintTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void loginCodesAndMessagesAreRecognized() {
|
||||||
|
assertTrue(Ye2Tool.isYeLoginRequired(5112, "您需要注册登录或付费后下载"));
|
||||||
|
assertTrue(Ye2Tool.isYeLoginRequired(401, "请登录后下载"));
|
||||||
|
assertTrue(Ye2Tool.isYeLoginRequired(0, "需要登录才能下载"));
|
||||||
|
assertFalse(Ye2Tool.isYeLoginRequired(0, "ok"));
|
||||||
|
assertFalse(Ye2Tool.isYeLoginRequired(null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void missingAuthTellsUserToConfigureLocalAuthorization() {
|
||||||
|
String msg = Ye2Tool.loginRequiredDownloadMessage(false, null);
|
||||||
|
assertTrue(msg.contains("auths.ye.authorization"));
|
||||||
|
assertTrue(msg.contains("5112"));
|
||||||
|
assertTrue(msg.contains("公共云解析"));
|
||||||
|
assertTrue(msg.contains("ye:分享key@提取码"));
|
||||||
|
assertFalse(msg.contains("验证码求解"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void configuredAuthStillPointsAtLocalTokenWhenUpstreamRejects() {
|
||||||
|
String msg = Ye2Tool.loginRequiredDownloadMessage(true, "请登录后下载");
|
||||||
|
assertTrue(msg.contains("auths.ye.authorization"));
|
||||||
|
assertTrue(msg.contains("上游信息: 请登录后下载"));
|
||||||
|
assertFalse(msg.contains("{"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>0.4.3</revision>
|
<revision>0.4.8</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>
|
||||||
|
|||||||
@@ -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,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "nfd-web",
|
"name": "nfd-web",
|
||||||
"version": "0.4.2",
|
"version": "0.4.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"serve": "vue-cli-service serve",
|
||||||
"dev": "vue-cli-service serve",
|
"dev": "vue-cli-service serve",
|
||||||
"build": "node scripts/sync-version.js && vue-cli-service build && node scripts/compress-vs.js",
|
"build": "node scripts/sync-version.js && vue-cli-service build && node scripts/compress-vs.js",
|
||||||
"build:no-compress": "node scripts/sync-version.js && vue-cli-service build",
|
"build:no-compress": "node scripts/sync-version.js && vue-cli-service build",
|
||||||
"lint": "vue-cli-service lint"
|
"lint": "vue-cli-service lint",
|
||||||
|
"test": "node --test src/utils/batchTreeCollect.spec.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
|||||||
@@ -103,7 +103,23 @@
|
|||||||
<splitpanes v-if="!isMobile" class="split-theme custom-splitpanes" style="height:100%;">
|
<splitpanes v-if="!isMobile" class="split-theme custom-splitpanes" style="height:100%;">
|
||||||
<pane>
|
<pane>
|
||||||
<div class="tree-sidebar">
|
<div class="tree-sidebar">
|
||||||
<div class="tree-scroll-wrap">
|
<div class="tree-toolbar">
|
||||||
|
<span class="tree-toolbar-title">
|
||||||
|
<i class="fas fa-sitemap" aria-hidden="true"></i> 文件树
|
||||||
|
</span>
|
||||||
|
<el-button
|
||||||
|
v-if="!batchMode"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
@click="toggleBatchMode"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check-double"></i> 批量下载
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="tree-toolbar-hint">
|
||||||
|
{{ treeExpanding ? '正在展开并收集文件…' : `勾选文件夹将自动展开(最多 ${effectiveBatchMaxDepth} 层)` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="tree-scroll-wrap" v-loading="treeExpanding">
|
||||||
<el-tree
|
<el-tree
|
||||||
ref="fileTree"
|
ref="fileTree"
|
||||||
:data="treeData"
|
:data="treeData"
|
||||||
@@ -115,6 +131,7 @@
|
|||||||
:show-checkbox="batchMode"
|
:show-checkbox="batchMode"
|
||||||
:check-strictly="false"
|
:check-strictly="false"
|
||||||
@node-click="onNodeClick"
|
@node-click="onNodeClick"
|
||||||
|
@check="onTreeCheck"
|
||||||
@check-change="onTreeCheckChange"
|
@check-change="onTreeCheckChange"
|
||||||
:default-expand-all="false"
|
:default-expand-all="false"
|
||||||
:default-expanded-keys="['root']"
|
:default-expanded-keys="['root']"
|
||||||
@@ -128,7 +145,7 @@
|
|||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled"
|
:disabled="selectedFiles.length === 0 || batchDownloading || treeExpanding || batchBrowserDownloadDisabled"
|
||||||
:loading="batchDownloading"
|
:loading="batchDownloading"
|
||||||
@click="batchBrowserDownload"
|
@click="batchBrowserDownload"
|
||||||
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
|
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
|
||||||
@@ -138,7 +155,7 @@
|
|||||||
<el-button
|
<el-button
|
||||||
type="success"
|
type="success"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="selectedFiles.length === 0 || batchDownloading"
|
:disabled="selectedFiles.length === 0 || batchDownloading || treeExpanding"
|
||||||
:loading="batchDownloading"
|
:loading="batchDownloading"
|
||||||
@click="batchSendToDownloader"
|
@click="batchSendToDownloader"
|
||||||
>
|
>
|
||||||
@@ -236,7 +253,23 @@
|
|||||||
<!-- 移动端:上下布局 -->
|
<!-- 移动端:上下布局 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="mobile-tree-top">
|
<div class="mobile-tree-top">
|
||||||
<div class="tree-scroll-wrap">
|
<div class="tree-toolbar">
|
||||||
|
<span class="tree-toolbar-title">
|
||||||
|
<i class="fas fa-sitemap" aria-hidden="true"></i> 文件树
|
||||||
|
</span>
|
||||||
|
<el-button
|
||||||
|
v-if="!batchMode"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
@click="toggleBatchMode"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check-double"></i> 批量下载
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="tree-toolbar-hint">
|
||||||
|
{{ treeExpanding ? '正在展开并收集文件…' : `勾选文件夹将自动展开(最多 ${effectiveBatchMaxDepth} 层)` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="tree-scroll-wrap" v-loading="treeExpanding">
|
||||||
<el-tree
|
<el-tree
|
||||||
ref="fileTree"
|
ref="fileTree"
|
||||||
:data="treeData"
|
:data="treeData"
|
||||||
@@ -248,6 +281,7 @@
|
|||||||
:show-checkbox="batchMode"
|
:show-checkbox="batchMode"
|
||||||
:check-strictly="false"
|
:check-strictly="false"
|
||||||
@node-click="onNodeClick"
|
@node-click="onNodeClick"
|
||||||
|
@check="onTreeCheck"
|
||||||
@check-change="onTreeCheckChange"
|
@check-change="onTreeCheckChange"
|
||||||
:default-expand-all="false"
|
:default-expand-all="false"
|
||||||
:default-expanded-keys="['root']"
|
:default-expanded-keys="['root']"
|
||||||
@@ -328,8 +362,8 @@
|
|||||||
<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 || batchBrowserDownloadDisabled" :loading="batchDownloading" @click="batchBrowserDownload" :title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''">浏览器下载</el-button>
|
<el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading || treeExpanding || 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 || treeExpanding" :loading="batchDownloading" @click="batchSendToDownloader">发送到下载器</el-button>
|
||||||
<el-button size="small" @click="toggleBatchMode">取消</el-button>
|
<el-button size="small" @click="toggleBatchMode">取消</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="batchDownloading" class="batch-progress-info">
|
<div v-if="batchDownloading" class="batch-progress-info">
|
||||||
@@ -420,6 +454,15 @@ import 'splitpanes/dist/splitpanes.css'
|
|||||||
import fileTypeUtils from '@/utils/fileTypeUtils'
|
import fileTypeUtils from '@/utils/fileTypeUtils'
|
||||||
import DownloadDialog from '@/components/DownloadDialog.vue'
|
import DownloadDialog from '@/components/DownloadDialog.vue'
|
||||||
import { testConnection, autoDetect, addDownload, batchAddDownload, getConfig, saveConfig } from '@/utils/downloaderService'
|
import { testConnection, autoDetect, addDownload, batchAddDownload, getConfig, saveConfig } from '@/utils/downloaderService'
|
||||||
|
import batchTreeCollect from '@/utils/batchTreeCollect'
|
||||||
|
|
||||||
|
const {
|
||||||
|
DEFAULT_BATCH_MAX_DEPTH,
|
||||||
|
getTreeNodeId,
|
||||||
|
normalizeTreeItem,
|
||||||
|
uniqueFiles,
|
||||||
|
collectFolderFiles
|
||||||
|
} = batchTreeCollect
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'DirectoryTree',
|
name: 'DirectoryTree',
|
||||||
@@ -445,6 +488,11 @@ export default {
|
|||||||
viewMode: {
|
viewMode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'pane' // 'pane' or 'tree'
|
default: 'pane' // 'pane' or 'tree'
|
||||||
|
},
|
||||||
|
// 树批量勾选文件夹时的最大展开深度;用户勾选的文件夹为第 0 层,默认 5
|
||||||
|
batchMaxDepth: {
|
||||||
|
type: Number,
|
||||||
|
default: DEFAULT_BATCH_MAX_DEPTH
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -477,7 +525,8 @@ export default {
|
|||||||
batchMode: false,
|
batchMode: false,
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
batchDownloading: false,
|
batchDownloading: false,
|
||||||
batchProgress: { current: 0, total: 0, failed: 0 }
|
batchProgress: { current: 0, total: 0, failed: 0 },
|
||||||
|
treeExpanding: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -513,6 +562,10 @@ export default {
|
|||||||
batchBrowserDownloadDisabled() {
|
batchBrowserDownloadDisabled() {
|
||||||
return this.selectedFiles.length > 0
|
return this.selectedFiles.length > 0
|
||||||
&& this.selectedFiles.every(f => this.needsDownloader(f))
|
&& this.selectedFiles.every(f => this.needsDownloader(f))
|
||||||
|
},
|
||||||
|
effectiveBatchMaxDepth() {
|
||||||
|
const depth = Number(this.batchMaxDepth)
|
||||||
|
return Number.isFinite(depth) && depth >= 0 ? depth : DEFAULT_BATCH_MAX_DEPTH
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -525,10 +578,7 @@ export default {
|
|||||||
id: 'root',
|
id: 'root',
|
||||||
fileName: '全部文件',
|
fileName: '全部文件',
|
||||||
fileType: 'folder',
|
fileType: 'folder',
|
||||||
children: (newList || []).map(item => ({
|
children: (newList || []).map(item => normalizeTreeItem(item)),
|
||||||
...item,
|
|
||||||
isLeaf: item.fileType !== 'folder'
|
|
||||||
})),
|
|
||||||
isLeaf: false
|
isLeaf: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -536,6 +586,9 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
created() {
|
||||||
|
this.resetTreeCollectState()
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...fileTypeUtils,
|
...fileTypeUtils,
|
||||||
apiKeyHeaders() {
|
apiKeyHeaders() {
|
||||||
@@ -563,17 +616,14 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.withAuth(`${baseUrl}?${params.toString()}`)
|
return this.withAuth(`${baseUrl}?${params.toString()}`)
|
||||||
},
|
},
|
||||||
// 懒加载子节点
|
// 懒加载子节点(批量勾选文件夹时会通过 node.expand → store.load 复用此方法)
|
||||||
loadNode(node, resolve) {
|
loadNode(node, resolve) {
|
||||||
if (node.level === 0) {
|
if (node.level === 0) {
|
||||||
resolve(this.treeData[0].children)
|
resolve((this.treeData[0]?.children || []).map(item => normalizeTreeItem(item)))
|
||||||
} else if (node.data.fileType === 'folder' && node.data.parserUrl) {
|
} else if (node.data.fileType === 'folder' && node.data.parserUrl) {
|
||||||
axios.get(this.withAuth(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 => normalizeTreeItem(item))
|
||||||
...item,
|
|
||||||
isLeaf: item.fileType !== 'folder'
|
|
||||||
}))
|
|
||||||
resolve(children)
|
resolve(children)
|
||||||
} else {
|
} else {
|
||||||
this.$message.error(res.data.msg || '获取子节点失败')
|
this.$message.error(res.data.msg || '获取子节点失败')
|
||||||
@@ -970,9 +1020,156 @@ export default {
|
|||||||
this.selectedFiles = []
|
this.selectedFiles = []
|
||||||
this.batchDownloading = false
|
this.batchDownloading = false
|
||||||
this.batchProgress = { current: 0, total: 0, failed: 0 }
|
this.batchProgress = { current: 0, total: 0, failed: 0 }
|
||||||
// tree 模式下清除勾选
|
this.treeExpanding = false
|
||||||
if (this.$refs.fileTree) {
|
this.resetTreeCollectState()
|
||||||
this.$refs.fileTree.setCheckedKeys([])
|
const tree = this.getFileTree()
|
||||||
|
if (tree) {
|
||||||
|
tree.setCheckedKeys([])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetTreeCollectState() {
|
||||||
|
this._folderCheckQueue = []
|
||||||
|
this._queuedFolderKeys = new Set()
|
||||||
|
this._visitedInCollect = new Set()
|
||||||
|
this._folderLoadPromises = new Map()
|
||||||
|
this._treeCollecting = false
|
||||||
|
},
|
||||||
|
getFileTree() {
|
||||||
|
const ref = this.$refs.fileTree
|
||||||
|
if (!ref) return null
|
||||||
|
return Array.isArray(ref) ? ref[0] : ref
|
||||||
|
},
|
||||||
|
findTreeNode(data) {
|
||||||
|
const tree = this.getFileTree()
|
||||||
|
if (!tree || !data) return null
|
||||||
|
const key = getTreeNodeId(data)
|
||||||
|
if (key) {
|
||||||
|
const byKey = tree.getNode(key)
|
||||||
|
if (byKey) return byKey
|
||||||
|
}
|
||||||
|
const byData = tree.getNode(data)
|
||||||
|
if (byData) return byData
|
||||||
|
const nodesMap = tree.store?.nodesMap
|
||||||
|
if (nodesMap) {
|
||||||
|
return Object.values(nodesMap).find(node => getTreeNodeId(node.data) === key) || null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
ensureTreeNodeLoaded(node) {
|
||||||
|
if (!node) return Promise.resolve(null)
|
||||||
|
const key = getTreeNodeId(node.data)
|
||||||
|
if (key && this._folderLoadPromises.has(key)) {
|
||||||
|
return this._folderLoadPromises.get(key)
|
||||||
|
}
|
||||||
|
const promise = new Promise((resolve) => {
|
||||||
|
if (node.loaded && node.expanded) {
|
||||||
|
resolve(node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let settled = false
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(node)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(finish, 20000)
|
||||||
|
const expandNow = () => {
|
||||||
|
if (node.loaded && node.expanded) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
node.expand(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
finish()
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.loading) {
|
||||||
|
const started = Date.now()
|
||||||
|
const poll = () => {
|
||||||
|
if (!node.loading || Date.now() - started > 20000) {
|
||||||
|
expandNow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTimeout(poll, 50)
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expandNow()
|
||||||
|
})
|
||||||
|
if (key) this._folderLoadPromises.set(key, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
isCheckedInInfo(data, info) {
|
||||||
|
if (!info) return false
|
||||||
|
const key = getTreeNodeId(data)
|
||||||
|
if (key && (info.checkedKeys || []).map(String).includes(key)) return true
|
||||||
|
return (info.checkedNodes || []).some(n => getTreeNodeId(n) === key)
|
||||||
|
},
|
||||||
|
syncSelectedFilesFromTree(extraFiles = []) {
|
||||||
|
const tree = this.getFileTree()
|
||||||
|
const fromTree = tree
|
||||||
|
? tree.getCheckedNodes().filter(n => this.isDownloadableFile(n))
|
||||||
|
: []
|
||||||
|
this.selectedFiles = uniqueFiles([...fromTree, ...extraFiles])
|
||||||
|
},
|
||||||
|
enqueueFolderCollect(folderData) {
|
||||||
|
const key = getTreeNodeId(folderData)
|
||||||
|
if (!key || this._queuedFolderKeys.has(key) || this._visitedInCollect.has(key)) return
|
||||||
|
this._queuedFolderKeys.add(key)
|
||||||
|
this._folderCheckQueue.push(folderData)
|
||||||
|
this.drainFolderCollectQueue()
|
||||||
|
},
|
||||||
|
async drainFolderCollectQueue() {
|
||||||
|
if (this._treeCollecting) return
|
||||||
|
this._treeCollecting = true
|
||||||
|
this.treeExpanding = true
|
||||||
|
try {
|
||||||
|
while (this._folderCheckQueue.length) {
|
||||||
|
const next = this._folderCheckQueue.shift()
|
||||||
|
this._queuedFolderKeys.delete(getTreeNodeId(next))
|
||||||
|
await this.handleFolderCheck(next)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._treeCollecting = false
|
||||||
|
this.treeExpanding = false
|
||||||
|
this._visitedInCollect.clear()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async handleFolderCheck(folderData) {
|
||||||
|
try {
|
||||||
|
const ctx = await collectFolderFiles({
|
||||||
|
folderData,
|
||||||
|
depth: 0,
|
||||||
|
maxDepth: this.effectiveBatchMaxDepth,
|
||||||
|
getNode: (data) => this.findTreeNode(data),
|
||||||
|
ensureLoaded: (node) => this.ensureTreeNodeLoaded(node),
|
||||||
|
isDownloadable: (data) => this.isDownloadableFile(data),
|
||||||
|
visitedKeys: this._visitedInCollect
|
||||||
|
})
|
||||||
|
this.syncSelectedFilesFromTree(ctx.files)
|
||||||
|
if (ctx.depthExceeded) {
|
||||||
|
this.$message.warning(
|
||||||
|
`已达到最大展开深度(${this.effectiveBatchMaxDepth} 层),部分深层文件未勾选`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('批量展开文件夹失败:', e)
|
||||||
|
this.$message.error('展开文件夹失败,已跳过部分内容')
|
||||||
|
this.syncSelectedFilesFromTree()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 用户点击复选框(不含级联),文件夹勾选时触发递归展开
|
||||||
|
onTreeCheck(data, info) {
|
||||||
|
if (!this.batchMode || !data || data.fileType !== 'folder') return
|
||||||
|
if (this.isCheckedInInfo(data, info)) {
|
||||||
|
this.enqueueFolderCollect(data)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isFileSelected(file) {
|
isFileSelected(file) {
|
||||||
@@ -988,6 +1185,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBatchClick(file) {
|
onBatchClick(file) {
|
||||||
|
// 窗格批量:点击文件夹仍进入该目录(避免破坏现有导航)。
|
||||||
|
// 仅收集当前目录文件;递归展开只在树模式勾选文件夹时进行。
|
||||||
if (file.fileType === 'folder') {
|
if (file.fileType === 'folder') {
|
||||||
this.enterFolder(file)
|
this.enterFolder(file)
|
||||||
return
|
return
|
||||||
@@ -1001,9 +1200,8 @@ export default {
|
|||||||
this.selectedFiles = []
|
this.selectedFiles = []
|
||||||
},
|
},
|
||||||
onTreeCheckChange() {
|
onTreeCheckChange() {
|
||||||
if (!this.$refs.fileTree) return
|
if (!this.batchMode || this._treeCollecting) return
|
||||||
const checked = this.$refs.fileTree.getCheckedNodes()
|
this.syncSelectedFilesFromTree()
|
||||||
this.selectedFiles = checked.filter(n => this.isDownloadableFile(n))
|
|
||||||
},
|
},
|
||||||
extractTypeParam(file) {
|
extractTypeParam(file) {
|
||||||
if (!file.parserUrl) return null
|
if (!file.parserUrl) return null
|
||||||
@@ -1762,6 +1960,48 @@ html, body, #app, .main-container, .directory-tree, .content-card {
|
|||||||
border-right: 1px solid #eaeaea;
|
border-right: 1px solid #eaeaea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #eaeaea;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-theme .tree-toolbar {
|
||||||
|
border-bottom-color: #404040;
|
||||||
|
background: #232323;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toolbar-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-theme .tree-toolbar-title {
|
||||||
|
color: #e1e1e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toolbar-hint {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-theme .tree-toolbar-hint {
|
||||||
|
color: #bdc3c7;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-content {
|
.tree-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<el-radio-group
|
||||||
|
:model-value="modelValue"
|
||||||
|
size="small"
|
||||||
|
class="view-mode-switch"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<el-radio-button label="pane" title="窗格视图">
|
||||||
|
<i class="fas fa-th" aria-hidden="true"></i>
|
||||||
|
<span class="view-mode-text">{{ paneLabel }}</span>
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button label="tree" title="文件树视图">
|
||||||
|
<i class="fas fa-sitemap" aria-hidden="true"></i>
|
||||||
|
<span class="view-mode-text">{{ treeLabel }}</span>
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ViewModeSwitch',
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: 'pane'
|
||||||
|
},
|
||||||
|
paneLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '窗格'
|
||||||
|
},
|
||||||
|
treeLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '文件树'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.view-mode-switch :deep(.el-radio-button__inner) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 36px;
|
||||||
|
min-width: 76px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.view-mode-switch i {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.view-mode-text {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.view-mode-switch :deep(.el-radio-button__inner) {
|
||||||
|
min-width: 68px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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云盘',
|
||||||
@@ -377,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 {
|
||||||
@@ -390,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) {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* 文件树批量勾选:按深度递归展开并收集可下载文件。
|
||||||
|
*
|
||||||
|
* Depth convention (checked folder = depth 0):
|
||||||
|
* - 用户勾选的文件夹为第 0 层。
|
||||||
|
* - 其直接子项为第 1 层。
|
||||||
|
* - 当 depth < maxDepth 时继续展开子文件夹;默认 maxDepth = 5,
|
||||||
|
* 即最多再向下展开 5 层(第 0/1/2/3/4 层会 load,第 5 层文件夹不再展开)。
|
||||||
|
* - 触及上限时若仍有未加载/剩余子文件夹,标记 depthExceeded,由 UI 提示跳过。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_BATCH_MAX_DEPTH = 5
|
||||||
|
|
||||||
|
function getTreeNodeId(item) {
|
||||||
|
if (!item) return ''
|
||||||
|
if (item.id) return String(item.id)
|
||||||
|
if (item.fileId) return `fid:${item.fileId}`
|
||||||
|
if (item.parserUrl) return `url:${item.parserUrl}`
|
||||||
|
return `name:${item.fileName || ''}:${item.fileType || ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTreeItem(item) {
|
||||||
|
if (!item || typeof item !== 'object') return item
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: getTreeNodeId(item),
|
||||||
|
isLeaf: item.fileType !== 'folder'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIdentity(file) {
|
||||||
|
return `${file?.parserUrl || ''}::${file?.fileName || ''}::${file?.fileId || file?.id || ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueFiles(files) {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
for (const file of files || []) {
|
||||||
|
const key = fileIdentity(file)
|
||||||
|
if (!key || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(file)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFolderNode(data) {
|
||||||
|
return !!(data && data.fileType === 'folder')
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAlreadyLoadedFiles(node, isDownloadable, ctx) {
|
||||||
|
for (const child of node.childNodes || []) {
|
||||||
|
if (isDownloadable(child.data)) {
|
||||||
|
ctx.files.push(child.data)
|
||||||
|
} else if (isFolderNode(child.data) && child.loaded) {
|
||||||
|
collectAlreadyLoadedFiles(child, isDownloadable, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnexploredFolders(node) {
|
||||||
|
if (!node) return false
|
||||||
|
if (isFolderNode(node.data) && !node.loaded) return true
|
||||||
|
return (node.childNodes || []).some((child) => {
|
||||||
|
if (!isFolderNode(child.data)) return false
|
||||||
|
if (!child.loaded) return true
|
||||||
|
return hasUnexploredFolders(child)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归展开文件夹并收集可下载文件(对接 el-tree node / loadNode)。
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {object} opts.folderData 勾选的文件夹 data(depth 从 0 计)
|
||||||
|
* @param {number} [opts.depth=0]
|
||||||
|
* @param {number} opts.maxDepth
|
||||||
|
* @param {(data: object) => any} opts.getNode el-tree getNode
|
||||||
|
* @param {(node: any) => Promise<any>} opts.ensureLoaded 展开并触发懒加载
|
||||||
|
* @param {(data: object) => boolean} opts.isDownloadable
|
||||||
|
* @param {Set<string>} [opts.visitedKeys]
|
||||||
|
* @param {(data: object) => string} [opts.getKey]
|
||||||
|
* @param {{ files: object[], depthExceeded: boolean }} [opts.ctx]
|
||||||
|
*/
|
||||||
|
async function collectFolderFiles(opts) {
|
||||||
|
const {
|
||||||
|
folderData,
|
||||||
|
depth = 0,
|
||||||
|
maxDepth,
|
||||||
|
getNode,
|
||||||
|
ensureLoaded,
|
||||||
|
isDownloadable,
|
||||||
|
getKey = getTreeNodeId,
|
||||||
|
visitedKeys = new Set(),
|
||||||
|
ctx = { files: [], depthExceeded: false }
|
||||||
|
} = opts
|
||||||
|
|
||||||
|
if (!folderData || typeof maxDepth !== 'number') return ctx
|
||||||
|
|
||||||
|
const key = getKey(folderData)
|
||||||
|
if (key) visitedKeys.add(key)
|
||||||
|
|
||||||
|
const node = getNode(folderData)
|
||||||
|
if (!node) return ctx
|
||||||
|
|
||||||
|
if (depth >= maxDepth) {
|
||||||
|
// 未 load 的节点在真实 el-tree 中没有 childNodes,不能把预置子项算进去
|
||||||
|
if (node.loaded) {
|
||||||
|
collectAlreadyLoadedFiles(node, isDownloadable, ctx)
|
||||||
|
if (hasUnexploredFolders(node)) {
|
||||||
|
ctx.depthExceeded = true
|
||||||
|
}
|
||||||
|
} else if (isFolderNode(node.data)) {
|
||||||
|
ctx.depthExceeded = true
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureLoaded(node)
|
||||||
|
} catch {
|
||||||
|
// 单层加载失败:跳过该分支,不中断整次收集
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of node.childNodes || []) {
|
||||||
|
const data = child.data
|
||||||
|
if (!data) continue
|
||||||
|
if (isDownloadable(data)) {
|
||||||
|
ctx.files.push(data)
|
||||||
|
} else if (isFolderNode(data)) {
|
||||||
|
await collectFolderFiles({
|
||||||
|
...opts,
|
||||||
|
folderData: data,
|
||||||
|
depth: depth + 1,
|
||||||
|
visitedKeys,
|
||||||
|
ctx
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_BATCH_MAX_DEPTH,
|
||||||
|
getTreeNodeId,
|
||||||
|
normalizeTreeItem,
|
||||||
|
fileIdentity,
|
||||||
|
uniqueFiles,
|
||||||
|
isFolderNode,
|
||||||
|
collectFolderFiles
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
const { describe, it } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const {
|
||||||
|
DEFAULT_BATCH_MAX_DEPTH,
|
||||||
|
getTreeNodeId,
|
||||||
|
normalizeTreeItem,
|
||||||
|
fileIdentity,
|
||||||
|
uniqueFiles,
|
||||||
|
collectFolderFiles
|
||||||
|
} = require('./batchTreeCollect.js')
|
||||||
|
|
||||||
|
function file(name, extra = {}) {
|
||||||
|
return {
|
||||||
|
fileName: name,
|
||||||
|
fileType: 'file',
|
||||||
|
parserUrl: `/v2/redirectUrl/demo/${name}`,
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function folder(name, extra = {}) {
|
||||||
|
return {
|
||||||
|
fileName: name,
|
||||||
|
fileType: 'folder',
|
||||||
|
parserUrl: `/v2/getFileList?dir=${name}`,
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNode(data, children = [], { loaded = true, expanded = true, failLoad = false } = {}) {
|
||||||
|
const node = {
|
||||||
|
data,
|
||||||
|
loaded,
|
||||||
|
expanded,
|
||||||
|
childNodes: [],
|
||||||
|
expand(cb) {
|
||||||
|
if (failLoad) throw new Error('load failed')
|
||||||
|
this.loaded = true
|
||||||
|
this.expanded = true
|
||||||
|
if (cb) cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.childNodes = children.map((child) => (child.data ? child : makeNode(child)))
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStore(rootNode) {
|
||||||
|
const map = new Map()
|
||||||
|
const walk = (node) => {
|
||||||
|
map.set(getTreeNodeId(node.data), node)
|
||||||
|
for (const child of node.childNodes || []) walk(child)
|
||||||
|
}
|
||||||
|
walk(rootNode)
|
||||||
|
return {
|
||||||
|
getNode(data) {
|
||||||
|
return map.get(getTreeNodeId(data)) || null
|
||||||
|
},
|
||||||
|
async ensureLoaded(node) {
|
||||||
|
if (node.loaded && node.expanded) return node
|
||||||
|
await new Promise((resolve) => node.expand(resolve))
|
||||||
|
for (const child of node.childNodes || []) {
|
||||||
|
map.set(getTreeNodeId(child.data), child)
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
},
|
||||||
|
isDownloadable(data) {
|
||||||
|
return !!(data && data.parserUrl && data.fileType !== 'folder' && data.fileType !== 'url')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('batchTreeCollect helpers', () => {
|
||||||
|
it('uses checked folder as depth 0 and defaults max depth to 5', () => {
|
||||||
|
assert.equal(DEFAULT_BATCH_MAX_DEPTH, 5)
|
||||||
|
assert.equal(getTreeNodeId({ id: 'root' }), 'root')
|
||||||
|
assert.equal(normalizeTreeItem({ fileId: 'abc', fileType: 'folder' }).id, 'fid:abc')
|
||||||
|
assert.equal(normalizeTreeItem({ fileId: 'abc', fileType: 'folder' }).isLeaf, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates files by parserUrl + name + id', () => {
|
||||||
|
const a = file('a.txt', { fileId: '1' })
|
||||||
|
const dup = file('a.txt', { fileId: '1' })
|
||||||
|
const b = file('b.txt', { fileId: '2' })
|
||||||
|
assert.equal(fileIdentity(a), fileIdentity(dup))
|
||||||
|
assert.deepEqual(uniqueFiles([a, dup, b]).map((f) => f.fileName), ['a.txt', 'b.txt'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collects current-level files and recurses into folders', async () => {
|
||||||
|
const leafA = file('a.txt')
|
||||||
|
const leafB = file('b.txt')
|
||||||
|
const nested = folder('nested')
|
||||||
|
const root = folder('root')
|
||||||
|
const store = createStore(makeNode(root, [
|
||||||
|
makeNode(leafA),
|
||||||
|
makeNode(nested, [makeNode(leafB)])
|
||||||
|
]))
|
||||||
|
|
||||||
|
const ctx = await collectFolderFiles({
|
||||||
|
folderData: root,
|
||||||
|
maxDepth: 4,
|
||||||
|
...store
|
||||||
|
})
|
||||||
|
assert.equal(ctx.depthExceeded, false)
|
||||||
|
assert.deepEqual(ctx.files.map((f) => f.fileName).sort(), ['a.txt', 'b.txt'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('default depth 5 expands four descendant folders then stops', async () => {
|
||||||
|
// depth 0 root → 1 d1 → 2 d2 → 3 d3 → 4 d4 → 5 d5 (cap, still has leftover folder)
|
||||||
|
const skipped = file('skipped.txt')
|
||||||
|
const capFile = file('cap.txt')
|
||||||
|
const d5 = folder('d5')
|
||||||
|
const d4 = folder('d4')
|
||||||
|
const d3 = folder('d3')
|
||||||
|
const d2 = folder('d2')
|
||||||
|
const d1 = folder('d1')
|
||||||
|
const root = folder('root')
|
||||||
|
const tree = makeNode(root, [
|
||||||
|
makeNode(d1, [
|
||||||
|
makeNode(d2, [
|
||||||
|
makeNode(d3, [
|
||||||
|
makeNode(d4, [
|
||||||
|
makeNode(d5, [
|
||||||
|
makeNode(capFile),
|
||||||
|
makeNode(folder('d6'), [makeNode(skipped)], { loaded: false, expanded: false })
|
||||||
|
], { loaded: true })
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
const store = createStore(tree)
|
||||||
|
const loadedIds = []
|
||||||
|
const ctx = await collectFolderFiles({
|
||||||
|
folderData: root,
|
||||||
|
maxDepth: DEFAULT_BATCH_MAX_DEPTH,
|
||||||
|
getNode: store.getNode,
|
||||||
|
ensureLoaded: async (node) => {
|
||||||
|
loadedIds.push(node.data.fileName)
|
||||||
|
return store.ensureLoaded(node)
|
||||||
|
},
|
||||||
|
isDownloadable: store.isDownloadable
|
||||||
|
})
|
||||||
|
assert.equal(ctx.depthExceeded, true)
|
||||||
|
assert.deepEqual(ctx.files.map((f) => f.fileName), ['cap.txt'])
|
||||||
|
assert.ok(!ctx.files.some((f) => f.fileName === 'skipped.txt'))
|
||||||
|
assert.ok(loadedIds.includes('d4'))
|
||||||
|
assert.ok(!loadedIds.includes('d5'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops expanding at maxDepth and warns when remaining folders exist', async () => {
|
||||||
|
// depth 0: root
|
||||||
|
// depth 1: d1
|
||||||
|
// depth 2: d2
|
||||||
|
// depth 3: d3
|
||||||
|
// depth 4: d4 (cap when maxDepth=4) — still has child folder d5
|
||||||
|
const deepFile = file('deep.txt')
|
||||||
|
const skipped = file('skipped.txt')
|
||||||
|
const d5 = folder('d5')
|
||||||
|
const d4 = folder('d4')
|
||||||
|
const d3 = folder('d3')
|
||||||
|
const d2 = folder('d2')
|
||||||
|
const d1 = folder('d1')
|
||||||
|
const root = folder('root')
|
||||||
|
|
||||||
|
const d5Node = makeNode(d5, [makeNode(skipped)], { loaded: false, expanded: false })
|
||||||
|
const d4Node = makeNode(d4, [makeNode(deepFile), d5Node], { loaded: true })
|
||||||
|
const tree = makeNode(root, [
|
||||||
|
makeNode(d1, [
|
||||||
|
makeNode(d2, [
|
||||||
|
makeNode(d3, [d4Node])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
const store = createStore(tree)
|
||||||
|
|
||||||
|
let loadedIds = []
|
||||||
|
const ctx = await collectFolderFiles({
|
||||||
|
folderData: root,
|
||||||
|
maxDepth: 4,
|
||||||
|
getNode: store.getNode,
|
||||||
|
ensureLoaded: async (node) => {
|
||||||
|
loadedIds.push(node.data.fileName)
|
||||||
|
return store.ensureLoaded(node)
|
||||||
|
},
|
||||||
|
isDownloadable: store.isDownloadable
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(ctx.depthExceeded, true)
|
||||||
|
assert.deepEqual(ctx.files.map((f) => f.fileName), ['deep.txt'])
|
||||||
|
assert.ok(!ctx.files.some((f) => f.fileName === 'skipped.txt'))
|
||||||
|
assert.ok(!loadedIds.includes('d4'), 'depth-4 folder must not be expanded')
|
||||||
|
assert.ok(loadedIds.includes('root'))
|
||||||
|
assert.ok(loadedIds.includes('d3'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collects already-loaded files at the depth cap without expanding further', async () => {
|
||||||
|
const capFile = file('cap.txt')
|
||||||
|
const inner = folder('inner')
|
||||||
|
const cap = folder('cap')
|
||||||
|
const root = folder('root')
|
||||||
|
const tree = makeNode(root, [
|
||||||
|
makeNode(cap, [
|
||||||
|
makeNode(capFile),
|
||||||
|
makeNode(inner, [makeNode(file('too-deep.txt'))], { loaded: false })
|
||||||
|
])
|
||||||
|
])
|
||||||
|
const store = createStore(tree)
|
||||||
|
const ctx = await collectFolderFiles({
|
||||||
|
folderData: root,
|
||||||
|
maxDepth: 1,
|
||||||
|
...store
|
||||||
|
})
|
||||||
|
assert.equal(ctx.depthExceeded, true)
|
||||||
|
assert.deepEqual(ctx.files.map((f) => f.fileName), ['cap.txt'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips a branch when ensureLoaded throws', async () => {
|
||||||
|
const ok = file('ok.txt')
|
||||||
|
const badFolder = folder('bad')
|
||||||
|
const root = folder('root')
|
||||||
|
const badNode = makeNode(badFolder, [makeNode(file('lost.txt'))], { loaded: false, failLoad: true })
|
||||||
|
const tree = makeNode(root, [makeNode(ok), badNode])
|
||||||
|
const store = createStore(tree)
|
||||||
|
const ctx = await collectFolderFiles({
|
||||||
|
folderData: root,
|
||||||
|
maxDepth: 4,
|
||||||
|
getNode: store.getNode,
|
||||||
|
ensureLoaded: async (node) => {
|
||||||
|
if (node.data.fileName === 'bad') throw new Error('network')
|
||||||
|
return store.ensureLoaded(node)
|
||||||
|
},
|
||||||
|
isDownloadable: store.isDownloadable
|
||||||
|
})
|
||||||
|
assert.deepEqual(ctx.files.map((f) => f.fileName), ['ok.txt'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -417,10 +417,7 @@
|
|||||||
<!-- 目录树组件 -->
|
<!-- 目录树组件 -->
|
||||||
<div v-if="showDirectoryTree" class="directory-tree-container">
|
<div v-if="showDirectoryTree" class="directory-tree-container">
|
||||||
<div style="margin-bottom: 10px; text-align: right;">
|
<div style="margin-bottom: 10px; text-align: right;">
|
||||||
<el-radio-group v-model="directoryViewMode" size="small">
|
<ViewModeSwitch v-model="directoryViewMode" pane-label="窗格" tree-label="文件树" />
|
||||||
<el-radio-button label="pane">窗格</el-radio-button>
|
|
||||||
<el-radio-button label="tree">文件树</el-radio-button>
|
|
||||||
</el-radio-group>
|
|
||||||
</div>
|
</div>
|
||||||
<DirectoryTree
|
<DirectoryTree
|
||||||
:file-list="directoryData"
|
:file-list="directoryData"
|
||||||
@@ -619,6 +616,7 @@ import axios from 'axios'
|
|||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import DarkMode from '@/components/DarkMode'
|
import DarkMode from '@/components/DarkMode'
|
||||||
import DirectoryTree from '@/components/DirectoryTree'
|
import DirectoryTree from '@/components/DirectoryTree'
|
||||||
|
import ViewModeSwitch from '@/components/ViewModeSwitch'
|
||||||
import DownloadDialog from '@/components/DownloadDialog'
|
import DownloadDialog from '@/components/DownloadDialog'
|
||||||
import parserUrl from '../parserUrl1'
|
import parserUrl from '../parserUrl1'
|
||||||
import fileTypeUtils from '@/utils/fileTypeUtils'
|
import fileTypeUtils from '@/utils/fileTypeUtils'
|
||||||
@@ -629,7 +627,7 @@ import { PREVIEW_BASE_URL } from '@/utils/constants'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
components: { DarkMode, DirectoryTree, DownloadDialog },
|
components: { DarkMode, DirectoryTree, ViewModeSwitch, DownloadDialog },
|
||||||
mixins: [fileTypeUtils],
|
mixins: [fileTypeUtils],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -1143,10 +1141,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)
|
||||||
@@ -1420,7 +1432,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) {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="text-align:right;margin-bottom:12px;">
|
<div style="text-align:right;margin-bottom:12px;">
|
||||||
<DarkMode @theme-change="toggleTheme" style="float: left;"/>
|
<DarkMode @theme-change="toggleTheme" style="float: left;"/>
|
||||||
<el-radio-group v-model="viewMode" size="small" style="margin-left:20px;">
|
<ViewModeSwitch
|
||||||
<el-radio-button label="pane">窗格</el-radio-button>
|
v-model="viewMode"
|
||||||
<el-radio-button label="tree">目录树</el-radio-button>
|
pane-label="窗格"
|
||||||
</el-radio-group>
|
tree-label="目录树"
|
||||||
|
style="margin-left:20px;"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" style="text-align:center;margin-top:40px;">加载中...</div>
|
<div v-if="loading" style="text-align:center;margin-top:40px;">加载中...</div>
|
||||||
<div v-else-if="error" style="color:red;text-align:center;margin-top:40px;">{{ error }}</div>
|
<div v-else-if="error" style="color:red;text-align:center;margin-top:40px;">{{ error }}</div>
|
||||||
@@ -29,11 +31,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import DirectoryTree from '@/components/DirectoryTree'
|
import DirectoryTree from '@/components/DirectoryTree'
|
||||||
|
import ViewModeSwitch from '@/components/ViewModeSwitch'
|
||||||
import DarkMode from '@/components/DarkMode'
|
import DarkMode from '@/components/DarkMode'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ShowList',
|
name: 'ShowList',
|
||||||
components: { DirectoryTree, DarkMode },
|
components: { DirectoryTree, ViewModeSwitch, DarkMode },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: true,
|
loading: true,
|
||||||
|
|||||||
+12
-1
@@ -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>
|
||||||
|
|||||||
@@ -463,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(":", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,14 @@ proxy:
|
|||||||
|
|
||||||
### 解析认证相关
|
### 解析认证相关
|
||||||
auths:
|
auths:
|
||||||
# 123网盘:配置用户名密码
|
# 夸克:小文件可免登录。大文件(访客 23018)再填本机 Cookie。空值等于没配。
|
||||||
|
# 公共解析站没有共用夸克账号。
|
||||||
|
# qk:
|
||||||
|
# cookie:
|
||||||
|
# 123:公共云解析不能共用账号。优先填 authorization(浏览器 authorToken / sso-token,不要带 Bearer)。
|
||||||
|
# 账号密码触发验证码时会失败并提示改用 Authorization,本项目不处理验证码。
|
||||||
|
# 5112 / 请登录后下载 / 您需要注册登录或付费后下载 是上游要求登录。
|
||||||
ye:
|
ye:
|
||||||
username:
|
username:
|
||||||
password:
|
password:
|
||||||
|
# authorization:
|
||||||
|
|||||||
Reference in New Issue
Block a user