Compare commits

...
Author SHA1 Message Date
Cursor Agentandqaiu dd092e50dd fix(qk): resolve folder shares and guest small-file downloads
Quark parse stopped when the share root was only a folder and reported
that nothing could be downloaded. Enter subfolders, skip 0-byte
placeholders, and for visitors try a smaller file when Quark returns
23018 instead of transferring without a Cookie.

123 download failures now tell users to configure a local
auths.ye.authorization. Share listings can be read without login, and
hash fragments on 123 URLs are ignored. New URL fixtures use a ref=example
query, not a password-shaped parameter.

Co-authored-by: qaiu <[email protected]>
2026-10-30 23:41:37 +00:00
qaiu 12d0f6bfbb Merge pull request #233 from qaiu/cursor/ye-password-risk-auth-hint-140f
fix: 123 云盘账号密码登录触发验证码/风控时引导配置 Authorization
2026-10-31 07:06:12 +08:00
qaiu 9d2fd09331 Merge branch 'main' into cursor/ye-password-risk-auth-hint-140f 2026-10-31 07:03:25 +08:00
qaiu e9be9b3345 Merge pull request #259 from qaiu/fix/iz-auth-fallback
fix(iz): 蓝奏优享登录失败回退免登录 + release 0.4.8
2026-10-14 04:53:24 +08:00
qaiu 6acb774c87 release: 0.4.8 Youxiang auth fallback and configurable XFF 2026-10-14 04:38:00 +08:00
qaiu 511515cae4 fix(iz): 蓝奏优享登录失败回退免登录、TokenCache 多账号、可配置 XFF
- IzTool.parse 预登录失败 recover 后继续免登录解析
- IzTool / IzToolWithAuth 登录或重登失败回退匿名下载(downloadAnonymous)
- IzTool 改用 TokenCache(按 _configId/username 隔离),校验 token 前恢复 appToken 头
- 新增 IzXffHeader:-Dnfd.iz.xff / NFD_IZ_XFF = off(默认)/random/固定IP
2026-10-14 04:38:00 +08:00
Cursor Agentandqaiu 1ab9df7826 fix: guide Authorization when 123 password login hits captcha
123 pan username/password sign-in that returns captcha or risk-control
now fails with a user-facing hint to configure Authorization (Bearer/JWT),
instead of the raw upstream message. Wrong-password and network errors stay
distinct. No captcha solving, QR login, or retries.

Co-authored-by: qaiu <[email protected]>
2026-09-27 22:08:16 +00:00
qaiu ab8b687837 Merge pull request #231 from qaiu/cursor/release-047-9e8b
release: v0.4.7
2026-09-25 16:35:29 +08:00
Cursor Agentandqaiu 7616bf3144 release: 0.4.7 Lanzou host rewrite and apifile ajax
Co-authored-by: qaiu <[email protected]>
2026-09-25 08:34:52 +00:00
qaiu 01d7956de5 Merge pull request #229 from qaiu/cursor/lanzou-skip-wwww-rewrite-d50d
fix(parser): 蓝奏个性域名保留原主机,文件直链走 apifile
2026-09-25 16:33:05 +08:00
Cursor Agentandqaiu 78e53c076e fix(parser): 蓝奏文件 ajax 优先使用页面里的 apifile 绝对地址
个性域名文件页把 ajaxfile.php 写到 apifile.lanzouw.com。相对路径打到页面源站会空响应,直链拿不到。绝对地址优先,apifile 放进文件 ajax 兜底且排在四 w 之前;目录列表仍不走 apifile。

Co-authored-by: qaiu <[email protected]>
2026-09-25 08:27:58 +00:00
Cursor Agentandqaiu 4d985cc96f fix(parser): 蓝奏个性域名不再强制改写到 wwww.lanzoux.com
a.lanzouw.com 的页面请求被改到四个 w 的主机后,部分出口会连接超时。
分享页 GET 保留原主机;ajax/filemore 再按 w1、www、wwww 顺序回退。

Co-authored-by: qaiu <[email protected]>
2026-09-25 06:46:11 +00:00
qaiu 13ccdb8adf 更新 README.md 2026-09-24 06:05:20 +08:00
qaiu ac1afcd35f 更新 README.md 2026-09-24 06:04:37 +08:00
qaiu 0c678a4124 Merge pull request #221 from qaiu/cursor/release-046-b82b
release: v0.4.6
2026-09-18 19:01:51 -07:00
Cursor Agentandqaiu f77800602d release: 0.4.6 tree batch auto-expand and view-switch icons
Co-authored-by: qaiu <[email protected]>
2026-09-19 01:39:46 +00:00
qaiu 79ab829952 Merge pull request #220 from qaiu/cursor/tree-batch-expand-f84e
feat: 文件树批量勾选自动展开 + 视图切换图标 / Tree batch auto-expand and view-switch icons
2026-09-18 18:38:23 -07:00
Cursor Agentandqaiu 2181ef38df fix(web): set tree batch default max depth to 5
Align with VIP: default recursive batch depth is 5 (checked folder = depth 0). Still configurable via batchMaxDepth; warn when remaining folders hit the cap.

Co-authored-by: qaiu <[email protected]>
2026-09-19 01:14:06 +00:00
Cursor Agentandqaiu e09c9f76a7 feat(web): tree batch auto-expand with depth limit and view icons
勾选文件树文件夹时自动展开并懒加载子节点,按最大深度(默认 4,勾选文件夹为第 0 层)递归收集可下载文件;触及上限时中文提示部分文件已跳过。树侧栏顶部增加显眼的批量下载入口。Home/ShowList 窗格与文件树切换增加 Font Awesome 图标。

Co-authored-by: qaiu <[email protected]>
2026-09-19 01:12:33 +00:00
qaiu 7adb938357 更新 README.md 2026-09-18 00:36:05 -07:00
qaiu efb800d88e 更新 README.md 2026-09-18 00:30:44 -07:00
qandCursor 422d5b845b fix: recognize 123pan uid.share links and trim paste input
Clipboard auto-detect now matches {userId}.share.123pan.cn/123pan/{key}, and the share URL field is trimmed on paste and blur. Split web-service compiler args so IDE rebuilds no longer wipe classes.

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

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

Bump version to 0.4.4.

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

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

Bump version to 0.4.3.

Co-authored-by: Cursor <[email protected]>
2026-08-18 00:21:03 +08:00
qaiu b5f7d9694b 更新 README.md 2026-08-16 21:20:04 +08:00
qaiu f75d9f687b 更新 README.md 2026-08-16 21:18:37 +08:00
42 changed files with 4411 additions and 911 deletions
+21 -3
View File
@@ -8,7 +8,7 @@ on:
workflow_dispatch: workflow_dispatch:
push: push:
tags: tags:
- '*' - 'v*'
branches-ignore: branches-ignore:
- '*' - '*'
paths-ignore: paths-ignore:
@@ -262,9 +262,27 @@ jobs:
name: ${{ matrix.artifact-name }} name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-name }}.zip path: ${{ matrix.artifact-name }}.zip
- name: 上传到 Release # ================================================================
# 阶段三:创建 GitHub Release(只跑一次,避免矩阵任务重复拼接更新日志)
# ================================================================
publish-release:
name: 发布 GitHub Release
needs: native-package
if: github.event_name != 'pull_request' && github.ref_type == 'tag'
runs-on: ubuntu-latest
steps:
- name: 下载原生安装包
uses: actions/download-artifact@v4
with:
pattern: netdisk-fast-download-*
merge-multiple: true
- name: 创建 Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: ${{ matrix.artifact-name }}.zip files: |
netdisk-fast-download-linux-amd64.zip
netdisk-fast-download-windows-amd64.zip
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
generate_release_notes: true generate_release_notes: true
fail_on_unmatched_files: true
+50 -33
View File
@@ -18,6 +18,8 @@ QQ交流群:1017480890
![alt text](web-front/img/image.png) ![alt text](web-front/img/image.png)
> 本平台所有音乐解析工具不再维护,请移步[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,7 +470,7 @@ 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/v0.4.2/netdisk-fast-download-linux-amd64.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.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
@@ -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;
} }
+3 -2
View File
@@ -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
View File
@@ -63,7 +63,7 @@
<lombok.version>1.18.38</lombok.version> <lombok.version>1.18.38</lombok.version>
<slf4j.version>2.0.16</slf4j.version> <slf4j.version>2.0.16</slf4j.version>
<commons-lang3.version>3.18.0</commons-lang3.version> <commons-lang3.version>3.18.0</commons-lang3.version>
<jackson.version>2.18.6</jackson.version> <jackson.version>2.18.9</jackson.version>
<logback.version>1.5.32</logback.version> <logback.version>1.5.32</logback.version>
<junit.version>4.13.2</junit.version> <junit.version>4.13.2</junit.version>
</properties> </properties>
@@ -368,9 +368,12 @@ public abstract class PanBase implements IPanTool, Closeable {
// 检查响应头中的Content-Encoding是否为gzip // 检查响应头中的Content-Encoding是否为gzip
String contentEncoding = res.getHeader("Content-Encoding"); String contentEncoding = res.getHeader("Content-Encoding");
try { try {
if ("gzip".equalsIgnoreCase(contentEncoding)) { if ("gzip".equalsIgnoreCase(contentEncoding) && res.body() instanceof Buffer gzipBody
&& gzipBody.length() >= 2
&& (gzipBody.getByte(0) & 0xff) == 0x1f
&& (gzipBody.getByte(1) & 0xff) == 0x8b) {
// 如果是gzip压缩的响应体,解压(只解压一次,缓存结果) // 如果是gzip压缩的响应体,解压(只解压一次,缓存结果)
String decompressed = decompressGzip((Buffer) res.body()); String decompressed = decompressGzip(gzipBody);
return new JsonObject(decompressed); return new JsonObject(decompressed);
} else { } else {
return res.bodyAsJsonObject(); return res.bodyAsJsonObject();
@@ -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) + "...";
}
}
@@ -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") + ", 请重新解析将使用免登陆模式");
} }
} }
@@ -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;
@@ -49,6 +50,22 @@ public class QkTool extends PanBase {
// __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;
} }
@@ -229,105 +246,303 @@ public class QkTool extends PanBase {
log.debug("成功获取 stoken: {}", stoken); log.debug("成功获取 stoken: {}", stoken);
// 第二步:获取文件列表 // 根目录可能只是文件夹(#282)。先列出再进入,找到文件后再取直链。
client.getAbs(DETAIL_URL) findDownloadableFiles(pwdId, stoken, "0", 0)
.addQueryParam("pr", "ucpro") .compose(files -> resolveListedFiles(files, pwdId, stoken))
.addQueryParam("fr", "pc") .onSuccess(downloadUrl -> completeWithMeta(downloadUrl, buildDownloadHeaders(null)))
.addQueryParam("pwd_id", pwdId) .onFailure(t -> fail(t.getMessage() == null ? "解析失败" : t.getMessage()));
.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)
.onSuccess(downloadUrl -> completeWithMeta(downloadUrl, buildDownloadHeaders(null)))
.onFailure(handleFail("获取下载链接"));
}).onFailure(handleFail(DETAIL_URL));
}) })
.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);
+362 -37
View File
@@ -101,59 +101,384 @@ public interface JsContent {
"""; """;
String lz = """ String lz = """
/** /**
* 蓝奏云解析器js签名获取工具 * 蓝奏云解析器 JS 沙箱:伪装 jQuery / document / window。
* 新版页面会用 document.cookie、location.reload、querySelector、
* $('#pwd').val()、.html()、.css() 等,这里做成可链式的最小实现。
* kdns.js 在浏览器里是 `var killdns = true`,需一并注入,否则 kd 会被改成 0。
*/ */
var signObj; var signObj;
var __lzPwd = '';
var killdns = true;
function __lzSetPwd(p) {
__lzPwd = p == null ? '' : String(p);
}
function __lzEl(id) {
var nid = String(id == null ? '' : id).replace(/^[#.]/, '');
var el = {
id: nid,
_value: nid === 'pwd' ? __lzPwd : '',
checked: false,
disabled: false,
innerHTML: '',
innerText: '',
textContent: '',
className: '',
style: { display: '', visibility: '', width: '', height: '' },
classList: {
add: function () {},
remove: function () {},
contains: function () { return false; },
toggle: function () {}
},
setAttribute: function () {},
getAttribute: function () { return null; },
removeAttribute: function () {},
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
},
removeEventListener: function () {},
appendChild: function (n) { return n; },
removeChild: function (n) { return n; },
insertBefore: function (n) { return n; },
click: function () {},
focus: function () {},
blur: function () {},
submit: function () {},
select: function () {},
reset: function () {}
};
el.parentNode = el;
el.parentElement = el;
el.children = [];
el.childNodes = [];
el.firstChild = null;
el.lastChild = null;
try {
Object.defineProperty(el, 'value', {
get: function () { return nid === 'pwd' ? __lzPwd : el._value; },
set: function (v) {
el._value = v;
if (nid === 'pwd') {
__lzPwd = v == null ? '' : String(v);
}
}
});
} catch (e) {
el.value = el._value;
}
return el;
}
function __lzJq(sel) {
var id = '';
if (typeof sel === 'string') {
id = sel.replace(/^[#.]/, '');
} else if (sel && sel.id) {
id = String(sel.id);
}
var el = (sel && sel.style && sel.addEventListener) ? sel : __lzEl(id);
var api = {
0: el,
length: 1,
selector: sel,
ready: function (fn) {
if (typeof fn === 'function') {
try { fn(jQuery); } catch (e) {}
}
return api;
},
on: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
off: function () { return api; },
bind: function (t, fn) { return api.on(t, fn); },
unbind: function () { return api; },
click: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
focus: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
blur: function () { return api; },
keyup: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
keydown: function (fn) { return api.keyup(fn); },
keypress: function (fn) { return api.keyup(fn); },
submit: function (fn) { return api.click(fn); },
change: function (fn) { return api.click(fn); },
hover: function () { return api; },
val: function (v) {
if (arguments.length === 0) {
if (typeof el.value !== 'undefined') {
return el.value;
}
return (id === 'pwd') ? __lzPwd : '';
}
el.value = v;
return api;
},
html: function (v) {
if (arguments.length === 0) {
return el.innerHTML;
}
el.innerHTML = v;
return api;
},
text: function (v) {
if (arguments.length === 0) {
return el.innerText;
}
el.innerText = v;
el.textContent = v;
return api;
},
attr: function (k, v) {
if (arguments.length < 2) {
return null;
}
return api;
},
prop: function (k, v) {
if (arguments.length < 2) {
return false;
}
return api;
},
css: function () { return api; },
addClass: function () { return api; },
removeClass: function () { return api; },
toggleClass: function () { return api; },
hasClass: function () { return false; },
show: function () {
el.style.display = '';
return api;
},
hide: function () {
el.style.display = 'none';
return api;
},
fadeIn: function () { return api; },
fadeOut: function () { return api; },
animate: function () { return api; },
find: function () { return api; },
parent: function () { return api; },
children: function () { return api; },
eq: function () { return api; },
first: function () { return api; },
last: function () { return api; },
each: function (fn) {
if (typeof fn === 'function') {
try { fn.call(el, 0, el); } catch (e) {}
}
return api;
},
append: function () { return api; },
prepend: function () { return api; },
remove: function () { return api; },
empty: function () { return api; },
ajax: function (obj) {
signObj = obj;
return api;
},
get: function () { return el; }
};
return api;
}
var $, jQuery; var $, jQuery;
$ = jQuery = function (sel) {
$ = jQuery = function () { if (typeof sel === 'function') {
return new jQuery.fn.init(); try { sel(jQuery); } catch (e) {}
} return __lzJq(document);
}
return __lzJq(sel);
};
jQuery.fn = jQuery.prototype = { jQuery.fn = jQuery.prototype = {
init: function () { init: function (sel) {
return { return __lzJq(sel);
focus: function (a) { }
};
},
keyup: function(a) {
},
ajax: function (obj) {
signObj = obj
},
val: function(a) {
},
}
},
}
jQuery.fn.init.prototype = jQuery.fn; jQuery.fn.init.prototype = jQuery.fn;
$.fn = jQuery.fn;
$.ajax = function (obj) { $.ajax = function (obj) {
signObj = obj signObj = obj;
} return {
done: function () { return this; },
fail: function () { return this; },
always: function () { return this; }
};
};
$.get = function () {};
$.post = function () {};
$.extend = function () {
var t = arguments[0] || {};
for (var i = 1; i < arguments.length; i++) {
var s = arguments[i];
if (s) {
for (var k in s) {
if (s.hasOwnProperty(k)) {
t[k] = s[k];
}
}
}
}
return t;
};
$.each = function (obj, fn) {
if (!obj || typeof fn !== 'function') {
return obj;
}
if (typeof obj.length === 'number') {
for (var i = 0; i < obj.length; i++) {
fn.call(obj[i], i, obj[i]);
}
} else {
for (var k in obj) {
if (obj.hasOwnProperty(k)) {
fn.call(obj[k], k, obj[k]);
}
}
}
return obj;
};
$.isFunction = function (f) { return typeof f === 'function'; };
$.isArray = function (a) {
return Object.prototype.toString.call(a) === '[object Array]';
};
$.trim = function (s) {
return s == null ? '' : String(s).replace(/^\\s+|\\s+$/g, '');
};
var __lzLocation = {
href: '',
search: '',
pathname: '/',
hash: '',
host: '',
hostname: '',
protocol: 'https:',
port: '',
origin: '',
assign: function () {},
replace: function () {},
reload: function () {}
};
var document = { var document = {
getElementById: function (v) { cookie: '',
return { title: '',
value: 'v', domain: '',
style: { referrer: '',
display: '' readyState: 'complete',
}, hidden: false,
addEventListener: function() {} visibilityState: 'visible',
documentElement: null,
body: null,
head: null,
location: __lzLocation,
getElementById: function (id) { return __lzEl(id); },
getElementsByClassName: function () { return []; },
getElementsByTagName: function (t) {
return t === 'script' ? [] : [__lzEl(t)];
},
getElementsByName: function () { return []; },
querySelector: function (s) { return __lzEl(s); },
querySelectorAll: function (s) { return [__lzEl(s)]; },
createElement: function (t) { return __lzEl(t); },
createTextNode: function (t) { return { nodeValue: t, data: t }; },
createDocumentFragment: function () { return __lzEl('fragment'); },
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
} }
}, },
} removeEventListener: function () {},
write: function () {},
writeln: function () {},
open: function () {},
close: function () {}
};
document.documentElement = __lzEl('html');
document.body = __lzEl('body');
document.head = __lzEl('head');
var window = {location: {}} var navigator = {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
platform: 'Win32',
language: 'zh-CN',
cookieEnabled: true,
onLine: true
};
var location = __lzLocation;
var console = {
log: function () {},
warn: function () {},
error: function () {},
info: function () {},
debug: function () {}
};
function setTimeout(fn, delay) {
if (typeof fn === 'function' && (!delay || delay <= 0)) {
try { fn(); } catch (e) {}
}
return 0;
}
function setInterval() { return 0; }
function clearTimeout() {}
function clearInterval() {}
var window = {
location: __lzLocation,
document: document,
navigator: navigator,
console: console,
innerWidth: 1920,
innerHeight: 1080,
setTimeout: setTimeout,
setInterval: setInterval,
clearTimeout: clearTimeout,
clearInterval: clearInterval,
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
},
removeEventListener: function () {},
atob: function (s) { return s; },
btoa: function (s) { return s; }
};
window.window = window;
window.top = window;
window.self = window;
window.parent = window;
window.jQuery = jQuery;
window.$ = $;
var top = window;
var self = window;
var parent = window;
"""; """;
String kwSignString = """ String kwSignString = """
@@ -50,21 +50,47 @@ public class JsExecUtils {
*/ */
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException, public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException,
NoSuchMethodException { NoSuchMethodException {
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎 return executeDynamicJs(jsText, funName, null);
}
/**
* @param pwd 分享密码,写入伪 DOM(#pwd / getElementById('pwd')),供新版页面取值
*/
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName, String pwd) throws ScriptException,
NoSuchMethodException {
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript");
try { try {
engine.eval(JsContent.lz + "\n" + jsText); engine.eval(JsContent.lz);
Invocable inv = (Invocable) engine; Invocable inv = (Invocable) engine;
//调用js中的函数 if (pwd != null) {
if (StringUtils.isNotEmpty(funName)) { inv.invokeFunction("__lzSetPwd", pwd);
inv.invokeFunction(funName);
} }
return (ScriptObjectMirror) engine.get("signObj"); try {
engine.eval(jsText);
if (StringUtils.isNotEmpty(funName)) {
inv.invokeFunction(funName);
}
} catch (ScriptException | NoSuchMethodException | RuntimeException e) {
ScriptObjectMirror captured = asSignObj(engine.get("signObj"));
if (captured != null) {
return captured;
}
throw e;
}
return asSignObj(engine.get("signObj"));
} finally { } finally {
// 清理引擎持有的引用,帮助 GC 回收
clearEngineBindings(engine); clearEngineBindings(engine);
} }
} }
private static ScriptObjectMirror asSignObj(Object sign) {
if (sign instanceof ScriptObjectMirror mirror
&& (mirror.get("url") != null || mirror.get("data") != null)) {
return mirror;
}
return null;
}
/** /**
* 调用执行js文件(使用缓存的 ScriptEngineManager 创建新引擎实例) * 调用执行js文件(使用缓存的 ScriptEngineManager 创建新引擎实例)
@@ -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);
+362 -29
View File
@@ -1,46 +1,379 @@
/** /**
* 蓝奏云解析器js签名获取工具 * 蓝奏云解析器 JS 沙箱:伪装 jQuery / document / window。
* 新版页面会用 document.cookie、location.reload、querySelector、
* $('#pwd').val()、.html()、.css() 等,这里做成可链式的最小实现。
* kdns.js 在浏览器里是 `var killdns = true`,需一并注入,否则 kd 会被改成 0。
*/ */
var signObj; var signObj;
var __lzPwd = '';
var killdns = true;
function __lzSetPwd(p) {
__lzPwd = p == null ? '' : String(p);
}
function __lzEl(id) {
var nid = String(id == null ? '' : id).replace(/^[#.]/, '');
var el = {
id: nid,
_value: nid === 'pwd' ? __lzPwd : '',
checked: false,
disabled: false,
innerHTML: '',
innerText: '',
textContent: '',
className: '',
style: { display: '', visibility: '', width: '', height: '' },
classList: {
add: function () {},
remove: function () {},
contains: function () { return false; },
toggle: function () {}
},
setAttribute: function () {},
getAttribute: function () { return null; },
removeAttribute: function () {},
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
},
removeEventListener: function () {},
appendChild: function (n) { return n; },
removeChild: function (n) { return n; },
insertBefore: function (n) { return n; },
click: function () {},
focus: function () {},
blur: function () {},
submit: function () {},
select: function () {},
reset: function () {}
};
el.parentNode = el;
el.parentElement = el;
el.children = [];
el.childNodes = [];
el.firstChild = null;
el.lastChild = null;
try {
Object.defineProperty(el, 'value', {
get: function () { return nid === 'pwd' ? __lzPwd : el._value; },
set: function (v) {
el._value = v;
if (nid === 'pwd') {
__lzPwd = v == null ? '' : String(v);
}
}
});
} catch (e) {
el.value = el._value;
}
return el;
}
function __lzJq(sel) {
var id = '';
if (typeof sel === 'string') {
id = sel.replace(/^[#.]/, '');
} else if (sel && sel.id) {
id = String(sel.id);
}
var el = (sel && sel.style && sel.addEventListener) ? sel : __lzEl(id);
var api = {
0: el,
length: 1,
selector: sel,
ready: function (fn) {
if (typeof fn === 'function') {
try { fn(jQuery); } catch (e) {}
}
return api;
},
on: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
off: function () { return api; },
bind: function (t, fn) { return api.on(t, fn); },
unbind: function () { return api; },
click: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
focus: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
blur: function () { return api; },
keyup: function (fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
return api;
},
keydown: function (fn) { return api.keyup(fn); },
keypress: function (fn) { return api.keyup(fn); },
submit: function (fn) { return api.click(fn); },
change: function (fn) { return api.click(fn); },
hover: function () { return api; },
val: function (v) {
if (arguments.length === 0) {
if (typeof el.value !== 'undefined') {
return el.value;
}
return (id === 'pwd') ? __lzPwd : '';
}
el.value = v;
return api;
},
html: function (v) {
if (arguments.length === 0) {
return el.innerHTML;
}
el.innerHTML = v;
return api;
},
text: function (v) {
if (arguments.length === 0) {
return el.innerText;
}
el.innerText = v;
el.textContent = v;
return api;
},
attr: function (k, v) {
if (arguments.length < 2) {
return null;
}
return api;
},
prop: function (k, v) {
if (arguments.length < 2) {
return false;
}
return api;
},
css: function () { return api; },
addClass: function () { return api; },
removeClass: function () { return api; },
toggleClass: function () { return api; },
hasClass: function () { return false; },
show: function () {
el.style.display = '';
return api;
},
hide: function () {
el.style.display = 'none';
return api;
},
fadeIn: function () { return api; },
fadeOut: function () { return api; },
animate: function () { return api; },
find: function () { return api; },
parent: function () { return api; },
children: function () { return api; },
eq: function () { return api; },
first: function () { return api; },
last: function () { return api; },
each: function (fn) {
if (typeof fn === 'function') {
try { fn.call(el, 0, el); } catch (e) {}
}
return api;
},
append: function () { return api; },
prepend: function () { return api; },
remove: function () { return api; },
empty: function () { return api; },
ajax: function (obj) {
signObj = obj;
return api;
},
get: function () { return el; }
};
return api;
}
var $, jQuery; var $, jQuery;
$ = jQuery = function (sel) {
$ = jQuery = function () { if (typeof sel === 'function') {
return new jQuery.fn.init(); try { sel(jQuery); } catch (e) {}
} return __lzJq(document);
}
return __lzJq(sel);
};
jQuery.fn = jQuery.prototype = { jQuery.fn = jQuery.prototype = {
init: function () { init: function (sel) {
return { return __lzJq(sel);
focus: function (a) { }
};
},
keyup: function(a) {
},
ajax: function (obj) {
signObj = obj
}
}
},
}
jQuery.fn.init.prototype = jQuery.fn; jQuery.fn.init.prototype = jQuery.fn;
$.fn = jQuery.fn;
// 伪装jquery.ajax函数获取关键数据
$.ajax = function (obj) { $.ajax = function (obj) {
signObj = obj signObj = obj;
} return {
done: function () { return this; },
fail: function () { return this; },
always: function () { return this; }
};
};
$.get = function () {};
$.post = function () {};
$.extend = function () {
var t = arguments[0] || {};
for (var i = 1; i < arguments.length; i++) {
var s = arguments[i];
if (s) {
for (var k in s) {
if (s.hasOwnProperty(k)) {
t[k] = s[k];
}
}
}
}
return t;
};
$.each = function (obj, fn) {
if (!obj || typeof fn !== 'function') {
return obj;
}
if (typeof obj.length === 'number') {
for (var i = 0; i < obj.length; i++) {
fn.call(obj[i], i, obj[i]);
}
} else {
for (var k in obj) {
if (obj.hasOwnProperty(k)) {
fn.call(obj[k], k, obj[k]);
}
}
}
return obj;
};
$.isFunction = function (f) { return typeof f === 'function'; };
$.isArray = function (a) {
return Object.prototype.toString.call(a) === '[object Array]';
};
$.trim = function (s) {
return s == null ? '' : String(s).replace(/^\s+|\s+$/g, '');
};
var __lzLocation = {
href: '',
search: '',
pathname: '/',
hash: '',
host: '',
hostname: '',
protocol: 'https:',
port: '',
origin: '',
assign: function () {},
replace: function () {},
reload: function () {}
};
var document = { var document = {
getElementById: function (v) { cookie: '',
return { title: '',
value: 'v' domain: '',
referrer: '',
readyState: 'complete',
hidden: false,
visibilityState: 'visible',
documentElement: null,
body: null,
head: null,
location: __lzLocation,
getElementById: function (id) { return __lzEl(id); },
getElementsByClassName: function () { return []; },
getElementsByTagName: function (t) {
return t === 'script' ? [] : [__lzEl(t)];
},
getElementsByName: function () { return []; },
querySelector: function (s) { return __lzEl(s); },
querySelectorAll: function (s) { return [__lzEl(s)]; },
createElement: function (t) { return __lzEl(t); },
createTextNode: function (t) { return { nodeValue: t, data: t }; },
createDocumentFragment: function () { return __lzEl('fragment'); },
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
} }
}, },
removeEventListener: function () {},
write: function () {},
writeln: function () {},
open: function () {},
close: function () {}
};
document.documentElement = __lzEl('html');
document.body = __lzEl('body');
document.head = __lzEl('head');
var navigator = {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
platform: 'Win32',
language: 'zh-CN',
cookieEnabled: true,
onLine: true
};
var location = __lzLocation;
var console = {
log: function () {},
warn: function () {},
error: function () {},
info: function () {},
debug: function () {}
};
function setTimeout(fn, delay) {
if (typeof fn === 'function' && (!delay || delay <= 0)) {
try { fn(); } catch (e) {}
}
return 0;
} }
function setInterval() { return 0; }
function clearTimeout() {}
function clearInterval() {}
var window = {
location: __lzLocation,
document: document,
navigator: navigator,
console: console,
innerWidth: 1920,
innerHeight: 1080,
setTimeout: setTimeout,
setInterval: setInterval,
clearTimeout: clearTimeout,
clearInterval: clearInterval,
addEventListener: function (t, fn) {
if (typeof fn === 'function') {
try { fn(); } catch (e) {}
}
},
removeEventListener: function () {},
atob: function (s) { return s; },
btoa: function (s) { return s; }
};
window.window = window;
window.top = window;
window.self = window;
window.parent = window;
window.jQuery = jQuery;
window.$ = $;
var top = window;
var self = window;
var parent = window;
+50 -6
View File
@@ -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("⚠️ 未提供登录信息,某些文件可能无法下载")
@@ -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);
}
}
@@ -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("{"));
}
}
@@ -0,0 +1,90 @@
package cn.qaiu.util;
import org.junit.Test;
import org.openjdk.nashorn.api.scripting.ScriptObjectMirror;
import java.util.Map;
import static org.junit.Assert.*;
/**
* 新版蓝奏页面会调用更多 document / jQuery API,沙箱必须能跑完并抓住 $.ajax。
*/
public class JsExecUtilsLzTest {
@Test
public void testNewIframeAjaxWithDomApis() throws Exception {
String js = """
var lanosso = '';
var down_1 = '';
var wsk_sign = 'c20230908';
var wp_sign = 'SIGN_ABC';
var ajaxdata = 'MrBR';
var kdns = 1;
if (typeof(killdns)=='undefined'){
var kdns = 0;
}
document.cookie = 'x=1';
document.location.reload();
document.querySelector('#tourl');
document.createElement('div');
$.ajax({
type : 'post',
url : '/ajaxfile.php?file=150233466',
data : { 'action':'downprocess','websignkey':ajaxdata,'signs':ajaxdata,'sign':wp_sign,'websign':'','kd':kdns,'ves':1 },
dataType : 'json',
success:function(msg){
$("#tourl").html("ok");
$("#outime").css("display","block");
}
});
""";
ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, null);
assertNotNull(sign);
assertEquals("/ajaxfile.php?file=150233466", String.valueOf(sign.get("url")));
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) sign.get("data");
assertEquals("downprocess", String.valueOf(data.get("action")));
assertEquals("SIGN_ABC", String.valueOf(data.get("sign")));
assertEquals("MrBR", String.valueOf(data.get("websignkey")));
assertEquals("1", String.valueOf(data.get("kd")));
}
@Test
public void testPwdViaJqueryValAndDocument() throws Exception {
String js = """
function down_p(){
var pwd = $('#pwd').val();
var pwd2 = document.getElementById('pwd').value;
var pwd3 = document.querySelector('#pwd').value;
$(".passwdinput").focus();
$.ajax({
type : 'post',
url : '/ajaxm.php',
data : { 'action':'downprocess','sign':'S1','p':pwd,'p2':pwd2,'p3':pwd3 }
});
}
""";
ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, "down_p", "e4k4");
assertNotNull(sign);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) sign.get("data");
assertEquals("e4k4", String.valueOf(data.get("p")));
assertEquals("e4k4", String.valueOf(data.get("p2")));
assertEquals("e4k4", String.valueOf(data.get("p3")));
}
@Test
public void testReadyAndSuccessCallbackDoNotDropAjax() throws Exception {
String js = """
$(function(){
document.getElementById('rpt').style.display = 'none';
$.ajax({ url: '/ajaxm.php?file=1', data: { a: 1 } });
$("#tourl").html("x");
});
""";
ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, null);
assertNotNull(sign);
assertTrue(String.valueOf(sign.get("url")).contains("ajaxm.php"));
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
</modules> </modules>
<properties> <properties>
<revision>0.4.2</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>
+3 -2
View File
@@ -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",
+263 -23
View File
@@ -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>
+6 -2
View File
@@ -246,8 +246,8 @@
storage: 'hash' storage: 'hash'
}, },
'123pan': { '123pan': {
reg: /((?:https?:\/\/)?www\.(123pan|123865|123684)\.com\/s\/[\w-]{6,})/, reg: /((?:https?:\/\/)?(?:[a-zA-Z\d-]+\.(?:m?share)\.123pan\.cn\/123pan\/[\w-]+|(?:www\.)?(?:123panpay|123pan|123\d{3})\.(?:com|cn)\/s\/[\w-]{6,}(?:\.html)?)(?:\?[^#\s]*)?)/i,
host: /www\.123pan\.com/, host: /(?:[a-zA-Z\d-]+\.(?:m?share)\.123pan\.cn|(?:www\.)?(?:123panpay|123pan|123\d{3})\.(?:com|cn))/i,
input: ['.ca-fot input', ".appinput .appinput"], input: ['.ca-fot input', ".appinput .appinput"],
button: ['.ca-fot button', ".appinput button"], button: ['.ca-fot button', ".appinput button"],
name: '123云盘', name: '123云盘',
@@ -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) {
+152
View File
@@ -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'])
})
})
+22 -10
View File
@@ -90,7 +90,7 @@
<!-- 开关按钮,控制是否自动读取剪切板 --> <!-- 开关按钮,控制是否自动读取剪切板 -->
<el-switch v-model="autoReadClipboard" active-text="自动识别剪切板"></el-switch> <el-switch v-model="autoReadClipboard" active-text="自动识别剪切板"></el-switch>
<el-input placeholder="请粘贴分享链接(http://或https://)" v-model="link" id="url" @paste="onPaste"> <el-input placeholder="请粘贴分享链接(http://或https://)" v-model="link" id="url" @paste="onPaste" @blur="normalizeShortcutInput">
<template #prepend>分享链接</template> <template #prepend>分享链接</template>
<template #append v-if="!autoReadClipboard"> <template #append v-if="!autoReadClipboard">
<el-button @click="getPaste(true)">读取剪切板</el-button> <el-button @click="getPaste(true)">读取剪切板</el-button>
@@ -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 -5
View File
@@ -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
View File
@@ -30,6 +30,16 @@
<groupId>cn.qaiu</groupId> <groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId> <artifactId>parser</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
@@ -81,7 +91,8 @@
${project.basedir}/src/main/generated ${project.basedir}/src/main/generated
</generatedSourcesDirectory> </generatedSourcesDirectory>
<compilerArgs> <compilerArgs>
<arg>-AoutputDirectory=${project.basedir}/src/main -Xlint:unchecked</arg> <arg>-AoutputDirectory=${project.basedir}/src/main</arg>
<arg>-Xlint:unchecked</arg>
</compilerArgs> </compilerArgs>
</configuration> </configuration>
</plugin> </plugin>
@@ -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(":", "");
} }
+8 -1
View File
@@ -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: