Compare commits

...
630 Commits
Author SHA1 Message Date
qaiu 65d518373d Merge pull request #191 from yukaidi1220/lecloud-direct-download
feat: 乐云 directDownload 接口支持 & 缓存配置补充完善
2026-06-05 23:02:18 +08:00
yukaidi bca4da4b6c feat: 乐云 directDownload 接口支持 & 缓存配置补充完善
- 新增 directDownload (GET) 接口,比 packageDownloadWithFileIds 少一次请求
- 每次随机选择下载方式,失败自动 fallback 到另一种
- 统一所有下载方法的 Promise 参数传递
- 添加 HTTP 状态码日志便于调试
- 优化 app-dev.yml 缓存配置注释,补充所有缺失的网盘类型
2026-06-05 22:48:01 +08:00
qaiu 0fd78defcb feat(ct): 升级城通网盘接口并实现目录分享解析
- 移除token参数,新增url参数至API1请求
- API2新增start_time/wait_seconds/verifycode/share_id/acheck=1参数
- parse()中从API1响应提取FileInfo(file_name/file_id/file_size/file_time/username)
- 新增parseFileList()实现目录分享解析,通过getdir.php+file_list API获取文件列表
- 新增CTD枚举项匹配城通网盘目录分享链接(/d/路径)
2026-06-03 09:46:16 +00:00
qaiu 452fd0ea2c Merge pull request #190 from yukaidi1220/pr/native-package
CI: 新增原生环境打包 & 启动日志改进
2026-05-31 17:02:50 +08:00
yukaidi dd8f2efb37 ci: Release 自动生成更新说明(generate_release_notes) 2026-05-31 16:39:23 +08:00
yukaidi 0feb8e798a fix: PR#190 review 修复 — 配置查找顺序/页面日志/ZIP结构/注释
- 配置文件查找顺序与 Deploy 保持一致(先当前目录,再 resources/)
- 页面地址日志改用 onComplete,无论演练场加载成功失败均输出
- Windows ZIP 移除 /* 通配符,与 Linux 保持一致的顶层目录结构
- 修正注释:同步读文件会阻塞 event loop,不再声称'避免阻塞'
- YAML 正则和 jdeps 回退列表补充适用范围说明
2026-05-31 16:34:19 +08:00
yukaidi d55d8edd2f AppMain: 启动日志增加前端页面访问地址提示 2026-05-31 14:00:45 +08:00
yukaidi 451496f102 CI: 新增 Linux/Windows 原生环境打包 (jlink + 精简 JRE) 及 Docker 多平台构建 2026-05-31 14:00:45 +08:00
qaiu 6d6351bd58 更新 README.md 2026-05-31 07:02:09 +08:00
q 1a5fc8d1ef feat: truncate long error msg, fix lanzou folder regex, bump version to 0.3.4 2026-05-30 01:16:17 +08:00
qaiu a44f30f7e5 Merge pull request #189 from qaiu/copilot/update-readme-badge
Update README maven badge to track tag-triggered workflow status
2026-05-30 00:17:51 +08:00
copilot-swe-agent[bot] f9ebd34de3 docs: remove branch filter from maven workflow badge 2026-05-29 16:16:43 +00:00
copilot-swe-agent[bot] b47db300a6 Initial plan 2026-05-29 16:10:44 +00:00
qaiu d19d8573f9 Merge pull request #188 from yukaidi1220/fix/qqwtool-json-api
fix(QQwTool): 改用 POST JSON API 解析 QQ 邮箱云盘链接
2026-05-29 23:54:20 +08:00
qaiu 799e120069 Merge pull request #187 from yukaidi1220/feat/contributions
安全加固、新功能、Bug 修复与代码质量改进
2026-05-29 23:53:09 +08:00
yukaidi 13f83e8795 fix(QQwTool): 改用 POST JSON API 解析 QQ 邮箱云盘链接
旧实现通过 GET 请求获取 HTML 并正则提取 JS 变量,但接口已改为返回 JSON,
导致 NumberFormatException。改为 POST 请求 `https://wx.mail.qq.com/s`,
body 为 `f=json&k={shareKey}`,解析 JSON 响应中的 body.url/name/size。

- 使用 postAbs() 替代 request() 以正确处理 HTTPS
- 使用 asJson() 兼容 gzip 响应
- 使用 complete() 正确存储 downloadUrl
- 添加 User-Agent、URL 编码 shareKey
2026-05-29 22:26:44 +08:00
yukaidi 7b5900aae4 refactor: 代码质量清理与日志规范化
- 替换 System.out.println/printStackTrace 为 Logger: MkgsTool, PodTool, WsTool, IpExtractor, ReqIpUtil, LogStatistics
- JsPlaygroundLogger 日志列表限制最大 1000 条防止内存泄漏
- JsScriptLoader JarFile 改用 try-with-resources 防止文件句柄泄漏
- DbServiceImpl Thread.sleep 改为 vertx.setTimer 避免阻塞 event loop
- 删除未使用的 api.js,删除空的 ParserApiClientLinkTest
- 移除前端未使用的导入和死代码 (downloaderService, monacoTypes)
- 提取 previewBaseUrl 到 constants.js 常量文件
2026-05-29 14:23:26 +08:00
yukaidi e36c0bbe45 fix: Docker 部署优化
- run.sh 改用 exec 直接运行 Java,修复 Docker 中 ShutdownHook 失效
- Dockerfile 预创建 db 和 logs 目录,添加非 root 用户运行
- Docker entrypoint 以 root 运行再降权,解决 volume 权限问题
- EXPOSE 改为仅 6401,entrypoint 添加 -Duser.timezone
2026-05-29 14:23:01 +08:00
yukaidi af723aed3a fix: NPE 修复、资源泄漏修复及其他 Bug 修复
- 修复 12 处 NPE 风险: FjTool/FsTool/IzTool/LzTool/MkwTool/P115Tool/PdbTool/QQTool/ParserCreate/CommonUtils/ShareLinkInfo/URLParamUtil
- 修复 4 处 Vert.x 资源泄漏: 测试类中 Vertx 实例未关闭
- 修复 CacheManager 防重入和 registerPeriodicCleanup 就绪检查
- 修复 ParserApi 中 redirectUrl()/viewUrl() Promise 未 complete
- 修复 CacheManager.updateTotalByField Promise 永不完成
- 修复 AppMain ShutdownHook 注册,确保 Vert.x 先于 JDBCPoolInit 关闭
- 修复 RouterHandlerFactory failureHandler 恢复返回 failure message
- 修复 ParserCreate/LzTool 收窄 catch 异常类型
- 修复 IzTool/FjTool/IzToolWithAuth 并发安全 (volatile + header 副本)
- 修复 P115Tool UA 为 null 时的 NPE,添加默认 User-Agent
- Font Awesome CDN 换源为 s4.zstatic.net,避免 bootcdn 投毒风险
- DirectoryTree selectAll 补 parserUrl 检查,Home 组件名 App→Home
2026-05-29 14:22:40 +08:00
yukaidi 0978186679 feat: 新功能与配置优化
- QQscTool: 支持多文件和目录解析,通过 GetFileList API 实现递归目录导航
- Home: 从粘贴文本中自动提取分享链接
- DirectoryTree: 目录浏览添加复制直链按钮
- domainName 改为可选,未配置时自动从请求地址推断
- 统一版本号管理,GitHub URL 构建时自动从 git remote origin 识别
- vue.config.js 添加前端构建配置,sync-version.js 构建时同步版本号
2026-05-29 14:21:32 +08:00
yukaidi 17460ff271 fix(security): 安全漏洞修复与依赖升级
- 升级 Vert.x 4.5.24 → 4.5.27, postgresql 42.7.3 → 42.7.11, logback 1.5.18 → 1.5.32, axios 1.13.5 → 1.16.1
- 修复 JWT 签名验证和密码比较的时序攻击漏洞 (MessageDigest.isEqual)
- 修复 AESUtils 使用不安全 Random 改为 SecureRandom
- 修复登录用户枚举和异常信息泄露,统一错误提示
- 修复 RateLimiter count++ 非原子操作 (AtomicInteger)
- 修复 JsParserExecutor DCL 模式缺少 volatile
- 修复 Token 日志泄露,仅打印前8字符
- 修复 Playground 密码时序攻击和堆栈泄露
- 所有 window.open 添加 noopener,noreferrer
- LocalConstant 改用 ConcurrentHashMap 保证线程安全
- Dockerfile 添加非 root 用户运行,secret.yml 加入 .gitignore
2026-05-29 14:20:54 +08:00
yukaidi f81b3852ee fix: Font Awesome CDN 换源为 s4.zstatic.net,避免 bootcdn 投毒风险 2026-05-29 14:10:06 +08:00
yukaidi 37abebf8f8 fix(QQscTool): 简化 filesetId 正则,避免反斜杠转义问题 2026-05-29 13:58:13 +08:00
yukaidi 79fab8c0d6 fix: 修复前端错误信息丢失 & QQscTool filesetId 提取失败
- 前端 axios 对 HTTP 非2xx直接reject,catch块丢失后端错误信息,从 error.response.data.msg 提取实际错误展示给用户

- QQscTool extractFilesetId 正则未适配 Nuxt 转义JSON格式
2026-05-29 13:46:25 +08:00
yukaidi 9b70fb2778 feat(QQscTool): 支持多文件和目录解析,通过 GetFileList API 实现递归目录导航 2026-05-29 13:22:12 +08:00
yukaidi bd2868748f feat(Home): 从粘贴文本中自动提取分享链接 2026-05-29 13:08:22 +08:00
yukaidi 1f47bf13b5 fix(QQscTool): 检测被和谐文件,避免返回无效直链 2026-05-29 12:56:28 +08:00
yukaidi 7d8b33afe0 refactor: 前端代码质量清理
- fix: Home.vue 组件名 'App' → 'Home'
- fix: DirectoryTree selectAll 补 parserUrl 空值检查
- fix: 提取 previewBaseUrl 到 utils/constants.js,解除 ShowFile 对 Home 的耦合
- fix: Home.vue focus 事件监听器改为命名函数,加 beforeUnmount 移除
- fix: Playground.vue MutationObserver 保存引用,onUnmounted 中 disconnect
- chore: 删除未使用的 api.js
- chore: 删除 ClientLinks.vue 死代码 downloadClient/shouldShowDownloadButton
- chore: 删除 DirectoryTree.vue 死代码 buildTree
2026-05-29 12:38:34 +08:00
yukaidi 367f7c78a4 Merge remote-tracking branch 'upstream/main' 2026-05-29 12:17:51 +08:00
yukaidi 1bd23ec4ae feat: 目录浏览添加复制直链按钮 2026-05-29 12:13:00 +08:00
yukaidi 3461532679 fix: ConfigUtil 读取配置失败时自动尝试 resources/ 目录
当文件系统直接读取 app.yml 失败时(如 Docker 卷挂载场景),
ConfigUtil.readConfig 现在会自动尝试 resources/ 子目录作为
fallback,确保配置文件在各种部署方式下都能被正确加载。
2026-05-29 11:54:13 +08:00
yukaidi 4cfcdfa1f8 fix: Deploy 启动时自动从 resources/ 子目录查找配置文件
当 app.yml 在当前目录不存在时,自动回退到 resources/app.yml,
解决 Docker 部署时配置文件在 resources/ 子目录导致启动失败的问题。
2026-05-29 11:39:21 +08:00
yukaidi ac2f526a1c fix: clientLinks/clientLink 补全参数注入,统一 _requestOrigin 设置
- clientLinks 无 auth 时调用 URLParamUtil.addParam() 注入代理/认证配置
- clientLink 补设 _requestOrigin 并调用 addParam()
- getFileList/getFileDownUrl 补设 _requestOrigin 保持一致性
- getDownLink 回退地址补上端口号
2026-05-29 11:36:20 +08:00
yukaidi cfb624e9e0 fix: 统一 origin 解析逻辑,修复反向代理下 domainName 不一致
- ServerApi 添加 resolveOrigin() 统一处理 X-Forwarded-Host 头
- ParserApi.parse() 补设 _requestOrigin,修复 /v2/linkInfo 路径遗漏
- 清理 app-dev.yml 残留注释
2026-05-29 11:31:08 +08:00
yukaidi e1bf45b5c8 feat: domainName 改为可选,未配置时自动从请求地址推断
- app-dev.yml 注释掉默认 domainName
- ParserApi 添加 getLinkPrefix() 支持 X-Forwarded-Host 反向代理
- ServerApi 传递 _requestOrigin 到 otherParam 供 parser 层使用
- URLParamUtil.addParam() 读不到配置时用 _requestOrigin 兜底
- AppMain 启动日志 domainName 为空时显示本地端口地址
2026-05-29 11:27:32 +08:00
yukaidi e2dc611aa4 fix: 剪切板自动读取失败时静默,仅手动读取时提示 2026-05-29 11:02:00 +08:00
yukaidi 77c953626f fix: api.js baseURL 改为相对路径,修复反向代理下请求绕过代理的问题 2026-05-29 10:59:12 +08:00
yukaidi 0e14c9a925 refactor: 统一版本号管理,消除硬编码
项目版本(pom.xml revision)和parser版本(parserVersion)统一为单一来源,
前端构建时自动同步,发版只需改根pom.xml的两个属性。
2026-05-29 10:57:49 +08:00
yukaidi a4e8585e2c fix: 错误处理返回具体异常信息而非通用"服务器内部错误"
RouterHandlerFactory 的 onFailure 和 catch 两处均丢弃了
e.getMessage(),导致前端无法获知具体报错原因。
2026-05-29 10:49:54 +08:00
yukaidi cf7d64916e fix: Groovy 正则改为字符串形式避免斜杠解析问题 2026-05-29 10:37:31 +08:00
yukaidi 4ac80bbfe8 fix: gmavenplus-plugin 添加 Groovy 依赖 2026-05-29 10:34:24 +08:00
yukaidi cfe8352d45 refactor: GitHub URL 改为构建时自动从 git remote origin 识别
前端:vue.config.js 通过 DefinePlugin 注入 VUE_APP_GITHUB_REPO_URL,
Home.vue/Playground.vue 中硬编码的 GitHub URL 全部改为动态变量。
后端:parser/pom.xml 添加 gmavenplus-plugin 在 initialize 阶段从
git remote origin 解析 github.owner/github.repo,SCM 字段引用 property。
2026-05-29 10:29:44 +08:00
yukaidi 4a0fe61d30 fix: ParserCreate 正则匹配 PWD 组时捕获 IllegalArgumentException
matcher.group(PWD) 在正则未定义 PWD 命名组时抛出
IllegalArgumentException 而非 IllegalStateException,原 catch 未覆盖
导致无提取码的链接(如QQ文件分享)返回 500。
2026-05-29 10:13:22 +08:00
yukaidi c62e109aff fix: 将硬编码的上游 GitHub URL 改为 fork 地址
Home.vue、Playground.vue、parser/pom.xml 中的 qaiu/netdisk-fast-download
URL 全部替换为 yukaidi1220/netdisk-fast-download。
2026-05-29 10:09:49 +08:00
qaiu ff400d3be3 Merge pull request #186 from yukaidi1220/260529
fix: 修复多个内存泄漏问题、资源管理缺陷及安全漏洞
感谢 @yukaidi1220 的贡献! 🎉

本次 PR 对项目做了一次全面深入的审查,涵盖了内存泄漏、资源管理、安全漏洞和代码缺陷,工作量很大,质量也很高,辛苦了👍

几个小建议供参考:

registerPeriodicCleanup 注册时机:目前在 的 块中调用,类加载时机不能保证 Vertx 已完全就绪。建议后续改为实现 接口,在 启动后执行,时机更可控。CacheServiceImplstaticAppRunPostExecVerticle

RateLimiter 并发安全:去掉 方向正确,但需确认 底层是 ,否则 与 并发执行存在竞态风险。另外 不能保证 的原子性,可考虑改用 。synchronizedipRequestMapConcurrentHashMapremoveIfcomputevolatile int countcount++AtomicInteger

PanBase 子类覆写 client:static 共享 WebClient 本身没问题,但如果有子类在构造中重新赋值 用于特殊场景(如代理),需确保这些子类有对应的逻辑,否则泄漏依然存在。this.client = WebClient.create(...)close()
2026-05-29 09:59:56 +08:00
yukaidi 1d243b8f1b fix: Docker EXPOSE 改为仅 6401,entrypoint 添加 -Duser.timezone
审查发现:
- EXPOSE 6400 误导用户映射后端端口,实际只需 6401(反向代理)
- TZ 环境变量不如 JVM -Duser.timezone 可靠
2026-05-29 09:57:05 +08:00
yukaidi 732a7f86fe fix: Docker entrypoint 以 root 运行再降权,彻底解决 volume 权限问题
去掉 USER appuser,entrypoint 以 root 身份运行,先 chown 修复
volume 挂载目录的权限,再通过 su 降权到 appuser 执行应用。
2026-05-29 09:49:22 +08:00
yukaidi 3c428f6a6d fix: ShutdownHook 注册顺序调整,确保 Vert.x 先于 JDBCPoolInit 关闭
JVM ShutdownHook 按注册逆序执行。将 AppMain 的 hook 移到 Deploy.start() 之前注册,
使执行顺序变为:Deploy hook(关闭 Vert.x)-> AppMain hook(关闭 JDBCPoolInit/JsParserExecutor)。
2026-05-29 09:01:08 +08:00
yukaidi bff17f2d4e fix: run.sh 改用 exec 直接运行 Java,修复 Docker 中 ShutdownHook 失效
原 nohup+tail-f 模式下,Docker SIGTERM 发给 tail 而非 Java 进程,
导致 ShutdownHook 永远不会触发,资源无法优雅关闭。
改为 exec 让 Java 成为 PID 1,正确接收信号。
同时支持通过 JVM_XMX/JVM_OPTS 环境变量自定义 JVM 参数。
2026-05-29 08:46:57 +08:00
yukaidi df600eaada fix: Dockerfile 预创建 db 和 logs 目录,修复非 root 用户无法写入 H2 数据库
appuser 运行时无法在 /app 下创建新子目录,H2 尝试创建 /app/db/nfdData.mv.db
时因目录不存在导致 AccessDeniedException。在 chown 之前预创建目录。
2026-05-29 08:34:59 +08:00
yukaidi dd4027c931 fix: LzTool/IzToolWithAuth 最后两处 .get().toString() NPE 风险
- LzTool:221 — obj.get("url").toString() 改为 String.valueOf()
- IzToolWithAuth:434 — get("uuid").toString() 添加 null 检查
2026-05-29 07:54:33 +08:00
yukaidi d1569195e4 fix: AppMain 注册 ShutdownHook 关闭 JDBCPoolInit 和 JsParserExecutor
审查发现 9c3945f 因模块依赖问题回退了 ShutdownHook 中的清理逻辑,
导致 JDBCPoolInit 连接池和 JsParserExecutor WorkerExecutor 在进程退出时
无法被显式关闭。将清理逻辑移到 web-service 模块的 AppMain(可依赖所有模块)。
2026-05-29 07:22:57 +08:00
yukaidi d6e88f0c53 fix: PlaygroundApi 移除重复 import 和未使用的 getStackTrace 死代码
审查发现 42925c8 引入了重复的 StandardCharsets import,且 getStackTrace
方法在移除堆栈泄露后无任何调用方,属于死代码。
2026-05-29 07:06:53 +08:00
yukaidi 2a9fa81e56 fix: CacheManager registerPeriodicCleanup 添加防重入和 Vertx 就绪检查
审查发现 static 块中注册定时任务存在时序风险:如果 CacheServiceImpl 在 Vertx
初始化前被加载,定时任务将注册失败且无法恢复。添加 cleanupRegistered 标志防止
重复注册,Vertx 未就绪时跳过并等待下次调用。
2026-05-29 07:02:23 +08:00
yukaidi 5eed1fdfa0 revert: 回退 MyData.java 的构造函数修改,恢复为空 TODO
用户明确表示不需要改动 MyData 的 TODO,回退 741d7aa 对该文件的修改。
2026-05-29 06:58:41 +08:00
yukaidi cf7fc4f502 fix: FjTool login() 中 token.substring 添加 null 保护
token 脱敏日志在 token 可能为 null 时会抛 NPE,添加 null 检查。
2026-05-29 06:58:09 +08:00
yukaidi 0ea31d631a fix: 移除 IzTool/IzToolWithAuth login() 中未使用的 h 变量(死代码)
并发安全修复引入的 h 变量创建后从未使用,后续请求仍通过
setTemplateParam("appToken", token) 传递 token。删除死代码并为
同一行的 token.substring 添加 null 保护。
2026-05-29 06:57:44 +08:00
yukaidi 74840ab63f fix(FsTool): 收窄 parseFileNameFromContentDisposition 中的 catch 异常类型
将 catch(Exception) 改为 catch(IllegalArgumentException),
只捕获 URLDecoder.decode 在遇到非法百分比编码时抛出的具体异常。
2026-05-29 06:38:35 +08:00
yukaidi 31f33339f1 fix(FsTool): 修复 parseById 中 get("paramJson") 可能导致的 NPE
当 otherParam 中缺少 "paramJson" 键时,后续 getString 调用会抛出 NPE。
添加 null 检查并提前返回失败。
2026-05-29 06:37:53 +08:00
yukaidi c0a0d0dc47 fix(FjTool): 修复 parseById 中 get("paramJson") 可能导致的 NPE
当 otherParam 中缺少 "paramJson" 键时,后续 getString 调用会抛出 NPE。
添加 null 检查并提前返回失败。
2026-05-29 06:37:12 +08:00
yukaidi 0cd77ee9b9 fix(IzTool): 修复 parseFileList 中 get("uuid") 可能导致的 NPE
当 otherParam 中缺少 "uuid" 键时,原代码直接调用 .toString() 会抛出
NullPointerException。改为先取出 Object 再做 null 检查。
2026-05-29 06:36:43 +08:00
yukaidi 741d7aa8ca fix: implement MyData @DataObject constructor deserialization
Implement the MyData(JsonObject) constructor to deserialize `id` and
`maxSize` fields from the provided JsonObject, replacing the empty TODO.
2026-05-29 06:28:39 +08:00
yukaidi d06a80dc73 fix: narrow catch exception type in LzTool.java
Replace 2 instances of `catch (Exception ignored)` with
`catch (MalformedURLException ignored)` around `new java.net.URL(url)`
calls, since that constructor only throws MalformedURLException.
2026-05-29 06:28:20 +08:00
yukaidi 86b9c43b8b fix: narrow catch exception type in ParserCreate.java
Replace 5 instances of `catch (Exception ignored)` with
`catch (IllegalStateException ignored)` around matcher.group() calls,
since that method only throws IllegalStateException when a named group
does not exist.
2026-05-29 06:27:51 +08:00
yukaidi 206981d4b4 revert: RouterHandlerFactory failureHandler 恢复返回 failure message
原 ctx.failure().getMessage() 是故意设计——RateLimiter 等组件通过 promise.fail()
传递用户友好的错误消息(如"请求次数太多了"),这些消息需要通过 failureHandler
返回给客户端。改为固定"服务器内部错误"会导致这些消息丢失。
仅添加 null 检查防止 NPE。
2026-05-29 06:15:56 +08:00
yukaidi 7d5831b5f4 fix: 彻底消除用户枚举和异常信息泄露的遗留问题
- UserServiceImpl: 3处"用户不存在"统一改为"用户名或密码错误"/"认证失败"
- RouterHandlerFactory: failureHandler 中 ctx.failure().getMessage() 改为"服务器内部错误"
2026-05-29 05:53:40 +08:00
yukaidi 7ca63985bd fix(test): 删除空的 ParserApiClientLinkTest 文件
该文件仅包含空行,无实际测试代码,直接删除。
2026-05-29 04:06:21 +08:00
yukaidi 06416a4e5f fix(test): JsFetchBridgeTest Vertx 资源泄漏修复
将各 @Test 方法中局部创建的 Vertx.vertx() 统一为成员变量,
通过 @Before 创建并初始化,@After 关闭,避免资源泄漏。
2026-05-29 04:06:13 +08:00
yukaidi 88739e8d1a fix(test): JsParserTest Vertx 资源泄漏修复
将各 @Test 方法中局部创建的 Vertx.vertx() 统一为成员变量,
通过 @Before 创建并初始化,@After 关闭,避免资源泄漏。
2026-05-29 04:04:52 +08:00
yukaidi ffaba4f496 fix(test): BaiduPhotoParserTest Vertx 资源泄漏修复
将各 @Test 方法中局部创建的 Vertx.vertx() 统一为成员变量,
通过 @Before 创建并初始化,@After 关闭,避免资源泄漏。
2026-05-29 04:03:37 +08:00
yukaidi c4f94a2bc7 fix: replace printStackTrace/System.out.println with logger in JsHttpClient, PlaygroundApi, LogStatistics, CacheServiceImpl, JsPlaygroundExecutor, JsPlaygroundLogger 2026-05-29 03:48:11 +08:00
yukaidi 49a3918244 fix: replace double-brace init with static block + Collections.unmodifiableMap; add final to UNIQUE_PREFIX 2026-05-29 03:40:06 +08:00
yukaidi caddff567f fix: replace System.out.println with logger in IpExtractor 2026-05-29 03:39:36 +08:00
yukaidi aed9e9f10d fix: replace System.out.println/printStackTrace with logger in ReqIpUtil; add final to static fields 2026-05-29 03:39:11 +08:00
yukaidi 6557b49383 fix: replace System.out.println with log.debug in MkgsTool 2026-05-29 03:38:36 +08:00
yukaidi 082cc4c743 fix: replace System.out.println with log in PodTool; mask token in log output 2026-05-29 03:38:07 +08:00
yukaidi e261ebe698 fix: replace System.out.println with log.debug in WsTool 2026-05-29 03:37:32 +08:00
yukaidi 5a0dc69186 chore: remove debug console.log statements
Remove debug logging from production code while preserving
console.error and console.warn for actual error/warning cases.
2026-05-29 03:28:22 +08:00
yukaidi 29d8bf3ea4 fix(security): add noopener,noreferrer to all window.open calls
Prevent reverse tabnapping by adding security attributes to all
window.open calls that open links in new tabs.
2026-05-29 03:24:53 +08:00
yukaidi 07a330cfd4 fix: LocalConstant 改用 ConcurrentHashMap 保证线程安全,LzTool 方法名拼写修正
- LocalConstant: HashMap → ConcurrentHashMap,put() 改用 putIfAbsent 消除 check-then-act 竞态
- LzTool: 私有方法 setDateAndComplate → setDateAndComplete(拼写修正,仅内部调用)
2026-05-29 03:14:10 +08:00
yukaidi aa30571709 fix(security): 升级 axios 版本 1.13.5 -> 1.16.1
修复 14 个安全漏洞
2026-05-29 03:08:59 +08:00
yukaidi 2bb9912cf5 fix(security): 升级 Vert.x 版本 4.5.24 -> 4.5.27 (parser 模块)
修复 SslContext 缓存 DoS 漏洞
2026-05-29 03:08:43 +08:00
yukaidi 8f77d9fe98 fix(security): 升级 Vert.x 版本 4.5.24 -> 4.5.27 (根 pom.xml)
修复 SslContext 缓存 DoS 漏洞
2026-05-29 03:08:27 +08:00
yukaidi 9cb32b3e8f fix(security): 升级 postgresql 驱动版本 42.7.3 -> 42.7.11
修复 SCRAM 认证 DoS 漏洞
2026-05-29 03:08:00 +08:00
yukaidi 080206925f fix(security): 升级 logback 版本 1.5.19 -> 1.5.32 (parser 模块)
修复 CVE-2024-12798 等 3 个安全漏洞
2026-05-29 03:07:39 +08:00
yukaidi 54d2a8189c fix(security): 升级 logback 版本 1.5.18 -> 1.5.32 (根 pom.xml)
修复 CVE-2024-12798 等 3 个安全漏洞
2026-05-29 03:07:22 +08:00
yukaidi 377bc12cf9 fix: Dockerfile 添加非 root 用户运行应用,提升容器安全性 2026-05-29 02:55:57 +08:00
yukaidi da715c8a8f fix: CacheManager 消除双括号初始化,改用标准 HashMap 写法 2026-05-29 02:55:32 +08:00
yukaidi fa4028296f fix: FjTool parseFileList 中 uuid 参数为 null 时的 NPE 2026-05-29 02:54:00 +08:00
yukaidi dc629a3126 fix: P115Tool 中 UA 参数为 null 时的 NPE,添加默认 User-Agent 2026-05-29 02:53:32 +08:00
yukaidi 746c7ad5b3 fix: 替换 e.printStackTrace() 和 System.out.println 为 logger 调用
- HttpProxyVerticle: err.printStackTrace() / e.printStackTrace() -> LOGGER.error()
- RouterHandlerFactory: 5处 printStackTrace() -> LOGGER.error()
- CommonUtil: e.printStackTrace() -> LOGGER.error()
- ReflectionUtil: 新增 LOGGER,3处 printStackTrace() -> LOGGER.error()
- CreateDatabase: e.printStackTrace() -> LOGGER.error()
- URLUtil: 新增 LOGGER,e.printStackTrace() -> LOGGER.error()
- LzTool: e.printStackTrace() -> log.error()
- MkwTool: 3处 System.out.println + 1处 printStackTrace -> log.debug()/log.error()
- PdbTool: e.printStackTrace() -> log.error()
- ParserApi: t.printStackTrace() -> log.error()
- CacheManager: 2处 Throwable::printStackTrace -> LOGGER.error()
- QQTool: 3处 System.out.println -> log.debug()
- FjTool: System.out.println -> log.debug()
2026-05-29 02:50:06 +08:00
yukaidi aef1b9ab11 fix: IzToolWithAuth 并发安全 - token/authFlag 改为 volatile,header 副本替代共享修改 2026-05-29 02:49:01 +08:00
yukaidi 79c9eb3dda fix: FjTool 并发安全 - token/userId/authFlag 改为 volatile,header0 副本替代共享修改 2026-05-29 02:48:13 +08:00
yukaidi 5a08ed68c2 fix: IzTool 并发安全 - token/authFlag 改为 volatile,header 副本替代共享修改 2026-05-29 02:47:27 +08:00
yukaidi e5a623c5a8 fix: 将 secret.yml 加入 .gitignore 防止敏感配置泄露 2026-05-29 02:41:00 +08:00
yukaidi 4586138bf1 fix: 修复 AESUtils.getRandomString 使用不安全的 Random,改为 SecureRandom 2026-05-29 02:40:32 +08:00
yukaidi 1dddec110e fix: 修复 PasswordUtil.checkPassword 中的时序攻击漏洞,使用 MessageDigest.isEqual() 2026-05-29 02:39:31 +08:00
yukaidi 46e9999e4c fix: 修复 CacheManager.updateTotalByField 中 getShareKeyTotal 缺少 onFailure 导致 Promise 永不完成 2026-05-29 02:39:05 +08:00
yukaidi a664ae3a56 fix: 修复 ParserApi 中 redirectUrl() 和 viewUrl() 的 Promise 未 complete 问题 2026-05-29 02:38:27 +08:00
yukaidi 9bcdcb2cb7 fix: 修复 MkwTool 中 set-cookie 为 null 时的 NPE 2026-05-29 02:37:22 +08:00
yukaidi 0b8592559a fix: 修复 CommonUtils.getURLParams() 中 fullUrl.getQuery() 返回 null 时的 NPE 2026-05-29 02:36:47 +08:00
yukaidi c0b18be5ab fix: 修复 ShareLinkInfo.getCacheKey() 中 otherParam.get("UA") 可能导致的 NPE 2026-05-29 02:36:20 +08:00
yukaidi 46b2eb1ccd 修复RouterHandlerFactory异常信息泄露:Future失败和异常捕获时返回通用错误消息,详细异常仅记日志 2026-05-29 02:26:12 +08:00
yukaidi d323376bed 修复RateLimiter count++非原子操作:将volatile int改为AtomicInteger,使用incrementAndGet() 2026-05-29 02:25:07 +08:00
yukaidi 838c86ae15 修复JsParserExecutor DCL模式缺少volatile:EXECUTOR字段添加volatile保证多线程可见性 2026-05-29 02:23:17 +08:00
yukaidi 42925c857c 修复Playground密码时序攻击和堆栈泄露:使用MessageDigest.isEqual()比较密码,移除返回给客户端的完整堆栈信息 2026-05-29 02:22:52 +08:00
yukaidi ba981d281f 修复Token日志泄露:日志中token仅打印前8个字符,其余用...替代 2026-05-29 02:21:12 +08:00
yukaidi 4159b884de 修复登录用户枚举和异常信息泄露:统一登录失败提示为'用户名或密码错误',隐藏数据库异常详情 2026-05-29 02:19:22 +08:00
yukaidi 36b38421e5 修复JWT签名验证时序攻击:使用MessageDigest.isEqual()替代String.equals()进行签名比较 2026-05-29 02:18:43 +08:00
yukaidi b77c8a80e9 fix(web-service): ParserApi 中 CacheManager/ServerApi 改为 static 避免每次请求重复创建
CacheManager 和 ServerApi 无请求级状态,每次 new 会造成不必要的对象分配,
改为 static final 字段复用;同时修复 viewURL 中内联 new ServerApi()。
2026-05-29 02:14:32 +08:00
yukaidi 886dcd039f fix(web-service): DbServiceImpl Thread.sleep 改为 vertx.setTimer 避免阻塞 event loop
sayOk() 中使用 Thread.sleep(4000) 会阻塞 Vert.x event loop 线程,
改为 vertx.setTimer 异步延迟完成 promise。
2026-05-29 02:13:42 +08:00
yukaidi d99885d396 fix(core): RouterVerticle Router 从 static final 改为实例字段
Router 声明为 static final 会在类加载时提前创建,
与 Vert.x 实例生命周期不匹配,改为在 start() 中初始化为实例字段。
2026-05-29 02:12:37 +08:00
yukaidi 942de9c430 fix(core): ConfigRetriever 成功路径也调用 close() 防止资源泄漏
readConfig() 中 onSuccess 回调未关闭 ConfigRetriever,
文件监听器和底层资源无法释放,现在成功和失败路径均调用 close()。
2026-05-29 02:11:35 +08:00
yukaidi ae3ff9ecbb fix(parser): JsPlaygroundLogger 日志列表限制最大 1000 条防止内存泄漏
日志列表 Collections.synchronizedList 无容量限制,长时间运行会无界增长。
新增 addLog() 方法,在添加前检查容量,超过 1000 条时移除最早的条目。
2026-05-29 02:09:59 +08:00
yukaidi bcc4315ea9 fix(parser): JsScriptLoader JarFile 改用 try-with-resources 防止文件句柄泄漏
JarFile 在手动 close() 时若中间抛异常会导致文件句柄未关闭,
改为 try-with-resources 确保无论正常或异常都能释放资源。
2026-05-29 02:08:50 +08:00
yukaidi 2f7304ab2d fix: Docker 镜像地址改为动态获取仓库名,修复 fork 仓库推送被拒绝 2026-05-29 01:47:16 +08:00
yukaidi 0df01ba3d5 fix: Deploy配置读取失败时主线程永久阻塞
BUG-05: 配置读取失败时仅调用printStackTrace,未调用LockSupport.unpark()
导致主线程永远阻塞在LockSupport.park()
现在失败时记录错误日志、unpark主线程并退出进程
2026-05-29 01:40:38 +08:00
yukaidi 710e454fd0 fix: dependency graph 步骤添加 continue-on-error,fork 仓库未启用时不影响 CI 2026-05-29 01:40:30 +08:00
yukaidi c46dfa00a0 fix: ReverseProxyVerticle HTTPS默认端口应为443而非80
BUG-03: URL使用https://前缀构造,但默认端口设为80(HTTP)
导致所有未指定端口的HTTPS代理目标连接失败
2026-05-29 01:40:05 +08:00
yukaidi 9a3ea05023 fix: HttpProxyVerticle代理认证绕过漏洞
SEC-01: 修复三个安全问题:
1. split.length<=1时直接放行请求,现在返回403
2. Base64解码无异常处理,现在捕获IllegalArgumentException返回403
3. 日志中明文记录密码,现在只记录用户名
2026-05-29 01:39:31 +08:00
yukaidi 66d7a62d3a fix: ReflectionUtil正则拼写错误boolen应为boolean
BUG-02: boolen拼写错误导致boolean[]类型参数永远不会被识别为基本类型数组
参数绑定失败并抛出RuntimeException
2026-05-29 01:38:53 +08:00
yukaidi f1b6cd3e18 fix: HttpProxyConf构造器port字段从未赋值,timeout被重复赋值
BUG-01: this.timeout = DEFAULT_PORT 应为 this.port = DEFAULT_PORT
导致port字段始终为null,代理服务器无法获取正确端口
2026-05-29 01:38:24 +08:00
yukaidi 189d1477a8 fix: 将 fetch-runtime.js 复制到 test resources,修复测试类加载不到资源文件
CI 运行测试时 JsParserExecutor.loadFetchRuntime() 通过 ClassLoader.getResourceAsStream
找不到 fetch-runtime.js。将文件复制到 parser/src/test/resources/ 确保测试类路径可用。
2026-05-29 01:25:32 +08:00
yukaidi 9c3945f45a fix: 修复编译错误,core 模块不能依赖 web-service/parser/core-database
core 模块的 Deploy.java 和 PostExecVerticle.java 直接引用了上层模块的类,
导致编译失败(package does not exist)。

- Deploy.java: 移除对 JDBCPoolInit 和 JsParserExecutor 的显式调用,
  vertx.close() 会级联关闭 Vert.x 创建的资源
- PostExecVerticle.java: 移除缓存定时清理逻辑(不能引用 web-service 的 CacheManager)
- CacheManager: 添加 registerPeriodicCleanup() 静态方法,通过 VertxHolder 注册定时任务
- CacheServiceImpl: static 块中调用 CacheManager.registerPeriodicCleanup(),服务加载时自动注册
2026-05-29 01:08:15 +08:00
yukaidi 77c7d6c5d6 fix: ShutdownHook 中 JDBCPoolInit.instance() 添加 null 检查,防止未初始化时 NPE
安装引导模式下数据库可能未配置,JDBCPoolInit.instance() 为 null,直接调用 close() 会 NPE。
2026-05-29 00:53:31 +08:00
yukaidi ab3009e9cc fix: ShutdownHook 接入 JDBCPoolInit.close() 和 JsParserExecutor.shutdownExecutor()
将已实现但未调用的 close()/shutdownExecutor() 接入 JVM ShutdownHook,显式释放资源。
关闭顺序:vertx.close() → JDBC 连接池 → WorkerExecutor 线程池,确保依赖关系正确。
2026-05-29 00:50:45 +08:00
yukaidi 1c2291f9cf Revert "fix(performance): CommonUtil initConfig 改为异步非阻塞读取"
This reverts commit 6dfa770137.
2026-05-29 00:37:27 +08:00
yukaidi efb135ee48 Revert "fix(error): URLUtil 异常不再吞没,改为抛出 IllegalArgumentException"
This reverts commit 0dfee8ab22.
2026-05-29 00:37:18 +08:00
yukaidi 0699c4a127 Revert "fix(memory): ReflectionUtil 添加 SoftReference + TTL 缓存清理"
This reverts commit be1ed3d46d.
2026-05-29 00:37:09 +08:00
yukaidi 33cef5f8e1 Revert "fix(resource): ReqIpUtil 使用统一 Vertx 单例"
This reverts commit 1fca578c07.
2026-05-29 00:36:55 +08:00
yukaidi 32d467b6d9 Revert "fix(security): SecurityClassFilter 改为白名单策略"
This reverts commit a83665ac44.
2026-05-29 00:35:10 +08:00
yukaidi 6dfa770137 fix(performance): CommonUtil initConfig 改为异步非阻塞读取 2026-05-29 00:33:27 +08:00
yukaidi 0dfee8ab22 fix(error): URLUtil 异常不再吞没,改为抛出 IllegalArgumentException
原代码 catch Exception 后仅打印堆栈,调用方无法感知解析失败。
改为抛出 IllegalArgumentException,让调用方明确知道 URL 解析失败。
2026-05-29 00:33:10 +08:00
yukaidi 85fe910f25 fix(bug): ParamUtil 修复数组越界问题
原代码当 kv.length == 0 时访问 kv[0] 会抛出异常。
改为跳过空参数,使用 split(=, 2) 限制分割次数。
2026-05-29 00:32:56 +08:00
yukaidi 6c60b0116f fix(resource): JDBCPoolInit 实现 AutoCloseable 添加 close() 方法
原代码单例模式无关闭方法,应用退出时数据库连接池无法释放。

改为:
- 实现 AutoCloseable 接口
- 添加 close() 方法关闭连接池
- 关闭后将 pool 置 null 防止重复关闭
2026-05-29 00:32:26 +08:00
yukaidi 8dfcf510f6 fix(resource): JsParserExecutor WorkerExecutor 懒加载 + 关闭支持
原代码静态初始化 WorkerExecutor,应用关闭时无法释放线程资源。

改为:
- 懒加载创建 WorkerExecutor
- 实现 AutoCloseable 接口
- 添加 shutdownExecutor() 静态方法供应用关闭时调用
2026-05-29 00:32:13 +08:00
yukaidi be1ed3d46d fix(memory): ReflectionUtil 添加 SoftReference + TTL 缓存清理
原代码使用永久缓存 Reflections 实例,占用大量内存且不释放。

改为:
- 使用 SoftReference 允许 GC 在内存不足时回收
- 添加 1 小时 TTL 防止长期占用
- 每次获取时自动清理过期条目
2026-05-29 00:32:02 +08:00
yukaidi 1fca578c07 fix(resource): ReqIpUtil 使用统一 Vertx 单例
原代码在字段级别直接创建 Vertx.vertx() 实例,
可能导致多个 Vertx 实例重复创建,浪费系统资源。

改为使用 WebClientVertxInit.get() 获取统一单例。
2026-05-29 00:31:49 +08:00
yukaidi a83665ac44 fix(security): SecurityClassFilter 改为白名单策略
原黑名单策略默认放行所有类,存在安全风险。
改为白名单策略,仅允许明确安全的 Java 类被 JS 访问。

允许: java.util.*, java.time.*, java.lang 基础类型, Nashorn API
拒绝: 默认拒绝所有未在白名单中的类
2026-05-29 00:31:38 +08:00
yukaidi 21e8a370c3 fix: ShutdownHook 改为同步等待 vertx.close(),修复 JVM 提前退出导致资源未释放
审查发现 vertx.close() 是异步操作,ShutdownHook 线程提交关闭任务后立即退出,
JVM 在资源实际释放前就终止了,与未修复时行为等价。
改为 CompletableFuture.get(10s) 阻塞等待,超时有 warn 日志。
同时移除无用的 mainVertx 字段,修正 JsExecUtils 误导性注释。
2026-05-28 23:58:52 +08:00
yukaidi 3dd4dd139b fix: 缓存清理异常日志级别从 debug 改为 warn,确保生产环境可见
审查发现数据库异常时 debug 级别会被静默吞掉,运维无法感知。
2026-05-28 23:43:07 +08:00
yukaidi afe2046bc8 fix: RateLimiter 移除 synchronized 并添加 volatile,修复事件循环阻塞
审查发现 synchronized 在 Vert.x 事件循环中会严重阻塞并发。
ConcurrentHashMap 本身已线程安全,移除 synchronized 锁。
RequestInfo 字段添加 volatile 保证多线程内存可见性。
2026-05-28 23:42:40 +08:00
yukaidi 6d24388690 fix: ServiceVerticle 保存 MessageConsumer 引用,修复 unregister 参数类型错误
审查发现 unregister(address) 参数类型不匹配,ServiceBinder.unregister() 需要
MessageConsumer 而非 String。改为保存 register() 返回的 MessageConsumer,
stop() 中直接调用 consumer.unregister()。同时修复日志在 clear() 后读 size 始终为 0 的 bug。
2026-05-28 23:42:24 +08:00
yukaidi 0b024a849a fix: 添加缓存表定时清理任务,修复 cache_link_info 无限增长
- CacheManager 添加 cleanupExpiredCache() 方法删除过期缓存记录
- PostExecVerticle 注册每小时执行一次的定时清理任务
- 原实现只有读时惰性检查过期,过期记录永远不会被删除,长期运行后数据库持续膨胀
2026-05-28 23:20:17 +08:00
yukaidi 8745dc3567 fix: RateLimiter 添加过期条目清理,修复 ipRequestMap 无限增长
当 Map 超过 1000 条目时触发惰性清理,移除所有已过期的 IP 条目。
原实现中过期条目只重置计数不删除 key,长期运行后 Map 持续膨胀。
同时消除多余的 ipRequestMap.get(ip) 调用,直接使用 compute() 返回值。
2026-05-28 23:16:53 +08:00
yukaidi 1f4c7019d4 fix: ServiceVerticle 添加 stop() 方法注销 EventBus 消费者,修复重部署时消费者累积泄漏
保存已注册的 EventBus 地址列表,在 stop() 中通过 ServiceBinder 逐一注销。
原实现有 start() 无 stop(),Verticle 重部署时旧消费者不会被注销,导致重复注册。
2026-05-28 23:15:12 +08:00
yukaidi 255e7b2fb5 fix: JsParserExecutor 和 JsHttpClient 添加资源清理,修复解析完成后资源泄漏
- JsHttpClient 添加 close() 方法释放 WebClient 连接池
- JsParserExecutor 添加 close() 方法,清除 ScriptEngine 中注入的 Java 对象引用
- parse()/parseFileList()/parseById() 均在 onComplete 回调中调用 close() 释放资源
2026-05-28 23:13:09 +08:00
yukaidi 7419e536cf fix: JsExecUtils 缓存 ScriptEngineManager,避免每次调用都创建新实例
ScriptEngineManager 是重量级对象(含类加载器扫描等),将其缓存为 static 字段,
executeDynamicJs/executeOtherJs 每次调用只创建轻量的 ScriptEngine 实例。
2026-05-28 23:08:50 +08:00
yukaidi 74df000287 fix: PanBase WebClient 改为静态共享单例,修复每请求创建4个实例的资源泄漏
WebClient 是线程安全的,将 client/clientNoRedirects/clientDisableUA 改为 static 共享实例,
避免每次解析请求创建4个独立 WebClient(各含连接池)。
clientSession 仍保持实例级(管理 cookie,非线程安全)。
代理模式下仍创建独立 WebClient 实例。
2026-05-28 23:06:45 +08:00
yukaidi 2e0127d609 fix: 注册 JVM ShutdownHook,修复 Vert.x 实例进程退出时不关闭的资源泄漏
Deploy.deployVerticle() 中创建的 Vert.x 实例是局部变量,进程退出时无法优雅关闭,
导致 Netty EventLoopGroup、JDBC 连接池、内部定时器等资源泄漏。
添加 ShutdownHook 在 JVM 关闭时调用 vertx.close() 级联释放所有资源。
2026-05-28 23:04:54 +08:00
qaiu 2b9168e8df 更新 LzTool.java
fix:蓝奏目录识别问题
2026-05-28 12:33:16 +08:00
qaiu 7bb3cf7c51 Update README.md 2026-05-17 21:59:19 +08:00
qaiu 2ce5058be0 Add professional version cloud storage options 2026-05-17 21:57:39 +08:00
q a5fc41f152 1 2026-04-29 22:40:57 +08:00
q 3245a27156 release v3.0.2 2026-04-29 22:37:07 +08:00
q ce0fbf65aa fixed test docx 2026-04-28 15:57:50 +08:00
qaiu eb87c3d865 Update app.properties 2026-04-27 20:06:19 +08:00
qaiu 990a90e461 更新 README.md 2026-04-27 12:04:29 +08:00
qaiu 45cb255a4d 更新 README.md 2026-04-27 12:01:11 +08:00
qaiu 5e8b8a3575 更新 README.md 2026-04-24 03:05:29 +08:00
qaiu c317e53c24 Merge pull request #182 from qaiu/copilot/update-readme-netdisk-comparison
README: Remove 奶牛快传(cowtransfer) references
2026-04-23 19:41:33 +08:00
copilot-swe-agent[bot]andqaiu 29b59d8450 Remove 奶牛快传(cowtransfer) from README.md
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/63030ac7-158c-43ca-9dcd-254e03c6f8f4

Co-authored-by: qaiu <[email protected]>
2026-04-23 11:27:04 +00:00
qaiu 97b7e2f86e Merge pull request #181 from qaiu/copilot/check-authentication-mechanism
fix: 演练场密码登录后所有API返回"未授权访问"
2026-04-23 09:34:18 +08:00
copilot-swe-agent[bot]andqaiu 2f55294b58 fix: 修复演练场输入密码后提示未授权访问的问题
根本原因:框架 RouterHandlerFactory 未注册 SessionHandler,
导致 ctx.session() 始终返回 null。登录时密码校验通过但认证
状态被静默丢弃,后续所有请求均返回"未授权访问"。

修复方案:将 Session 鉴权改为 Token(Bearer)鉴权:
- PlaygroundConfig: 新增 generateToken()/validateToken(),
  使用 SecureRandom 生成密码学安全 Token,并在生成时
  清理过期 Token 防止内存泄漏
- PlaygroundApi: login() 返回 Token;checkAuth() 从
  Authorization 请求头中读取并校验 Token
- playgroundApi.js: 添加请求拦截器自动携带 Token;
  login() 从响应中提取并保存 Token 到 localStorage
- Playground.vue: 后端报告未认证时同步清除 playground_token

Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/52144d13-cd49-4a3d-b279-9b8d6cbad757

Co-authored-by: qaiu <[email protected]>
2026-04-23 01:22:09 +00:00
q 2161190d9a config: switch active profile to dev 2026-04-22 16:10:22 +08:00
q aaae301cbc release v3.0.0: core refactoring, new AppRun/PostExecVerticle, proxy and router improvements 2026-04-22 15:57:35 +08:00
qaiu 9ca6511235 Merge pull request #180 from qaiu/copilot/fix-filemanagerplugin-copy-back
fix: restore missing copy of build output to webroot/nfd-front in vue.config.js
2026-04-22 12:57:06 +08:00
copilot-swe-agent[bot]andqaiu 8582290db3 fix: restore copy of nfd-front to webroot/nfd-front in vue.config.js
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/e4e9323a-d8f5-48b1-9476-7efab611f978

Co-authored-by: qaiu <[email protected]>
2026-04-22 04:40:03 +00:00
copilot-swe-agent[bot] 5ff33d7c58 Initial plan 2026-04-22 04:39:22 +00:00
q 0cfb69a240 fix frontend shortcut parsing and proxy static serving 2026-04-22 04:24:22 +08:00
q 110a9beda4 fix parser onedrive url decoding and bump vulnerable deps 2026-04-22 02:03:04 +08:00
qaiu fd6a3f5929 更新 README.md 2026-04-21 23:22:52 +08:00
qaiu 82ad6ec427 更新 README.md 2026-04-20 07:51:57 +08:00
qaiu 1bfc7c960d 更新 README.md 2026-04-19 19:34:57 +08:00
qaiu 332f49f483 更新 README.md 2026-04-19 19:32:13 +08:00
q b967c7a1bb Merge pull request #177 from qaiu/pr-177
# Conflicts:
#	parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java
#	parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java
2026-04-19 08:51:44 +08:00
q 519dbe1f77 docs: update fs support and dev proxy port 2026-04-19 08:47:01 +08:00
q c64855d4ad feat: improve downloader integration for parsed files 2026-04-19 08:43:27 +08:00
qaiu d50d10ba89 Merge pull request #178 from qaiu/copilot/implement-feishu-share-parser-java
feat: 支持飞书云盘分享解析
2026-04-18 16:47:33 +08:00
copilot-swe-agent[bot]andqaiu e79478c421 refactor: address code review - extract constants, improve logging
- Extract Pattern constants as static final fields
- Extract PAGE_SIZE constant for API pagination
- Add logging for NumberFormatException in file size parsing

Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/56418d09-a396-40cf-a080-c71e4a69c323

Co-authored-by: qaiu <[email protected]>
2026-04-18 08:46:06 +00:00
copilot-swe-agent[bot]andqaiu c401a84eb8 feat: add Feishu cloud disk share parser (file + folder support)
Add FsTool parser for Feishu (飞书) cloud disk share links.
Supports both file and folder share URL formats:
- File: https://xxx.feishu.cn/file/{token}
- Folder: https://xxx.feishu.cn/drive/folder/{token}

The parser:
- Fetches anonymous session cookies from share page
- Uses Range probe to detect filename and size
- Returns download URL with required headers (Cookie, Referer)
- Supports folder listing via v3 API with pagination
- Updates README with Feishu in supported cloud disk list

Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/56418d09-a396-40cf-a080-c71e4a69c323

Co-authored-by: qaiu <[email protected]>
2026-04-18 08:43:26 +00:00
copilot-swe-agent[bot] a9978a6202 Initial plan 2026-04-18 08:36:17 +00:00
qaiu cc9d0a4b30 更新 README.md 2026-04-15 05:16:47 +08:00
qaiu 696ef832f8 更新 README.md 2026-04-13 08:03:04 +08:00
qaiu 442f9d1d2e Merge pull request #176 from qaiu/copilot/optimize-pandomain-template
fix(PanDomainTemplate): 修复现有网盘域名模板正则表达式中的多处缺陷
2026-04-12 19:45:06 +08:00
copilot-swe-agent[bot]andqaiu a45a64380c 优化乐云(LE)正则以支持 /mshare/ 格式,补充测试用例
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/7341ab49-5648-498c-b153-0fcd3b3f8aad

Co-authored-by: qaiu <[email protected]>
2026-04-12 11:40:55 +00:00
copilot-swe-agent[bot] df7442c3dd Initial plan 2026-04-12 11:39:26 +00:00
qaiu 1a949725f3 更新 README.md 2026-04-12 19:33:44 +08:00
qaiu 7c14f3437b 更新 README.md 2026-04-12 19:32:20 +08:00
qaiu bc402da365 更新 README.md 2026-04-12 19:30:47 +08:00
qaiu b95b474660 更新 README.md 2026-04-12 19:29:10 +08:00
qaiu 691a3770d9 更新 README.md 2026-04-12 19:28:06 +08:00
copilot-swe-agent[bot]andqaiu 49ec54a3b5 refactor(tests): 改善测试注释说明,增强可读性
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/5523822b-ffe2-4e95-ac13-fd3f0dc41970

Co-authored-by: qaiu <[email protected]>
2026-04-12 11:19:51 +00:00
qaiu 2fc15f437e 更新 README.md 2026-04-12 19:18:15 +08:00
qaiu 190f6ca7ab 更新 README.md 2026-04-12 19:17:44 +08:00
qaiu c683fd27d4 更新 README.md 2026-04-12 19:17:18 +08:00
copilot-swe-agent[bot]andqaiu d815cc1010 fix(PanDomainTemplate): 优化现有网盘域名模板正则表达式
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/5523822b-ffe2-4e95-ac13-fd3f0dc41970

Co-authored-by: qaiu <[email protected]>
2026-04-12 11:17:06 +00:00
copilot-swe-agent[bot] fd84ff1200 Initial plan 2026-04-12 11:05:17 +00:00
qaiu a420bad305 Update README.md 2026-04-09 16:46:29 +08:00
qaiu 6ef6e47580 Update README.md 2026-04-07 08:39:41 +08:00
qaiu 94f83ec296 Fix duplicate Trendshift badge in README
Removed duplicate Trendshift badge from README.
2026-04-07 08:23:44 +08:00
qaiu 702569c701 Add Trendshift badge to README
Added Trendshift badge to README for repository tracking.
2026-04-07 08:22:40 +08:00
qaiu d4940ca9ee fixed: 123-YePan: Fix regex pattern for share key extraction 2026-04-07 08:20:06 +08:00
qaiu dbd1c138ca Merge pull request #173 from qaiu/copilot/identify-yifang-cloud-new-format
feat: recognize new Fangcloud /share/ URL format
2026-04-05 17:59:54 +08:00
copilot-swe-agent[bot]andqaiu 0b49c55cf3 feat: recognize new Fangcloud /share/ URL format in addition to /sharing/ and /s/
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/dc483348-3899-4448-80ce-c2352e6bc23e

Co-authored-by: qaiu <[email protected]>
2026-04-05 08:20:46 +00:00
copilot-swe-agent[bot] b1ec3b2eea Initial plan 2026-04-05 08:16:46 +00:00
qaiu 9ea89feee7 更新 README.md 2026-04-04 20:16:58 +08:00
qaiu 4a843194a3 Merge pull request #171 from qaiu/copilot/update-ws-domain-recognition
feat(WS): 扩展文叔叔域名匹配 + 补充单元测试
2026-03-18 12:21:31 +08:00
copilot-swe-agent[bot]andqaiu 03503115fd feat: 文叔叔(WS)域名扩展 + 单元测试补充
Co-authored-by: qaiu <[email protected]>
2026-03-18 02:18:53 +00:00
copilot-swe-agent[bot] 1870aef60e Initial plan 2026-03-18 02:11:33 +00:00
qaiu ed8fd66d1e 更新 README.md 2026-03-16 20:15:36 +08:00
qaiu c1c4c8cdc5 更新 README.md 2026-03-16 20:14:57 +08:00
q 256ec3b152 Fixed: Lz parser return filename error. 2026-03-07 13:45:26 +08:00
q da490e5bbd Merge branch 'main' of github.com:qaiu/netdisk-fast-download 2026-03-06 10:39:49 +08:00
q ba0ac86eea LzToooool 2026-03-06 10:38:11 +08:00
qaiu b5544c4131 更新 README.md 2026-02-28 21:31:20 +08:00
qaiu d94ea6aaf3 更新 README.md 2026-02-25 01:42:46 +08:00
q 742dda8677 更新前端版本号 v0.2.1b3,前端接口调整: randomAuth直接使用后端加密的encryptedAuth 2026-02-23 10:39:13 +08:00
qaiu 76e0db0cfb Merge pull request #169 from rensumo/main
fix(web-service): randomAuth 仅返回 encryptedAuth,避免泄露认证信息
2026-02-23 09:41:28 +08:00
rensumo 6458a6e2c5 refactor qk parser and add package metadata 2026-02-23 08:01:08 +08:00
rensumo cbf2294a8e fix: randomAuth only returns encryptedAuth 2026-02-22 20:49:24 +08:00
rensumo 9d558bf4e2 fix: avoid NPE in randomAuth code check 2026-02-22 20:23:46 +08:00
q fdf067c25e 更新 夸克解析、小飞机解析,前端版本号 2026-02-22 19:15:15 +08:00
qaiu 5f9da47513 Merge pull request #167 from rensumo/main
feat: 新增捐赠账号池并完善认证参数解码/失败熔断机制
2026-02-22 18:00:32 +08:00
rensumo b150641e3b fix: stabilize auth/decrypt flow and refresh donate account counts 2026-02-22 16:06:22 +08:00
rensumo 6355c35452 fix: 修复捐赠账号失败计数与路由外部访问问题 2026-02-22 12:36:20 +08:00
rensumo 81ffbbd6b1 feat: harden donated-account failure token and document key usage 2026-02-22 12:24:47 +08:00
rensumo 07c650a474 feat: Add validation for donateAccount endpoint 2026-02-22 11:12:35 +08:00
rensumo 04443bcb5e feat: 添加捐赠账号功能,支持数据库存储和随机选择账号解析 2026-02-19 12:59:47 +08:00
qaiu d06974d556 Merge pull request #163 from qaiu/dependabot/npm_and_yarn/web-front/axios-1.13.5
Bump axios from 1.12.0 to 1.13.5 in /web-front
2026-02-13 01:17:00 +08:00
qaiu 80fa51fd0a 更新 PanDomainTemplate.java 删除118网盘解析 2026-02-12 20:35:23 +08:00
qaiu a170134456 删除 P118Tool.java 2026-02-12 20:33:49 +08:00
qaiu ba76e0dc6c 更新 README.md 2026-02-12 17:12:52 +08:00
dependabot[bot] 4a79542a46 Bump axios from 1.12.0 to 1.13.5 in /web-front
Bumps [axios](https://github.com/axios/axios) from 1.12.0 to 1.13.5.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.12.0...v1.13.5)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.13.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-11 17:55:01 +00:00
qaiu 999e260a60 更新 README.md 2026-02-10 15:38:00 +08:00
q ed40b254e4 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.gitignore
2026-02-06 02:51:10 +08:00
qaiu 8e9b9e6347 删除 auth-test.properties 2026-02-05 22:11:32 +08:00
q 4fa2d88204 chore: 删除包含敏感信息的auth-test.properties并添加到.gitignore 2026-02-05 22:08:00 +08:00
q cb6d4811d2 fix: 完善 .gitignore 防止 Maven 变量目录被提交
- 添加更多构建目录忽略规则
- 防止 Maven 变量未替换时创建的字面量目录被提交
- 包括 build/, out/, classes/ 等常见构建目录
2026-02-05 20:38:12 +08:00
q c19601b209 fix: 完善 .gitignore 防止 Maven 变量目录被提交
- 添加更多构建目录忽略规则
- 防止 Maven 变量未替换时创建的字面量目录被提交
- 包括 build/, out/, classes/ 等常见构建目录
2026-02-05 20:38:12 +08:00
q 6e6215ad7e feat(v0.2.1): 添加认证参数支持和客户端下载命令生成
主要更新:
- 新增 auth 参数加密传递支持 (QK/UC Cookie认证)
- 实现下载命令自动生成 (curl/aria2c/迅雷)
- aria2c 命令支持 8 线程 8 片段下载
- 修复 cookie 字段映射问题
- 优化前端 clientLinks 页面
- 添加认证参数文档和测试用例
- 更新 .gitignore 忽略编译目录
2026-02-05 20:35:47 +08:00
q 3a25e5f2ae feat(v0.2.1): 添加认证参数支持和客户端下载命令生成
主要更新:
- 新增 auth 参数加密传递支持 (QK/UC Cookie认证)
- 实现下载命令自动生成 (curl/aria2c/迅雷)
- aria2c 命令支持 8 线程 8 片段下载
- 修复 cookie 字段映射问题
- 优化前端 clientLinks 页面
- 添加认证参数文档和测试用例
- 更新 .gitignore 忽略编译目录
2026-02-05 20:35:47 +08:00
q 97ae1a5e92 fix: 修复安全漏洞 - 升级依赖版本
- Vert.x: 4.5.22 → 4.5.14 (包含所有安全修复,Netty版本由其管理)
- Logback: 1.5.19 → 1.5.18 (最新稳定版)
- SLF4J: 2.0.5 → 2.0.16
- Jackson: 2.14.2 → 2.18.2
- 移除不必要的 Netty BOM 依赖
2026-02-04 17:21:13 +08:00
q 7fc6367b9e fix: 修复安全漏洞 - 升级依赖版本
- Vert.x: 4.5.22 → 4.5.14 (包含所有安全修复,Netty版本由其管理)
- Logback: 1.5.19 → 1.5.18 (最新稳定版)
- SLF4J: 2.0.5 → 2.0.16
- Jackson: 2.14.2 → 2.18.2
- 移除不必要的 Netty BOM 依赖
2026-02-04 17:21:13 +08:00
q a4a521a6f8 fix: 修复多个安全漏洞
修复的安全问题:
1. Vert.x Web static handler 缓存操纵漏洞 - 升级到 4.5.11
2. Netty CRLF注入漏洞 (CVE-2024-47535) - 强制使用 4.1.115.Final
3. Logback 任意代码执行漏洞 (CVE-2024-12798) - 升级到 1.5.15
4. Vert.x-Web XSS漏洞 - 升级到 4.5.11
5. Logback 类实例化漏洞 (CVE-2023-6378) - 升级到 1.5.15

变更:
- 降级 vertx.version: 4.5.22 → 4.5.11 (稳定安全版本)
- 添加 netty.version: 4.1.115.Final (通过 netty-bom 强制版本)
- 降级 logback.version: 1.5.19 → 1.5.15 (稳定安全版本)
- 升级 slf4j.version: 2.0.5 → 2.0.16
- 升级 jackson.version: 2.14.2 → 2.18.2
- 在 dependencyManagement 中添加 Netty BOM 和 Logback 版本管理
2026-02-04 17:14:53 +08:00
q cb5c83be41 fix: 修复多个安全漏洞
修复的安全问题:
1. Vert.x Web static handler 缓存操纵漏洞 - 升级到 4.5.11
2. Netty CRLF注入漏洞 (CVE-2024-47535) - 强制使用 4.1.115.Final
3. Logback 任意代码执行漏洞 (CVE-2024-12798) - 升级到 1.5.15
4. Vert.x-Web XSS漏洞 - 升级到 4.5.11
5. Logback 类实例化漏洞 (CVE-2023-6378) - 升级到 1.5.15

变更:
- 降级 vertx.version: 4.5.22 → 4.5.11 (稳定安全版本)
- 添加 netty.version: 4.1.115.Final (通过 netty-bom 强制版本)
- 降级 logback.version: 1.5.19 → 1.5.15 (稳定安全版本)
- 升级 slf4j.version: 2.0.5 → 2.0.16
- 升级 jackson.version: 2.14.2 → 2.18.2
- 在 dependencyManagement 中添加 Netty BOM 和 Logback 版本管理
2026-02-04 17:14:53 +08:00
q 2056a91071 优化超星解析,清理冗余代码 2026-02-04 17:10:52 +08:00
q c760e47154 优化超星解析,清理冗余代码 2026-02-04 17:10:52 +08:00
qaiu f6209a8959 更新 README.md
添加夸克,uc解析
2026-02-03 16:18:55 +08:00
qaiu 3d30835ffa 更新 README.md
添加夸克,uc解析
2026-02-03 16:18:55 +08:00
q 72ed0ea8f8 Fixed 蓝奏云目录解析cookie验证问题 2026-02-03 13:27:09 +08:00
q ea47bb39eb Fixed 蓝奏云目录解析cookie验证问题 2026-02-03 13:27:09 +08:00
qaiu d698f82299 Update Playground password protection link in README 2026-02-03 10:41:42 +08:00
qaiu c45967e175 Update Playground password protection link in README 2026-02-03 10:41:42 +08:00
q ba4666c32a Fixed 蓝奏优享解析,v019b21 2026-02-02 16:02:36 +08:00
q 1b357de2f3 Fixed 蓝奏优享解析,v019b21 2026-02-02 16:02:36 +08:00
q d6d37e8204 fixed: 修复蓝奏优享 #159, #158 2026-02-02 15:59:44 +08:00
q cf76a5ddd6 fixed: 修复蓝奏优享 #159, #158 2026-02-02 15:59:44 +08:00
q cb9dbfcc69 蓝奏云规则更新 2026-02-01 10:33:25 +08:00
q 4e27bf0dc4 蓝奏云规则更新 2026-02-01 10:33:25 +08:00
qaiu 42b366ed0f Clean up launch configurations in launch.json,run AppMain
Removed unused Java launch configurations from launch.json.
2026-01-27 07:53:20 +08:00
qaiu 39c082e4ee Clean up launch configurations in launch.json,run AppMain
Removed unused Java launch configurations from launch.json.
2026-01-27 07:53:20 +08:00
qaiu 4021c507b6 更新 README.md, 添加接口文档https://nfdparser.apifox.cn 2026-01-27 00:05:56 +08:00
qaiu 95193fc8f9 更新 README.md, 添加接口文档https://nfdparser.apifox.cn 2026-01-27 00:05:56 +08:00
qaiu 4d8e82080d 189.qaiu.top 大文件解析体验版,支持天翼云盘,移动云盘等
#153 #156 #119 #92
2026-01-26 13:47:37 +08:00
qaiu 4f3131979f 189.qaiu.top 大文件解析体验版,支持天翼云盘,移动云盘等
#153 #156 #119 #92
2026-01-26 13:47:37 +08:00
qaiu fca608b44e 更新 README.md
天翼云盘演示站限时体验
2026-01-26 13:09:49 +08:00
qaiu ad9a8ab0b0 更新 README.md
天翼云盘演示站限时体验
2026-01-26 13:09:49 +08:00
q 34cb89a6ea fix(LeTool): 修复子目录 fileId URL 编码问题
- 在构建 parserUrl 时对 fileId 进行 URL 编码
- 避免 %2B 等特殊字符被前端 axios 自动解码导致请求失败
- 添加异常处理和降级方案
2026-01-23 17:59:19 +08:00
q 1f3161216a fix(LeTool): 修复子目录 fileId URL 编码问题
- 在构建 parserUrl 时对 fileId 进行 URL 编码
- 避免 %2B 等特殊字符被前端 axios 自动解码导致请求失败
- 添加异常处理和降级方案
2026-01-23 17:59:19 +08:00
q 69d5f269bd fix(LeTool): 修复联想乐云目录解析失败问题
- 添加统一的 HEADERS 定义,包含完整的浏览器请求头
- 修复 API_URL_PREFIX 路径(share -> mshare)
- 添加 getCleanShareId() 方法处理 URL 中的查询参数
- 所有请求统一使用 putHeaders(HEADERS)
- 增加调试日志输出
2026-01-23 13:24:11 +08:00
q e5f7b164a3 fix(LeTool): 修复联想乐云目录解析失败问题
- 添加统一的 HEADERS 定义,包含完整的浏览器请求头
- 修复 API_URL_PREFIX 路径(share -> mshare)
- 添加 getCleanShareId() 方法处理 URL 中的查询参数
- 所有请求统一使用 putHeaders(HEADERS)
- 增加调试日志输出
2026-01-23 13:24:11 +08:00
q 459c974cb8 联想乐云文件夹解析 2026-01-23 12:45:51 +08:00
q 7974c92382 联想乐云文件夹解析 2026-01-23 12:45:51 +08:00
qaiu 2f22cb01eb LeTool乐云目录解析 2026-01-23 03:26:35 +08:00
qaiu b51add45f1 LeTool乐云目录解析 2026-01-23 03:26:35 +08:00
qaiu e17fb99de4 启用在线脚本解析器 2026-01-06 02:21:19 +08:00
qaiu cb132359fd 启用在线脚本解析器 2026-01-06 02:21:19 +08:00
q 0f926a57ef 更新Playground和JsHttpClient相关功能,整理文档结构 2026-01-06 00:00:37 +08:00
q b58b3658b5 更新Playground和JsHttpClient相关功能,整理文档结构 2026-01-06 00:00:37 +08:00
q 4380bfe0d6 Merge branch 'main' of github.com:qaiu/netdisk-fast-download 2026-01-04 09:30:54 +08:00
q 9f25aca242 Merge branch 'main' of github.com:qaiu/netdisk-fast-download 2026-01-04 09:30:54 +08:00
q 8127cd0758 fixed. 演练场静态打包问题 2026-01-04 09:29:20 +08:00
q 343c017c79 fixed. 演练场静态打包问题 2026-01-04 09:29:20 +08:00
qaiu 71a220f42b 升级前端版本 2026-01-04 01:14:26 +08:00
qaiu d3b02676ec Update README.md 2026-01-03 21:20:54 +08:00
q d8f0dc4f8e 更新代码和文档 2026-01-03 21:11:04 +08:00
q 48aa5b6148 Add functional test report
- Document all completed tests and fixes
- Verify BUG1, BUG2, BUG3 fixes
- Confirm TypeScript removal
- Confirm text updates (JS演练场 → 脚本演练场)
- Service startup verification
2026-01-02 19:40:26 +08:00
q a989841a89 Remove TypeScript-related code and documentation
- Remove TypeScript API endpoints from PlaygroundApi
- Remove TypeScript methods from DbService interface and implementation
- Delete PlaygroundTypeScriptCode model class
- Delete TypeScript documentation files
- Clean up unused imports
2026-01-02 19:27:21 +08:00
q 86783e8e46 Merge branch 'copilot/add-playground-enhancements' 2026-01-02 19:25:05 +08:00
q 66b9bcc53a Fix playground bugs and remove TypeScript compiler
- Fix BUG1: JavaScript timeout with proper thread interruption using ScheduledExecutorService
- Fix BUG2: Add URL regex validation before execution in playground test API
- Fix BUG3: Register published parsers to CustomParserRegistry on save/update/delete
- Remove TypeScript compiler functionality (tsCompiler.js, dependencies, UI)
- Add password authentication for playground access
- Add mobile responsive layout support
- Load playground parsers on application startup
2026-01-02 19:24:47 +08:00
qaiu ff08615d1e 更新 README.md 2025-12-23 07:55:59 +08:00
qaiu 4a6c3a1f90 更新 README.md 2025-12-08 23:46:58 +08:00
copilot-swe-agent[bot]andqaiu c79702eba8 Address code review feedback: protect types.js endpoint and improve code readability
Co-authored-by: qaiu <[email protected]>
2025-12-07 05:27:07 +00:00
copilot-swe-agent[bot]andqaiu 41fc935c09 Fix JsonResult API calls and add documentation
Co-authored-by: qaiu <[email protected]>
2025-12-07 05:24:38 +00:00
copilot-swe-agent[bot]andqaiu 5fbbe5b240 Add playground loading animation, password auth, and mobile layout support
Co-authored-by: qaiu <[email protected]>
2025-12-07 05:20:06 +00:00
copilot-swe-agent[bot] 9c121c03f2 Initial plan 2025-12-07 05:11:50 +00:00
copilot-swe-agent[bot]andqaiu b74c3f31c4 Add implementation summary in Chinese
Co-authored-by: qaiu <[email protected]>
2025-12-07 04:57:03 +00:00
copilot-swe-agent[bot]andqaiu f23b97e22c Address code review feedback - improve code quality
Co-authored-by: qaiu <[email protected]>
2025-12-07 04:52:22 +00:00
copilot-swe-agent[bot]andqaiu 0560989e77 Complete TypeScript compiler integration with examples and documentation
Co-authored-by: qaiu <[email protected]>
2025-12-07 04:48:38 +00:00
copilot-swe-agent[bot]andqaiu f2c9c34324 Add TypeScript compiler integration - core implementation
Co-authored-by: qaiu <[email protected]>
2025-12-07 04:43:36 +00:00
copilot-swe-agent[bot] a97268c702 Initial plan 2025-12-07 04:34:47 +00:00
copilot-swe-agent[bot]andqaiu 2654b550fb Add comprehensive implementation summary
Co-authored-by: qaiu <[email protected]>
2025-12-06 22:56:10 +00:00
copilot-swe-agent[bot]andqaiu 12a5a17a30 Address code review feedback: fix Promise.race, improve statusText, use English error messages
Co-authored-by: qaiu <[email protected]>
2025-12-06 22:52:37 +00:00
copilot-swe-agent[bot]andqaiu e346812c0a Complete backend implementation with comprehensive documentation
Co-authored-by: qaiu <[email protected]>
2025-12-06 22:51:08 +00:00
copilot-swe-agent[bot]andqaiu 6b2e391af9 Add fetch polyfill tests and documentation
Co-authored-by: qaiu <[email protected]>
2025-12-06 22:49:32 +00:00
copilot-swe-agent[bot]andqaiu 199456cb11 Implement fetch polyfill and Promise for ES5 backend
Co-authored-by: qaiu <[email protected]>
2025-12-06 22:44:52 +00:00
copilot-swe-agent[bot] 636994387f Initial plan 2025-12-06 22:38:17 +00:00
qaiu 90c79f7bac Merge pull request #140 from rensumo/main
增加快速部署方式
2025-12-02 17:21:05 +08:00
rensumo 79601b36a5 增加快速部署 2025-12-02 14:43:29 +08:00
rensumo 96cef89f08 增加快速部署 2025-12-02 14:42:07 +08:00
rensumo e057825b25 增加快速部署
Add quick deployment instructions and Docker deployment section.
2025-12-02 14:40:53 +08:00
qaiu ebe848dfe8 Merge pull request #139 from Edakerx/main
Update README.md
2025-11-30 12:30:26 +08:00
Edakerx e259a0989e Update README.md
添加赞助商
2025-11-30 12:20:07 +08:00
q f750aa68e8 js演练场漏洞修复 2025-11-30 02:07:56 +08:00
q 49b8501e86 js演练场,ye2 2025-11-29 03:44:47 +08:00
q fc2e2a4697 Merge remote-tracking branch 'origin/main' 2025-11-29 03:42:25 +08:00
q b4b1d7f923 js演练场 2025-11-29 03:41:51 +08:00
q df646b8c43 js演练场 2025-11-29 02:56:25 +08:00
qaiu 8e790f6b22 更新 README.md 2025-11-28 20:33:07 +08:00
q 2e76af980e front ver 0.1.9.b12 2025-11-28 19:50:29 +08:00
q 80ccbe5b62 fixed. 123跨区下载错误 2025-11-28 19:48:19 +08:00
q aa0cd68f7f 客户端链接(实验性),js解析器插件,汽水音乐,一刻相册,咪咕音乐 2025-11-25 16:34:24 +08:00
qaiu 51833148b1 更新 README.md 2025-11-17 23:06:54 +08:00
q 0fa77ebf21 Merge remote-tracking branch 'origin/main' 2025-11-15 21:50:45 +08:00
q 584c075930 - [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
- [咪咕音乐-migu](https://music.migu.cn/)
- [一刻相册-baidu_photo](https://photo.baidu.com/)
2025-11-15 21:49:40 +08:00
qaiu 9e7a3718a4 Update README with new links and information 2025-11-14 06:33:16 +08:00
q 0e2ca2f1ca ce盘优化 2025-11-13 19:32:44 +08:00
q 52e889333b Merge remote-tracking branch 'origin/main' 2025-11-13 18:20:32 +08:00
qaiu 4745440079 Merge pull request #136 from qaiu/copilot/add-ce4tool-parser
Add Cloudreve 4.x API support with Ce4Tool parser
2025-11-13 18:18:30 +08:00
q b5628eac17 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	parser/src/test/java/cn/qaiu/parser/clientlink/impl/CurlLinkGeneratorTest.java
2025-11-13 17:58:59 +08:00
copilot-swe-agent[bot]andqaiu d23b11577e Simplify and optimize Ce4Tool and CeTool version detection logic
Co-authored-by: qaiu <[email protected]>
2025-11-10 09:59:48 +00:00
copilot-swe-agent[bot]andqaiu f1dd9fc0ee Add Ce4Tool for Cloudreve 4.x API support and update CeTool with version detection
Co-authored-by: qaiu <[email protected]>
2025-11-10 09:57:41 +00:00
copilot-swe-agent[bot]andqaiu 0877fadcfb Initial planning for Cloudreve 4.x API support
Co-authored-by: qaiu <[email protected]>
2025-11-10 09:53:58 +00:00
copilot-swe-agent[bot] 733059dc8e Initial plan 2025-11-10 09:50:49 +00:00
qaiu 321380c2b9 更新 README.md 2025-11-08 00:23:22 +08:00
qaiu deb121a51b 更新 README.md 2025-11-08 00:20:30 +08:00
qaiu b6aef7c239 更新 README.md 2025-11-08 00:19:16 +08:00
qaiu b13a7a5ee1 更新 README.md
测试下载链接更新
2025-11-04 15:44:17 +08:00
qaiu fff6a00690 更新 README.md
接口参考优化
2025-10-30 22:42:28 +08:00
qaiu b4da3cee20 Merge pull request #135 from qaiu/copilot/remove-test-filelist
[WIP] Remove test filelist from repository
2025-10-30 20:24:45 +08:00
copilot-swe-agent[bot]andqaiu 0a650996a1 Remove accidentally committed test-filelist.java file
Co-authored-by: qaiu <[email protected]>
2025-10-30 12:21:01 +00:00
copilot-swe-agent[bot] 37b91cd388 Initial plan 2025-10-30 12:18:50 +00:00
q 42b721eabf feat: 新增客户端协议生成系统,支持8种主流下载工具
🚀 核心功能
- 新增完整的客户端下载链接生成器系统
- 支持ARIA2、Motrix、比特彗星、迅雷、wget、cURL、IDM、FDM、PowerShell等8种客户端
- 自动处理防盗链参数(User-Agent、Referer、Cookie等)
- 提供可扩展的生成器架构,支持自定义客户端

🔧 技术实现
- ClientLinkGeneratorFactory: 工厂模式管理生成器
- DownloadLinkMeta: 元数据存储下载信息
- ClientLinkUtils: 便捷工具类
- 线程安全的ConcurrentHashMap设计

🌐 前端集成
- 新增ClientLinks.vue界面,支持客户端链接展示
- Element Plus图标系统,混合图标显示
- 客户端检测逻辑优化,避免自动打开外部应用
- 移动端和PC端环境判断

📚 文档完善
- 完整的CLIENT_LINK_GENERATOR_GUIDE.md使用指南
- API文档和测试用例
- 输出示例和最佳实践

从单纯的网盘解析工具升级为完整的下载解决方案生态
2025-10-24 09:29:05 +08:00
q 231d5c3fb9 修复WPS域名匹配正则表达式
- 修复PWPS正则表达式,支持子域名匹配
- 从 https://www\.kdocs\.cn/l/(?<KEY>.+) 修改为 https://(?:[a-zA-Z\d-]+\.)?kdocs\.cn/l/(?<KEY>.+)
- 现在可以正确匹配带子域名的WPS链接,如 www.kdocs.cn
- 测试通过:WPS云文档解析功能正常工作
2025-10-23 09:20:26 +08:00
q 064efdf3f3 feat: 完善JavaScript解析器功能
- 优化JsScriptLoader,支持JAR包内和文件系统的自动资源文件发现
- 移除预定义文件列表,完全依赖自动检测
- 添加getNoRedirect方法支持重定向处理
- 添加sendMultipartForm方法支持文件上传
- 添加代理配置支持
- 修复JSON解析的压缩处理问题
- 添加默认请求头支持(Accept-Encoding、User-Agent、Accept-Language)
- 更新文档,修正导出方式说明
- 优化README.md结构,删除不符合模块定位的内容
- 升级parser版本到10.2.1
2025-10-22 17:34:19 +08:00
qaiu 7b364a0f90 更新 README.md 2025-10-22 12:27:01 +08:00
q c8a4ca7f16 feat: 添加getNoRedirect方法支持302重定向处理
- 在JsHttpClient中添加getNoRedirect方法,支持不自动跟随重定向的HTTP请求
- 修改baidu-photo.js解析器,使用getNoRedirect获取真实的下载链接
- 更新测试用例断言,验证重定向处理功能正常工作
- 修复百度一刻相册解析器302重定向问题,现在能正确获取真实下载链接
2025-10-21 17:47:59 +08:00
qaiu 97627b824c 更新 README.md 2025-10-21 12:49:10 +08:00
q 6dbdc9bd90 优化工作流 2025-10-20 13:45:26 +08:00
q 4166ea10af 忽略package-lock.json 2025-10-20 13:42:44 +08:00
q fa12ab2c51 Merge branch 'main' of github.com:qaiu/netdisk-fast-download 2025-10-20 13:38:07 +08:00
q 4fc4ed8640 feat: 添加 WPS 云文档/WPS 云盘解析支持 (closes #133)
- 新增 PwpsTool 解析器,支持 WPS 云文档直链获取
- 调用 WPS API: https://www.kdocs.cn/api/office/file/{shareKey}/download
- 前端添加 kdocs.cn 链接识别规则
- 前端预览功能优化:WPS 云文档直接使用原分享链接预览
- 后端预览接口特殊处理:判断 shareKey 以 pwps: 开头自动重定向
- 支持提取文件名和有效期信息
- 更新 README 文档,添加 WPS 云文档支持说明

Parser 模块设计:
- 遵循开放封闭原则,易于扩展新网盘
- 只需实现 IPanTool 接口和注册枚举即可
- 支持自定义域名解析和责任链模式

技术特性:
- 免登录获取下载直链
- 支持在线预览(利用 WPS 原生功能)
- 文件大小限制:10M(免费版)/2G(会员版)
- 初始空间:5G(免费版)
2025-10-20 13:33:53 +08:00
qaiu 48172f2769 Merge pull request #134 from qaiu/copilot/update-parser-documentation
更新parser文档:开发者应继承PanBase并添加WebClient请求流程说明
2025-10-18 07:11:52 +08:00
copilot-swe-agent[bot]andqaiu c7e6d68fbd Update parser documentation with PanBase inheritance and WebClient flow
Co-authored-by: qaiu <[email protected]>
2025-10-17 23:03:39 +00:00
copilot-swe-agent[bot] e6672a51c5 Initial plan 2025-10-17 22:59:33 +00:00
q abde7841ac web展示内部版本号 2025-10-17 17:19:27 +08:00
q 8e661ed1c5 版本号,123文件信息解析支持 2025-10-17 16:41:02 +08:00
q 217cb3a776 parser v10.1.17发布到maven central 允许开发者依赖
1. 添加自定义解析器扩展和相关示例
2. 优化pom结构
2025-10-17 15:51:52 +08:00
q b8c1bca900 parser v10.1.17发布到maven central 允许开发者依赖
1. 添加自定义解析器扩展和相关示例
2. 优化pom结构
2025-10-17 15:51:41 +08:00
q 5e09b8e92a parser v10.1.17发布到maven central 允许开发者依赖
1. 添加自定义解析器扩展和相关示例
2. 优化pom结构
2025-10-17 15:50:45 +08:00
q c16bde6bb8 parser发布到maven central方便开发者依赖, pom文件结构调整 2025-10-16 18:08:03 +08:00
qaiu eb06eb9f3d 更新 README.md 2025-10-11 00:23:41 +08:00
qaiu 0c49088098 更新 README.md
远期规划
2025-10-11 00:19:07 +08:00
qaiu b970241a64 Merge pull request #132 from rensumo/main
更新linux部署的下载链接
2025-10-10 09:23:36 +08:00
rensumo 6c5aafc11e 更新linux部署的下载链接 2025-10-09 20:32:22 +08:00
qaiu ca0846f4a7 Merge pull request #131 from rensumo/main
修复命令行部署自启动命令的错误
2025-10-08 21:42:47 +08:00
rensumo 14f7fcc5ad 修复命令行部署自启动命令的错误 2025-10-08 15:31:04 +08:00
q 23a18aba5c web version 2025-09-28 13:44:07 +08:00
q 2d5a79bb16 Fixed: lz规则更新 #129 #128 2025-09-28 13:38:58 +08:00
q 51e1bbefbb 升级netty依赖 2025-09-15 10:10:30 +08:00
q 6647fc5371 fixed. ye解析,去除正则匹配, 分享key去除后缀, #123, #125 2025-09-15 09:44:32 +08:00
q b67544f0cd fixed. ye解析,去除正则匹配, #124,#125 2025-09-15 09:25:39 +08:00
qaiu ef5826a73b Merge pull request #124 from qaiu/dependabot/npm_and_yarn/web-front/axios-1.12.0
Bump axios from 1.11.0 to 1.12.0 in /web-front
2025-09-12 17:41:37 +08:00
dependabot[bot] a48adbd0df Bump axios from 1.11.0 to 1.12.0 in /web-front
Bumps [axios](https://github.com/axios/axios) from 1.11.0 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.11.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-09-12 09:37:49 +00:00
q 5c60493a24 修复123解析 #123 2025-09-12 17:34:07 +08:00
q 55e6227de0 优化docker脚本5 2025-09-10 18:23:58 +08:00
q 24a7395004 优化docker脚本4 2025-09-10 18:16:37 +08:00
q b2a7187fc5 优化docker脚本3 2025-09-10 18:10:51 +08:00
q ace7cdc88e 优化docker脚本2 2025-09-10 18:05:14 +08:00
q 2e909b5868 优化docker脚本 2025-09-10 17:53:49 +08:00
q de78bcbc98 docker多平台支持,add 树莓派 2025-09-10 17:45:27 +08:00
q c560f0e902 docker多平台支持 2025-09-10 17:27:44 +08:00
q 88860c9302 docker多平台支持 2025-09-10 17:21:33 +08:00
q ef65d0e095 Merge remote-tracking branch 'origin/main' 2025-09-10 17:10:52 +08:00
q 6438505f4a icloud国际版支持,QQ邮箱文件信息获取 2025-09-10 17:10:39 +08:00
qaiu 1be5030dd1 添加docker多平台支持 2025-09-10 17:06:40 +08:00
q 421b2f4a42 Merge remote-tracking branch 'origin/main' 2025-08-19 18:57:00 +08:00
q a66bf84381 直链API添加文件信息
修复蓝奏目录文件大小处理报错问题 #120
2025-08-19 18:56:42 +08:00
qaiu 0c4d366d6d 更新 README.md 2025-08-14 19:36:09 +08:00
qaiu a1d0a921fa 更新 README.md 2025-08-14 19:28:44 +08:00
q 2092230a61 更新文档 2025-08-14 15:27:03 +08:00
q 6e5ae6eff3 Merge remote-tracking branch 'origin/main' 2025-08-12 13:32:09 +08:00
q 4f8259d772 1. iz match fixed
2. redirect res content add "text/html; charset=utf-8"
2025-08-12 13:29:59 +08:00
qaiu 8b987d9824 Update README.md 2025-08-11 13:32:33 +08:00
q e8ba451d18 build status 2025-08-11 13:26:06 +08:00
q 77758db463 Merge remote-tracking branch 'origin/main' 2025-08-11 13:23:59 +08:00
q 6c58598a8e 用户API 2025-08-11 13:23:30 +08:00
qaiu 3ac35230a3 Update README.md 2025-08-11 13:18:00 +08:00
q ca91302d28 Merge remote-tracking branch 'origin/main' 2025-08-11 13:14:59 +08:00
q e07272a5dc 添加支持 QQ闪传,微雨云,优化前端逻辑 2025-08-11 13:14:43 +08:00
qaiu 461305e1df Update README.md 2025-08-08 12:33:46 +08:00
q 8e8ab10a0f Merge remote-tracking branch 'origin/main' 2025-08-05 15:50:50 +08:00
q e754326925 fixed p118 link 2025-08-05 15:50:32 +08:00
qaiu 4c92994c6f 更新 README.md 2025-08-05 13:14:09 +08:00
qaiu 66c57f47ac Merge pull request #113 from qaiu/dependabot/maven/org.apache.commons-commons-lang3-3.18.0
Bump org.apache.commons:commons-lang3 from 3.12.0 to 3.18.0
2025-07-30 09:17:42 +08:00
dependabot[bot] ec689eadd8 Bump org.apache.commons:commons-lang3 from 3.12.0 to 3.18.0
Bumps org.apache.commons:commons-lang3 from 3.12.0 to 3.18.0.

---
updated-dependencies:
- dependency-name: org.apache.commons:commons-lang3
  dependency-version: 3.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-21 09:05:14 +00:00
qaiu c1e15709a7 Merge pull request #110 from qaiu/dependabot/maven/core-database/org.apache.commons-commons-lang3-3.18.0
Bump org.apache.commons:commons-lang3 from 3.12.0 to 3.18.0 in /core-database
2025-07-21 17:02:12 +08:00
q 2848937ce7 Merge remote-tracking branch 'origin/main' 2025-07-18 13:00:33 +08:00
q 42ff0c21b2 1. 默认缓存时间修改
2. 文件夹解析异常处理
3. 首页优化
2025-07-18 13:00:12 +08:00
qaiu 3ed7e547e6 更新 README.md
联系方式更新
2025-07-16 13:30:47 +08:00
q fad8e688df 首页样式优化 2025-07-15 18:09:11 +08:00
q b2f2dcac4c Merge remote-tracking branch 'origin/main' 2025-07-14 16:16:13 +08:00
q fcba78e977 启动参数优化 2025-07-14 16:16:00 +08:00
qaiu 77c9d777a1 更新 README.md 2025-07-12 13:47:03 +08:00
dependabot[bot] 4460659210 Bump org.apache.commons:commons-lang3 in /core-database
Bumps org.apache.commons:commons-lang3 from 3.12.0 to 3.18.0.

---
updated-dependencies:
- dependency-name: org.apache.commons:commons-lang3
  dependency-version: 3.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-12 01:16:21 +00:00
q 8631524107 移动端布局优化 2025-07-11 11:51:08 +08:00
q 0579588814 优化构建流v0.1.9b6b 2025-07-10 19:17:22 +08:00
q df2bfb6ac7 优化构建流 2025-07-10 19:12:15 +08:00
q 517b6f8910 del 2025-07-10 19:07:02 +08:00
q 94a46d2833 目录解析支持优化 v0.1.9b6 2025-07-10 18:59:59 +08:00
q 1631a0faa1 目录解析支持优化 v0.1.9b5 2025-07-10 18:58:12 +08:00
qaiu 06d5943cb6 Merge remote-tracking branch 'origin/main' 2025-07-09 07:58:10 +08:00
qaiu 3095e13676 ye目录解析 2025-07-09 07:57:47 +08:00
qaiu 482cbce7e8 Update maven.yml 2025-07-09 07:09:58 +08:00
qaiu ef2fc3ab98 lz目录解析预览 2025-07-09 07:06:12 +08:00
q 5b57b05eae 目录解析支持优化 v0.1.9b2 2025-07-08 18:58:38 +08:00
q 093579c6f5 Merge remote-tracking branch 'origin/main' 2025-07-08 18:57:17 +08:00
q c2d4990d7f 目录解析支持优化 v0.1.9b2 2025-07-08 18:55:19 +08:00
qaiu 40e8380738 更新 README.md 2025-07-08 04:03:42 +08:00
qaiu b716e1e861 更新 README.md 2025-07-08 04:02:50 +08:00
qaiu 8432d4952c 更新 README.md 2025-07-08 03:51:46 +08:00
qaiu dd8f085f63 更新 README.md 2025-07-08 03:51:00 +08:00
qaiu 161ff8d8a3 更新 README.md 2025-07-08 03:48:19 +08:00
qaiu 1390cd0104 更新 README.md 2025-07-08 03:47:09 +08:00
qaiu 7a02b1e97f 更新 README.md 2025-07-08 03:46:19 +08:00
qaiu 036f107c90 更新 README.md
docker镜像更新
2025-07-08 03:20:18 +08:00
qaiu 5652383450 Update README.md 2025-07-08 02:23:01 +08:00
qaiu 9a047a5da0 更新 README.md 2025-07-04 19:38:18 +08:00
qaiu 8975743a37 更新 README.md 2025-07-04 19:34:53 +08:00
q 0e30eafe49 目录解析支持 2025-07-04 19:20:06 +08:00
q 7facb62f21 Merge remote-tracking branch 'origin/main' 2025-07-04 19:17:35 +08:00
q 30d43cb961 目录解析支持 2025-07-04 19:16:36 +08:00
q c505b17e35 目录解析支持 2025-07-04 19:11:39 +08:00
qaiu 080c4c753d Create update-release-badge.yml 2025-07-04 09:34:22 +08:00
q ade0d34d91 start 2025-07-02 18:40:32 +08:00
qaiu 56d082eb0b Update maven.yml
提交任意tag 触发工作流
2025-07-02 18:36:45 +08:00
qaiu 795c4529ba Update AppMain.java 2025-07-02 18:29:46 +08:00
qaiu 0f5cfe22ea Update maven.yml
构建docker添加tag版本
2025-07-02 18:26:48 +08:00
qaiu 925ad2c3a5 Update AppMain.java 2025-07-02 18:20:30 +08:00
qaiu f3e96907fe Update maven.yml
docker镜像构建添加版本号
2025-07-02 18:19:01 +08:00
qaiu 75a1e58a7d Update AppMain.java 2025-07-02 18:13:19 +08:00
yyzy-official 379e889f71 Update maven.yml
修改工作流版本号问题
2025-07-02 18:09:48 +08:00
yyzy-official 40c06f397b Update README.md
收款码隐藏
2025-07-02 17:41:23 +08:00
yyzy-official 9e9302436e Update README.md
1. 微信联系方式
2. 预览地址
2025-07-02 17:39:14 +08:00
qaiu 6d816d4193 Update README.md 2025-07-02 15:50:29 +08:00
qaiu 438eda9c08 Update README.md 2025-07-02 15:46:55 +08:00
q ace39e4633 jdk24适配 2025-06-17 15:48:52 +08:00
qaiu 7712391f29 更新 README.md 2025-06-06 07:17:49 +08:00
qaiu 65f08dcb02 Update README.md 2025-06-04 16:28:50 +08:00
qaiu 1d332aa6f4 Update README.md 2025-06-04 16:26:56 +08:00
QAIU ba81641517 1. 修改docker构建失败 2025-06-04 15:37:50 +08:00
qaiu fb30bdb879 Update README.md 2025-06-04 15:27:21 +08:00
qaiu fc451d3b41 Update README.md 文件夹解析说明 2025-06-04 15:25:26 +08:00
QAIU ffee1f3462 1. 修复 小飞机解析错误#106
2. 添加 123云盘文件夹分享下解析为压缩包下载直链
3. 添加 123云盘全部域名支持: "www.123pan.com","www.123pan.cn","www.123865.com","www.123684.com","www.123912.com","www.123pan.cn"
2025-06-04 15:13:05 +08:00
QAIU f30027dd13 1. 蓝奏云域名适配 2025-05-28 17:01:50 +08:00
QAIU 8b6aad17f4 0 2025-05-08 18:18:52 +08:00
QAIU b77930adfb remove yarn.lock 2025-05-08 18:18:15 +08:00
qaiu aff8f88076 Update README.md 2025-04-23 14:41:52 +08:00
qaiu 4e6582e24c Update nfd-service-template.xml 2025-04-23 14:31:56 +08:00
qaiu fa9acaccfd Update README.md 2025-04-07 13:29:43 +08:00
QAIU 0414f85f12 115域名变动,360pan暂不可用,ctfile域名变动 2025-04-03 10:57:28 +08:00
QAIU 527dd0eeb4 城通分享格式适配, 优化日志打印 2025-03-28 17:59:28 +08:00
QAIU 74ed7475c9 json异常时, 快速失败 2025-03-24 13:35:40 +08:00
qaiu 54dc3dba96 蓝奏云随机404 问题修复 2025-03-22 12:53:33 +08:00
qaiu 9980159090 更新 README.md 2025-03-17 19:24:38 +08:00
QAIU 0b193ebb00 Merge remote-tracking branch 'origin/main' 2025-03-14 14:03:47 +08:00
QAIU f5fc9843b2 微信版QQ邮箱中转站解析优化(已改名为QQ邮箱云盘) 2025-03-14 14:03:29 +08:00
qaiu df1f67dd26 Update README.md 2025-02-25 10:29:25 +08:00
qaiu b069a5f576 Update README.md 2025-02-25 10:26:47 +08:00
qaiu 7686763a03 Update README.md 2025-02-25 10:25:35 +08:00
qaiu 635a6eac37 Update README.md 2025-02-25 10:02:23 +08:00
QAIU 877edc535f md 2025-02-24 17:39:23 +08:00
QAIU 01d59e3c1e add 超星盘,360盘 2025-02-22 16:55:00 +08:00
QAIU fece2799e3 Merge remote-tracking branch 'origin/main' 2025-02-21 18:28:08 +08:00
QAIU de9756ee86 优化细节 2025-02-21 18:27:52 +08:00
qaiu 51f047a51b 更新 README.md 2025-02-20 18:11:39 +08:00
qaiu 04b66e82b7 Update README.md 2025-02-20 18:06:58 +08:00
qaiu df89253647 Update README.md 2025-02-20 17:47:00 +08:00
qaiu 45dbca794e 更新 README.md 2025-02-13 12:14:59 +08:00
qaiu 857bf28f99 Update README.md 2025-02-12 16:25:16 +08:00
QAIU e07ce15228 新增文件列表解析接口/redirectUrl/:type/:param 2025-02-10 14:19:18 +08:00
QAIU 0637bcfd8e 新增文件列表解析接口/v2/getFileList?url= 2025-02-07 19:28:09 +08:00
QAIU 23db0563ac 新增文件列表解析接口/v2/getFileList?url= 2025-02-07 19:27:48 +08:00
QAIU ccba71aa4e MySQL支持, 其他优化 2025-02-06 16:54:54 +08:00
QAIU fee4bf2ad6 MySQL支持, 其他优化 2025-02-06 16:52:22 +08:00
QAIU 5052fea9ef MySQL支持, 其他优化 2025-02-06 16:52:06 +08:00
qaiu e85215fca1 Update README.md 2025-02-06 15:51:46 +08:00
qaiu e42fe45329 Update README.md 2025-02-06 15:50:26 +08:00
qaiu 4240815bd1 Create FUNDING.yml 2025-02-05 11:11:01 +08:00
qaiu 6f0c5305e2 更新 README.md 2025-01-26 16:26:50 +08:00
qaiu 757005cad8 更新 README.md 2025-01-26 16:09:45 +08:00
qaiu 81651ad97c 更新 README.md 2025-01-26 15:31:43 +08:00
qaiu f3763b6058 优化内核, QQ邮箱微信账户分享,添加123请求header 2025-01-24 19:21:58 +08:00
qaiu 82478dc485 add: 网易云音乐云盘分享URL支持 2025-01-23 17:23:58 +08:00
qaiu 703fd05d43 Update README.md 2025-01-10 14:33:30 +08:00
qaiu ff868b6e2a Update README.md 2025-01-10 14:21:00 +08:00
qaiu 051a74b37b Update README.md 2025-01-10 14:19:59 +08:00
qaiu a0a1085623 Update README.md 2025-01-10 14:14:20 +08:00
qaiu 2612d3919c Update README.md 2025-01-10 14:12:55 +08:00
qaiu 6f123a236f Update README.md 2025-01-10 14:12:04 +08:00
QAIU 71e57e6a08 Merge remote-tracking branch 'origin/main' 2025-01-07 15:04:07 +08:00
QAIU 7cb18d8186 remove yarn.lock 2025-01-07 15:03:53 +08:00
qaiu cdbf670ece Update maven.yml 2025-01-07 15:00:18 +08:00
QAIU e0dafee617 remove yarn.lock 2025-01-07 14:48:16 +08:00
QAIU c37bce1563 优化解析器链接识别 2025-01-07 13:19:40 +08:00
QAIU 0b3c77d644 115pan 2025-01-07 11:13:52 +08:00
QAIU 2cf85caf86 115pan分享识别优化 2025-01-06 18:01:23 +08:00
QAIU 594010ba88 代理服务配置优化 2025-01-04 17:38:33 +08:00
QAIU d91460d2e2 修复蓝奏优享解析失败, gz压缩的json解析 2025-01-04 15:21:15 +08:00
QAIU 89713e6ac9 修复蓝奏优享解析失败 2025-01-04 14:21:32 +08:00
QAIU 17c9b2538c Merge remote-tracking branch 'origin/main' 2025-01-04 14:18:46 +08:00
QAIU d337b003cb 常规测试 2024-12-30 18:11:53 +08:00
qaiu 8f1485656b Update README.md
add docker加速地址
2024-12-23 13:05:25 +08:00
qaiu f0c4ec3031 Update maven.yml 2024-12-23 13:02:51 +08:00
qaiu 458be84aca Update maven.yml 2024-12-23 13:00:28 +08:00
qaiu c7716aad34 Update README.md 2024-12-18 13:05:16 +08:00
QAIU 4a3e734408 1. onedrive
常规测试
2024-12-18 11:46:06 +08:00
qaiu 54cc212753 Merge pull request #78 from xrgzs/add-ghcr
增加 ghcr.io 容器构建
2024-12-17 16:24:03 +08:00
xrgzs f4ae1eaa51 PR时不更新依赖图 2024-12-17 16:15:59 +08:00
xrgzs d2537282c9 增加 ghcr.io 容器构建 2024-12-17 15:47:05 +08:00
QAIU 87527688c3 1. 代理配置 2024-12-17 15:21:59 +08:00
qaiu 2be0b6505a 更新 P115Tool.java
UA问题说明
2024-12-17 09:37:48 +08:00
QAIU 672f100c7c 1. 动态UA 2024-12-16 19:01:55 +08:00
QAIU 5af402c0c5 1. 动态UA 2024-12-16 18:52:02 +08:00
QAIU 693a4f0f63 1. P115网盘解析BUG
2. 完善onedrive支持
2024-12-16 15:54:06 +08:00
QAIU f8d2426ff6 API 2024-12-16 13:19:15 +08:00
QAIU 973a9bedcd 添加115网盘支持(测试中)#75 2024-12-16 13:15:53 +08:00
QAIU a583733400 修复小飞机解析失败 2024-12-16 12:31:34 +08:00
QAIU 78eb51b3ca 处理编译失败问题 2024-11-29 11:50:44 +08:00
QAIU a2606be9d8 修复蓝奏优享#71 2024-11-26 13:02:02 +08:00
qaiu a4975c72ce Merge pull request #70 from qaiu/dependabot/npm_and_yarn/web-front/eslint/plugin-kit-0.2.3
Bump @eslint/plugin-kit from 0.2.2 to 0.2.3 in /web-front
2024-11-18 12:12:25 +08:00
dependabot[bot] 58f96822a4 Bump @eslint/plugin-kit from 0.2.2 to 0.2.3 in /web-front
Bumps [@eslint/plugin-kit](https://github.com/eslint/rewrite) from 0.2.2 to 0.2.3.
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/release-please-config.json)
- [Commits](https://github.com/eslint/rewrite/compare/plugin-kit-v0.2.2...plugin-kit-v0.2.3)

---
updated-dependencies:
- dependency-name: "@eslint/plugin-kit"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2024-11-15 21:50:27 +00:00
qaiu 96b0d94986 Update README.md 2024-11-13 13:59:54 +08:00
QAIU 70b38db8c5 IP互助计划, 添加正向代理服务(TODO) 2024-11-12 19:05:43 +08:00
QAIU b6a9c2d3a0 . 2024-11-07 18:37:08 +08:00
QAIU a01df6c7db web file package config 2024-11-07 12:34:24 +08:00
QAIU 4455bee570 pod update 2024-11-05 18:42:32 +08:00
qaiu cd0adef2ed Merge pull request #67 from qaiu/dependabot/npm_and_yarn/web-front/http-proxy-middleware-2.0.7
Bump http-proxy-middleware from 2.0.6 to 2.0.7 in /web-front
2024-11-04 19:13:24 +08:00
dependabot[bot] 4aa24a65fb Bump http-proxy-middleware from 2.0.6 to 2.0.7 in /web-front
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2024-11-04 10:58:07 +00:00
QAIU 760dca8772 升级vue3 2024-11-04 18:56:49 +08:00
QAIU 8269673619 1. add iCloud解析 2024-11-04 14:18:56 +08:00
qaiu 82ec586554 手残 又不小心提交了 ads代码 2024-11-02 18:59:33 +08:00
qaiu ca98cc8708 里程碑版本前奏: 1. 添加google云盘解析(需要联网), 2. web页面人工智障自动解析URL, 3. 优化一堆细节问题, 4: git换行配置(待验证), 5. 酷我解析可用 2024-11-02 18:58:15 +08:00
QAIU f07800985d 1. 优化123pan日志, 2. 微博短链测试, 3. 分享类添加其他参数Map(Cookie支持准备) 2024-11-01 18:18:29 +08:00
qaiu b042df93b7 更新 urltool.py 2024-10-29 18:59:23 +08:00
QAIU ecf4441946 .. 2024-10-29 18:36:51 +08:00
QAIU 39b2612840 .. 2024-10-28 18:59:18 +08:00
QAIU 218f486e6b .. 2024-10-28 18:58:37 +08:00
QAIU cfcc25f175 add onedrive 2024-10-28 18:57:26 +08:00
qaiu 155e88223c 0.0 前端优化,302标识短链添加/d前缀 2024-10-28 14:36:39 +08:00
QAIU 05039ece51 add 118, 微雨云 2024-10-26 16:04:49 +08:00
QAIU 1c673f2b46 idea run Main. 2024-10-25 18:18:42 +08:00
QAIU 2232a70228 Merge remote-tracking branch 'origin/main' 2024-10-25 18:18:34 +08:00
QAIU e661b1d817 idea run Main. 2024-10-25 18:18:17 +08:00
qaiu 5a6a65f580 Update README.md 2024-10-25 14:54:19 +08:00
qaiu 5cdd3bcd30 Update README.md 2024-10-25 14:52:36 +08:00
qaiu 1233a885b8 Update README.md 2024-10-25 14:49:09 +08:00
QAIU adf56cd768 add 酷狗音乐, 酷我音乐, 网易云音乐, QQ音乐 2024-10-25 14:38:57 +08:00
QAIU cd4b208be9 一处SQL语法错误 2024-10-24 10:48:36 +08:00
QAIU 502de1a5d0 0..0 2024-10-23 18:08:10 +08:00
qaiu 4158f869a3 0.0 2024-10-23 18:04:34 +08:00
QAIU ff569d339c 1. add music parser 2024-10-21 19:14:29 +08:00
QAIU 10eec323dd 1. add 网易云音乐解析 2024-10-20 18:16:51 +08:00
qaiu 0a3db51c7d 更新 app-dev.yml 蓝奏设置合理缓存时间 2024-10-11 08:15:10 +08:00
qaiu 229aee0b30 Update README.md 2024-10-09 15:38:29 +08:00
QAIU 44714aa981 1. add 城通网盘解析(慢速) https://www.ctfile.com
2. 优化解析接口的实现
2024-10-09 15:33:33 +08:00
qaiu 2b6138a889 前端打包说明 2024-10-08 03:01:29 +08:00
qaiu 5e424f7bf4 前端打包说明 2024-10-08 02:13:50 +08:00
qaiu 294e47deed 1. 启用内嵌静态页面, 2. 蓝奏域名规则优化, 3. 反向代理优化, 4. 修复一堆细节问题 2024-10-08 02:06:37 +08:00
qaiu dc42547b73 更新 README.md 2024-10-07 19:32:22 +08:00
qaiu 7ef7f0706b 更新 PanDomainTemplate.java
蓝奏匹配正则优化
2024-10-06 15:19:51 +08:00
qaiu a59b98a7c9 更新 PanDomainTemplate.java 2024-10-06 15:04:13 +08:00
qaiu 088fee9a4d 1. add:123云盘的新域名
2. update: 统计API支持ce盘
3. 缓存时长
2024-10-01 17:38:57 +08:00
QAIU d8666acfe8 国庆快乐 ^ ^ #59 2024-09-30 17:42:38 +08:00
QAIU 209e9c2866 国庆快乐 ^ ^ #59 2024-09-30 17:38:36 +08:00
qaiu 6c3195dea4 更新 README.md 2024-09-29 22:11:04 +08:00
qaiu 7d774a7433 更新 README.md 2024-09-29 22:10:23 +08:00
qaiu f1ec4433cf 更新 README.md 2024-09-29 22:09:58 +08:00
QAIU 1f825db261 update 支持Cloudreve任意https的80端口的域名, 修复因json异常解析导致的解析时间超时的问题 2024-09-25 20:01:10 +08:00
QAIU 1019f24f1d update web-front README.md 2024-09-24 18:05:30 +08:00
QAIU f5c5b99579 1. 修改文叔叔链接匹配规则 2024-09-24 17:55:18 +08:00
QAIU e002d19f1b 1. 前端页面优化, 增强统计功能, 支持生成二维码
2. 添加统计接口
2024-09-24 17:08:29 +08:00
qaiu 0d5c9651f0 Update README.md 2024-09-24 10:07:25 +08:00
qaiu 53fc13b95c Merge pull request #56 from qaiu/dependabot/npm_and_yarn/web-front/micromatch-4.0.8
Bump micromatch from 4.0.5 to 4.0.8 in /web-front
2024-09-24 00:09:00 +08:00
QAIU 694c3b0ddc 修复移动云空间无法解析的前端问题, 更新前端依赖 2024-09-23 18:33:09 +08:00
dependabot[bot] 9b3d4577cc Bump micromatch from 4.0.5 to 4.0.8 in /web-front
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2024-09-23 08:46:39 +00:00
QAIU 77783915dd 修复移动云空间无法解析的前端问题, 更新前端依赖 2024-09-23 16:45:30 +08:00
qaiu b67ac21a79 Update README.md add 宝塔安装教程 2024-09-22 17:27:54 +08:00
qaiu 603afed2f2 . 2024-09-19 05:34:34 +00:00
qaiu c2a7c34496 . 2024-09-19 05:31:24 +00:00
qaiu edd40f48ba 依赖更新 2024-09-19 05:26:31 +00:00
qaiu cca3d6b8b9 web-front add yarn.lock 2024-09-19 05:22:55 +00:00
qaiu f004512903 Update devcontainer.json 2024-09-19 09:37:01 +08:00
qaiu 6407bb6730 Merge pull request #47 from qaiu/dependabot/npm_and_yarn/web-front/webpack-5.94.0
Bump webpack from 5.88.2 to 5.94.0 in /web-front
2024-09-19 09:01:53 +08:00
qaiu b914eeadec Merge pull request #51 from qaiu/dependabot/npm_and_yarn/web-front/multi-d66d039ac5
Bump serve-static and express in /web-front
2024-09-19 09:01:04 +08:00
qaiu dcadc6783e Merge pull request #52 from qaiu/dependabot/npm_and_yarn/web-front/express-4.21.0
Bump express from 4.19.2 to 4.21.0 in /web-front
2024-09-19 09:00:27 +08:00
dependabot[bot] bc9f43634f Bump express from 4.19.2 to 4.21.0 in /web-front
Bumps [express](https://github.com/expressjs/express) from 4.19.2 to 4.21.0.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.0/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.19.2...4.21.0)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2024-09-19 00:59:00 +00:00
dependabot[bot] 4778f0164c Bump serve-static and express in /web-front
Bumps [serve-static](https://github.com/expressjs/serve-static) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `serve-static` from 1.15.0 to 1.16.2
- [Release notes](https://github.com/expressjs/serve-static/releases)
- [Changelog](https://github.com/expressjs/serve-static/blob/v1.16.2/HISTORY.md)
- [Commits](https://github.com/expressjs/serve-static/compare/v1.15.0...v1.16.2)

Updates `express` from 4.19.2 to 4.21.0
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.0/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.19.2...4.21.0)

---
updated-dependencies:
- dependency-name: serve-static
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2024-09-19 00:58:59 +00:00
qaiu 9904754a07 Merge pull request #48 from qaiu/dependabot/npm_and_yarn/web-front/axios-1.7.4
Bump axios from 1.6.0 to 1.7.4 in /web-front
2024-09-19 08:57:43 +08:00
qaiu 1b79077c9e 更新 PanDomainTemplate.java 2024-09-19 06:20:12 +08:00
qaiu c13afb05b3 示例下载链接修改 2024-09-19 05:43:54 +08:00
qaiu 03e320efb8 Create devcontainer.json 2024-09-18 17:40:52 +08:00
qaiu 7846332476 Update README.md 2024-09-18 16:58:27 +08:00
qaiu 2d5d3b86e0 Update README.md 2024-09-18 16:58:02 +08:00
qaiu 7c9ba890af 1. .. 2024-09-18 15:45:49 +08:00
qaiu 0d609daffa 1. 123后缀处理 2. 修复缓存时间戳格式问题 2024-09-18 15:41:56 +08:00
qaiu c12e56d402 Update README.md 2024-09-18 13:34:53 +08:00
qaiu c7b38c07d5 Update README.md 2024-09-18 13:34:26 +08:00
qaiu dc51066cea 1. 缓存优化 2024-09-18 13:28:20 +08:00
qaiu 59d2fb3010 1. 缓存优化 2024-09-18 13:24:33 +08:00
qaiu a0fe702c10 1. remove error update 2024-09-15 06:54:55 +08:00
qaiu f886f7e366 1. 添加缓存
2. 优化解析架构
3. 优化核心模块
2024-09-15 06:53:11 +08:00
dependabot[bot] 1d475d88ed Bump axios from 1.6.0 to 1.7.4 in /web-front
Bumps [axios](https://github.com/axios/axios) from 1.6.0 to 1.7.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.0...v1.7.4)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2024-08-30 18:09:12 +00:00
dependabot[bot] e64c901912 Bump webpack from 5.88.2 to 5.94.0 in /web-front
Bumps [webpack](https://github.com/webpack/webpack) from 5.88.2 to 5.94.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.88.2...v5.94.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2024-08-30 18:07:43 +00:00
qaiu 5fce02e623 Update README.md 2024-07-13 12:50:02 +08:00
qaiu 13997bc543 Update README.md 2024-06-19 10:04:04 +08:00
qaiu 3e05b0d6f9 Update README.md 2024-06-19 10:03:53 +08:00
qaiu 966417f867 Merge pull request #45 from qaiu/dependabot/npm_and_yarn/web-front/multi-d7cccafd4e
Bump braces and filemanager-webpack-plugin in /web-front
2024-06-14 12:14:05 +08:00
dependabot[bot] 601a0d1b91 Bump braces and filemanager-webpack-plugin in /web-front
Bumps [braces](https://github.com/micromatch/braces) to 3.0.3 and updates ancestor dependency [filemanager-webpack-plugin](https://github.com/gregnb/filemanager-webpack-plugin). These dependencies need to be updated together.


Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

Updates `filemanager-webpack-plugin` from 2.0.5 to 8.0.0
- [Release notes](https://github.com/gregnb/filemanager-webpack-plugin/releases)
- [Changelog](https://github.com/gregnb/filemanager-webpack-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gregnb/filemanager-webpack-plugin/compare/v2.0.5...v8.0.0)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
- dependency-name: filemanager-webpack-plugin
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
2024-06-11 06:54:54 +00:00
176 changed files with 17054 additions and 3385 deletions
+17
View File
@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: "maven"
directory: "/"
open-pull-requests-limit: 10
ignore:
# 忽略通过 BOM 管理的 Vert.x 依赖
# 这些依赖的版本通过 vertx-dependencies BOM 统一管理
# 应该通过更新 pom.xml 中的 vertx.version 属性来更新这些依赖
- dependency-name: "io.vertx:vertx-web"
- dependency-name: "io.vertx:vertx-codegen"
- dependency-name: "io.vertx:vertx-config"
- dependency-name: "io.vertx:vertx-config-yaml"
- dependency-name: "io.vertx:vertx-service-proxy"
- dependency-name: "io.vertx:vertx-web-proxy"
- dependency-name: "io.vertx:vertx-web-client"
+210 -39
View File
@@ -1,14 +1,5 @@
# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven
name: Java CIMaven 构建 + Docker 镜像 + 原生环境打包)
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Java CI with Maven
# The API requires write permission on the repository to submit dependencies
permissions:
contents: write
packages: write
@@ -16,9 +7,9 @@ permissions:
on:
push:
tags:
- '*' # 只有推送tag时才会触发构建
- '*'
branches-ignore:
- '*' # 排除所有分支的提交
- '*'
paths-ignore:
- 'bin/**'
- '.github/**'
@@ -32,70 +23,250 @@ on:
- "main"
jobs:
# ================================================================
# 阶段一:构建前端 + Maven 打包(只执行一次,产物共享)
# ================================================================
build:
name: 编译构建
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 检出代码
uses: actions/checkout@v3
- uses: actions/setup-node@v4
- name: 设置 Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Set up JDK 17
- name: 设置 JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: maven
- name: Build Frontend
- name: 构建前端
run: cd web-front && yarn install && yarn run build
- name: Build with Maven
- name: Maven 编译打包
run: mvn -B package -DskipTests --file pom.xml
# Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive
- name: Update dependency graph
- name: 更新依赖图谱
uses: advanced-security/maven-dependency-submission-action@v3
if: github.event_name != 'pull_request'
continue-on-error: true
with:
ignore-maven-wrapper: true
# - uses: release-drafter/release-drafter@v5
# env:
# GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: Upload Artifact
- name: 分享应用打包目录(供原生包和 Docker 复用)
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: app-package
path: web-service/target/package/
- name: 分享 bin-zip(供 Docker 复用)
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: app-bin-zip
path: web-service/target/netdisk-fast-download-bin.zip
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
# ================================================================
# 阶段二-A:Docker 镜像构建(并行)
# ================================================================
docker:
name: Docker 镜像
needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v3
- name: 下载 bin-zip 产物
uses: actions/download-artifact@v4
with:
name: app-bin-zip
path: web-service/target/
- name: 登录 GitHub 容器仓库
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
- name: 设置 QEMU(多平台构建支持)
uses: docker/setup-qemu-action@v3
- name: 设置 Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract git tag
id: tag
run: |
GIT_TAG=$(git tag --points-at HEAD | head -n 1)
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
- name: Build and push Docker image
if: github.event_name != 'pull_request'
- name: 构建并推送 Docker 镜像
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/qaiu/netdisk-fast-download:${{ steps.tag.outputs.tag }}
ghcr.io/qaiu/netdisk-fast-download:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:latest
# ================================================================
# 阶段二-B:原生环境打包 Linux + Windows(并行)
# ================================================================
native-package:
name: 原生环境打包 → ${{ matrix.artifact-name }}
needs: build
if: github.event_name != 'pull_request'
strategy:
matrix:
include:
- os: ubuntu-latest
artifact-name: netdisk-fast-download-linux-amd64
- os: windows-latest
artifact-name: netdisk-fast-download-windows-amd64
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: 设置 JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: 下载 Maven 构建产物
uses: actions/download-artifact@v4
with:
name: app-package
path: web-service/target/package
# ============================================================
# jdeps 分析 → 确定所需 JDK 模块
# ============================================================
- name: 分析所需 JDK 模块(jdeps
run: |
MAIN_JAR="web-service/target/package/netdisk-fast-download.jar"
LIB_DIR="web-service/target/package/lib"
CP=""
for jar in "$LIB_DIR"/*.jar; do
CP="$CP${CP:+:}$jar"
done
RAW_MODULES=$(jdeps --print-module-deps --ignore-missing-deps --multi-release 17 \
--class-path "$CP" "$MAIN_JAR" 2>/dev/null | head -n 1 | tr -d '\r\n' || true)
if [ -z "$RAW_MODULES" ] || [[ "$RAW_MODULES" == *"Missing"* ]] || [[ "$RAW_MODULES" == *"Error"* ]]; then
# ⚠️ 回退列表:若项目新增了需要 java.* / jdk.* 模块的依赖,需同步更新此处
RAW_MODULES="java.base,java.logging,java.sql,java.naming,java.management,java.xml,jdk.unsupported,java.net.http,java.instrument,java.security.jgss,java.security.sasl,java.desktop,jdk.crypto.ec"
echo "jdeps 分析失败,使用回退模块列表"
else
# 补上 jdeps 无法检测的反射/SPI依赖
RAW_MODULES="$RAW_MODULES,java.desktop,jdk.crypto.ec"
fi
echo "detected modules: $RAW_MODULES"
printf 'JDK_MODULES=%s\n' "$RAW_MODULES" >> $GITHUB_ENV
# ============================================================
# jlink 生成精简 JRE
# ============================================================
- name: 生成精简 JREjlink
run: |
jlink \
--module-path "$JAVA_HOME/jmods" \
--add-modules "$JDK_MODULES" \
--output "native-package/netdisk-fast-download/jre" \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages
echo "JRE size:"
du -sh native-package/netdisk-fast-download/jre || true
# Windows: 确保 MSVC 运行时 DLL 到位
if [[ "$RUNNER_OS" == "Windows" ]]; then
JRE_BIN="native-package/netdisk-fast-download/jre/bin"
for dll in vcruntime140.dll msvcp140.dll vcruntime140_1.dll; do
if [ ! -f "$JRE_BIN/$dll" ] && [ -f "$JAVA_HOME/bin/$dll" ]; then
echo "jlink 未包含 $dll,从 JDK 补拷"
cp "$JAVA_HOME/bin/$dll" "$JRE_BIN/"
fi
done
echo "=== JRE bin 目录 DLL 清单 ==="
ls -la "$JRE_BIN"/*.dll 2>/dev/null || echo "(无 .dll 文件)"
fi
# ============================================================
# 组装包目录
# ============================================================
- name: 组装包目录
run: |
PKG="native-package/netdisk-fast-download"
SRC="web-service/target/package"
cp "$SRC/netdisk-fast-download.jar" "$PKG/"
cp -r "$SRC/lib" "$PKG/"
cp -r "$SRC/resources" "$PKG/"
cp -r "$SRC/webroot" "$PKG/"
mkdir -p "$PKG/db"
mkdir -p "$PKG/logs"
# ============================================================
# 生成启动脚本
# ============================================================
- name: 生成启动脚本(Linux
run: |
PKG="native-package/netdisk-fast-download"
echo '#!/bin/bash' > "$PKG/run.sh"
echo 'DIR="$(cd "$(dirname "$0")" && pwd)"' >> "$PKG/run.sh"
echo 'cd "$DIR" || exit 1' >> "$PKG/run.sh"
echo 'exec "$DIR/jre/bin/java" -Xmx512M -Dfile.encoding=utf-8 -jar "$DIR/netdisk-fast-download.jar" "$@"' >> "$PKG/run.sh"
chmod +x "$PKG/run.sh"
- name: 生成启动脚本(Windows
run: |
PKG="native-package/netdisk-fast-download"
echo '@echo off' > "$PKG/run.bat"
echo 'chcp 65001 > nul' >> "$PKG/run.bat"
echo 'pushd %~dp0' >> "$PKG/run.bat"
echo '"%~dp0jre\bin\java.exe" -Xmx512M -Dfile.encoding=utf-8 -jar "%~dp0netdisk-fast-download.jar" %*' >> "$PKG/run.bat"
# ============================================================
# 打包为 zip
# ============================================================
- name: 打包 ZIPLinux
if: runner.os == 'Linux'
run: |
cd native-package
zip -r "../${{ matrix.artifact-name }}.zip" netdisk-fast-download/
- name: 打包 ZIPWindows
if: runner.os == 'Windows'
shell: pwsh
run: |
Compress-Archive -Path native-package/netdisk-fast-download -DestinationPath "${{ matrix.artifact-name }}.zip"
# ============================================================
# 上传产物
# ============================================================
- name: 上传原生安装包
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-name }}.zip
- name: 上传到 Release
uses: softprops/action-gh-release@v2
with:
files: ${{ matrix.artifact-name }}.zip
tag_name: ${{ github.ref_name }}
generate_release_notes: true
+15
View File
@@ -29,6 +29,9 @@ target/
/src/logs/
*.zip
sdkTest.log
app.yml
app-local.yml
secret.yml
#some local files
@@ -39,7 +42,9 @@ gradlew.bat
unused.txt
/web-service/src/main/generated/
/db
/netdisk-fast-download/
/webroot/nfd-front/
/netdisk-fast-download/webroot/nfd-front/
package-lock.json
# Maven generated files
@@ -78,3 +83,13 @@ yarn-error.log*
*.iml
*.ipr
*.iws
# Build directories
**/target/
**/build/
**/classes/
**/out/
**/${project.build.directory}/
**/${project.basedir}/target/
**/${basedir}/target/
.spec-workflow/
+1 -53
View File
@@ -1,7 +1,4 @@
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
@@ -10,61 +7,12 @@
"request": "launch",
"mainClass": "${file}"
},
{
"type": "java",
"name": "StringCase",
"request": "launch",
"mainClass": "cn.qaiu.vx.core.util.StringCase",
"projectName": "core"
},
{
"type": "java",
"name": "FCURLParser",
"request": "launch",
"mainClass": "cn.qaiu.parser.FCURLParser",
"projectName": "parser"
},
{
"type": "java",
"name": "QkTool",
"request": "launch",
"mainClass": "cn.qaiu.parser.impl.QkTool",
"projectName": "parser"
},
{
"type": "java",
"name": "WebClientExample",
"request": "launch",
"mainClass": "qaiu.web.test.WebClientExample",
"projectName": "parser"
},
{
"type": "java",
"name": "AppMain",
"request": "launch",
"mainClass": "cn.qaiu.lz.AppMain",
"projectName": "web-service"
},
{
"type": "java",
"name": "TestJs",
"request": "launch",
"mainClass": "cn.qaiu.web.test.TestJs",
"projectName": "web-service"
},
{
"type": "java",
"name": "TestOS",
"request": "launch",
"mainClass": "cn.qaiu.web.test.TestOS",
"projectName": "web-service"
},
{
"type": "java",
"name": "WebProxyExamples",
"request": "launch",
"mainClass": "cn.qaiu.web.test.WebProxyExamples",
"projectName": "web-service"
}
]
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive"
"java.configuration.updateBuildConfiguration": "interactive",
"java.debug.settings.onBuildFailureProceed": true
}
+8 -3
View File
@@ -10,8 +10,13 @@ COPY ./web-service/target/netdisk-fast-download-bin.zip .
RUN unzip netdisk-fast-download-bin.zip && \
mv netdisk-fast-download/* ./ && \
rm netdisk-fast-download-bin.zip && \
chmod +x run.sh
chmod +x run.sh && \
mkdir -p db logs
EXPOSE 6400 6401
COPY ./docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["sh", "run.sh"]
EXPOSE 6401
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
ENTRYPOINT ["/docker-entrypoint.sh"]
+92 -64
View File
@@ -1,38 +1,51 @@
# 一款网盘分享链接云解析快速下载服务
QQ交流群:1017480890
<p align="center">
<img src="https://github.com/user-attachments/assets/87401aae-b0b6-4ffb-bbeb-44756404d26f" alt="项目预览图" />
</p>
<p align="center">
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/maven.yml?branch=v0.1.9b8a&style=flat"></a>
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/build.yml?branch=main&style=flat"></a>
<a href="https://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></a>
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.22-blue?style=flat"></a>
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.27-blue?style=flat"></a>
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
<a href="https://atomgit.com/QAIU/netdisk-fast-download"><img src="https://atomgit.com/QAIU/netdisk-fast-download/star/badge.svg" alt="AtomGit"></a>
<a href="https://oosmetrics.com/repo/qaiu/netdisk-fast-download"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/826aa27a-6e59-4de5-b7fa-cd189f484035.svg"></a>
<p align="center">
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
![alt text](web-front/img/image.png)
# netdisk-fast-download 网盘分享链接云解析服务
QQ群:1017480890
## 国内镜像
netdisk-fast-download网盘直链云解析(nfd云解析)能把网盘分享下载链接转化为直链,支持多款云盘,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
本项目同步托管于 **AtomGit**,国内访问更流畅:👉 [https://atomgit.com/QAIU/netdisk-fast-download](https://atomgit.com/QAIU/netdisk-fast-download)
## 介绍
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
[官方文档](https://nfd-parser.github.io/)
[API接入](https://nfdparser.apifox.cn/)
[公益解析,lz站](https://lz.qaiu.top)
[公益解析,lz0站](https://lz0.qaiu.top)
[专业版](https://189.qaiu.top)
## 快速开始
命令行下载分享文件:
```shell
curl -LOJ "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/nQOaNRPW&pwd=1234"
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/nQOaNRPW&pwd=1234"
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%2Fshare.feijipan.com%2Fs%2FnQOaNRPW&name=bilibili.mp4&ext=mp4):
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FTk1F2kGQ&name=bilibili.mp4&ext=mp4):
```
### 调用演示站下载:
https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/nQOaNRPW&pwd=1234
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%2Fshare.feijipan.com%2Fs%2FnQOaNRPW&name=bilibili.mp4&ext=mp4
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FTk1F2kGQ&name=bilibili.mp4&ext=mp4
```
@@ -40,20 +53,14 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
**JavaScript解析器文档:** [JavaScript解析器开发指南](parser/doc/JAVASCRIPT_PARSER_GUIDE.md) | [自定义解析器扩展指南](parser/doc/CUSTOM_PARSER_GUIDE.md) | [快速开始](parser/doc/CUSTOM_PARSER_QUICKSTART.md)
**Playground功能:** [JS解析器演练场密码保护说明](PLAYGROUND_PASSWORD_PROTECTION.md)
**Playground功能:** [JS解析器演练场密码保护说明](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)
## 预览地址
[预览地址1](https://lz.qaiu.top)
[预览地址2](https://lz0.qaiu.top)
[移动/联通/天翼云盘大文件试用版](https://189.qaiu.top)
main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/netdisk-fast-download/tree/main-jdk11)
**0.1.8及以上版本json接口格式有调整 参考json返回数据格式示例**
**小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
**注意: 请不要过度依赖lz.qaiu.top预览地址服务,建议本地搭建或者云服务器自行搭建。解析次数过多IP会被部分网盘厂商限制,不推荐做公共解析。**
**注意⚠️小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
**注意⚠️请不要过度依赖 lz.qaiu.top,建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制,遇到解析失败的分享链接不要着急提issues,请先检查分享是否有效。**
## 网盘支持情况:
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
> 20230824 123云盘解析大文件(>100MB)失效,需要登录
> 20230722 UC网盘解析失效,需要登录
@@ -61,13 +68,11 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/
- [蓝奏云-lz](https://pc.woozooo.com/)
- [蓝奏云优享-iz](https://www.ilanzou.com/)
- [奶牛快传-cow](https://cowtransfer.com/)
- [移动云云空间-ec](https://www.ecpan.cn/web)
- [小飞机网盘-fj](https://www.feijipan.com/)
- [亿方云-fc](https://www.fangcloud.com/)
- [123云盘-ye](https://www.123pan.com/)
- ~[115网盘(失效)-p115](https://115.com/)~
- ~[118网盘(已停服)-p118](https://www.118pan.com/)~
- [文叔叔-ws](https://www.wenshushu.cn/)
- [联想乐云-le](https://lecloud.lenovo.com/)
- [QQ邮箱云盘-qqw](https://mail.qq.com/)
@@ -80,20 +85,25 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/
- [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
- [飞书云盘-fs](https://www.feishu.cn/)
- [WPS云文档-pwps](https://www.kdocs.cn/)
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
- [咪咕音乐-migu](https://music.migu.cn/)
- [一刻相册-baidu_photo](https://photo.baidu.com/)
- Google云盘-pgd
- Onedrive-pod
- Dropbox-pdp
- iCloud-pic
### 仅专属版提供
### 专业版提供
- 迅雷网盘-xl
- [夸克云盘-qk](https://pan.quark.cn/)
- [UC云盘-uc](https://fast.uc.cn/)
- [移动云盘-p139](https://yun.139.com/)
- [联通云盘-pwo](https://pan.wo.cn/)
- [天翼云盘-p189](https://cloud.189.cn/)
## API接口
[api接口文档](https://nfdparser.apifox.cn/)
### 服务端口
- **6400**: API 服务端口(建议使用 Nginx 代理)
@@ -138,6 +148,56 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
- `{网盘标识}` 参考支持的网盘列表
- `your_host` 替换为您的域名或 IP
### 认证参数(v0.2.1+
[可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
部分网盘(如夸克、UC)需要登录后的 Cookie 才能解析和下载。可通过 `auth` 参数传递认证信息:
**参数格式**`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码
**加密方式**
- 算法:AES/ECB/PKCS5Padding
- 密钥:`nfd_auth_key2026`16字节)
- 流程:JSON → AES加密 → Base64 → URL编码
**JSON 结构**
```json
{
"authType": "cookie", // 认证类型: cookie/accesstoken/authorization/password/custom
"token": "your_cookie_here", // Cookie 或 Token 内容
"username": "", // 用户名(password 类型时使用)
"password": "", // 密码(password 类型时使用)
"ext1": "", // 扩展字段1(custom 类型时使用)
"ext2": "" // 扩展字段2(custom 类型时使用)
}
```
**网盘认证要求**
| 网盘 | 认证要求 | 说明 |
|------|---------|------|
| 夸克网盘(QK) | **必须** | 必须配置 Cookie 才能解析 |
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
**使用示例**
```
GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
```
> 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。
> [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
#### 密钥作用说明
- `server.authEncryptKey`
- 作用:用于 `auth` 参数的 AES 加解密
- 要求:16位(AES-128
- `server.donatedAccountFailureTokenSignKey`
- 作用:用于“捐赠账号失败计数 token”的 HMAC 签名/验签
- 目的:防止客户端伪造失败计数请求
- 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同
### 特殊说明
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
@@ -271,15 +331,15 @@ json返回数据格式示例:
| 网盘名称 | 免登陆下载分享 | 加密分享 | 初始网盘空间 | 单文件大小限制 |
|-------------|---------|----------|-----------|-----------------|
| 蓝奏云 | √ | √ | 不限空间 | 100M |
| 奶牛快传 | √ | X | 10G | 不限大小 |
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
| 小飞机网盘 | √ | √(密码可忽略) | 10G | 不限大小 |
| 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 |
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
| 123云盘 | √ | √ | 2T | 100G>100M需要登录) |
| 文叔叔 | √ | √ | 10G | 5GB |
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 |
| UC网盘 | x | √ | 10G | 不限大小 |
| 飞书云盘 | √ | X | 15G | 不限大小 |
# 打包部署
@@ -359,7 +419,7 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
```shell
cd ~
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.1.9b7/netdisk-fast-download-bin.zip
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v3.0.2/netdisk-fast-download-bin.zip
unzip netdisk-fast-download-bin.zip
cd netdisk-fast-download
bash service-install.sh
@@ -432,23 +492,6 @@ auths:
**注意:** 目前仅支持 123(ye)的认证配置。
## 开发计划
### v0.1.8~v0.1.9 ✓
- API添加文件信息(专属版/开源版)
- 目录解析(专属版/开源版)
- 文件预览功能(专属版/开源版)
- 文件夹预览功能(开源版)
- 友好的错误提示和一键反馈功能(开源版)
- 带cookie/token/username/pwd参数解析大文件(专属版)
### v0.2.x
- web后台管理--认证配置/分享链接管理(开源版/专属版)
- 123/小飞机/蓝奏优享等大文件解析(开源版)
- 直链分享(开源版/专属版)
- aria2/idm+/curl/wget链接生成(开源版/专属版)
- IP限流配置(开源版/专属版)
- refere防盗链,API鉴权防盗链(专属版)
- 123/小飞机/蓝奏优享/蓝奏文件夹解析API,天翼云盘/移动云盘文件夹解析API(专属版)
- 用户管理面板--营销推广系统(专属版)
**技术栈:**
Jdk17+Vert.x4
@@ -473,20 +516,5 @@ Core模块集成Vert.x实现类似spring的注解式路由API
</p>
### 关于赞助定制专属版
1. 专属版提供对小飞机,蓝奏优享大文件解析的支持, 提供天翼云盘/移动云盘/联通云盘的解析支持。
2. 可提供托管服务:包含部署服务和云服务器环境。
3. 可提供功能定制开发。
您可能需要提供一定的资金赞助支持定制专属版, 请添加以下任意一个联系方式详谈赞助模式:
<p>qq: 197575894</p>
<p>wechat: imcoding_</p>
<!--
![image](https://github.com/qaiu/netdisk-fast-download/assets/29825328/54276aee-cc3f-4ebd-8973-2e15f6295819)
[手机端支付宝打赏跳转链接](https://qr.alipay.com/fkx01882dnoxxtjenhlxt53)
-->
+1 -2
View File
@@ -1,6 +1,5 @@
#!/bin/bash
# set -x
LAUNCH_JAR="netdisk-fast-download.jar"
nohup java -Xmx512M -jar "$LAUNCH_JAR" "$@" >startup.log 2>&1 &
tail -f startup.log
exec java -Xmx${JVM_XMX:-512M} ${JVM_OPTS} -jar "$LAUNCH_JAR" "$@"
+1 -1
View File
@@ -65,7 +65,7 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
<version>42.7.11</version>
</dependency>
</dependencies>
@@ -53,7 +53,7 @@ public class CreateDatabase {
stmt.executeUpdate("CREATE DATABASE IF NOT EXISTS " + dbName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
LOGGER.info(">>>>>>>>>>> 数据库'{}'创建成功 <<<<<<<<<<<<", dbName);
} catch (SQLException e) {
e.printStackTrace();
LOGGER.error("创建数据库失败", e);
}
}
@@ -24,35 +24,39 @@ import java.util.*;
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class CreateTable {
public static Map<Class<?>, String> javaProperty2SqlColumnMap = new HashMap<>() {{
public static final Map<Class<?>, String> javaProperty2SqlColumnMap;
static {
Map<Class<?>, String> map = new HashMap<>();
// Java类型到SQL类型的映射
put(Integer.class, "INT");
put(Short.class, "SMALLINT");
put(Byte.class, "TINYINT");
put(Long.class, "BIGINT");
put(java.math.BigDecimal.class, "DECIMAL");
put(Double.class, "DOUBLE");
put(Float.class, "REAL");
put(Boolean.class, "BOOLEAN");
put(String.class, "VARCHAR");
put(Date.class, "TIMESTAMP");
put(java.time.LocalDateTime.class, "TIMESTAMP");
put(java.sql.Timestamp.class, "TIMESTAMP");
put(java.sql.Date.class, "DATE");
put(java.sql.Time.class, "TIME");
map.put(Integer.class, "INT");
map.put(Short.class, "SMALLINT");
map.put(Byte.class, "TINYINT");
map.put(Long.class, "BIGINT");
map.put(java.math.BigDecimal.class, "DECIMAL");
map.put(Double.class, "DOUBLE");
map.put(Float.class, "REAL");
map.put(Boolean.class, "BOOLEAN");
map.put(String.class, "VARCHAR");
map.put(Date.class, "TIMESTAMP");
map.put(java.time.LocalDateTime.class, "TIMESTAMP");
map.put(java.sql.Timestamp.class, "TIMESTAMP");
map.put(java.sql.Date.class, "DATE");
map.put(java.sql.Time.class, "TIME");
// 基本数据类型
put(int.class, "INT");
put(short.class, "SMALLINT");
put(byte.class, "TINYINT");
put(long.class, "BIGINT");
put(double.class, "DOUBLE");
put(float.class, "REAL");
put(boolean.class, "BOOLEAN");
}};
map.put(int.class, "INT");
map.put(short.class, "SMALLINT");
map.put(byte.class, "TINYINT");
map.put(long.class, "BIGINT");
map.put(double.class, "DOUBLE");
map.put(float.class, "REAL");
map.put(boolean.class, "BOOLEAN");
javaProperty2SqlColumnMap = Collections.unmodifiableMap(map);
}
private static final Logger LOGGER = LoggerFactory.getLogger(CreateTable.class);
public static String UNIQUE_PREFIX = "idx_";
public static final String UNIQUE_PREFIX = "idx_";
private static Case getCase(Class<?> clz) {
return switch (clz.getName()) {
@@ -17,7 +17,7 @@ import org.slf4j.LoggerFactory;
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class JDBCPoolInit {
public class JDBCPoolInit implements AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(JDBCPoolInit.class);
@@ -101,4 +101,16 @@ public class JDBCPoolInit {
synchronized public JDBCPool getPool() {
return pool;
}
/**
* 关闭连接池,释放数据库资源
*/
@Override
public synchronized void close() {
if (pool != null) {
pool.close();
LOGGER.info("数据库连接池已关闭: URL={}", url);
pool = null;
}
}
}
+6
View File
@@ -73,6 +73,12 @@
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
@@ -1,73 +0,0 @@
package cn.qaiu.vx.core.verticle.conf;
import io.vertx.core.json.JsonObject;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.impl.JsonUtil;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
/**
* Converter and mapper for {@link cn.qaiu.vx.core.verticle.conf.HttpProxyConf}.
* NOTE: This class has been automatically generated from the {@link cn.qaiu.vx.core.verticle.conf.HttpProxyConf} original class using Vert.x codegen.
*/
public class HttpProxyConfConverter {
private static final Base64.Decoder BASE64_DECODER = JsonUtil.BASE64_DECODER;
private static final Base64.Encoder BASE64_ENCODER = JsonUtil.BASE64_ENCODER;
static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json, HttpProxyConf obj) {
for (java.util.Map.Entry<String, Object> member : json) {
switch (member.getKey()) {
case "password":
if (member.getValue() instanceof String) {
obj.setPassword((String)member.getValue());
}
break;
case "port":
if (member.getValue() instanceof Number) {
obj.setPort(((Number)member.getValue()).intValue());
}
break;
case "preProxyOptions":
if (member.getValue() instanceof JsonObject) {
obj.setPreProxyOptions(new io.vertx.core.net.ProxyOptions((io.vertx.core.json.JsonObject)member.getValue()));
}
break;
case "timeout":
if (member.getValue() instanceof Number) {
obj.setTimeout(((Number)member.getValue()).intValue());
}
break;
case "username":
if (member.getValue() instanceof String) {
obj.setUsername((String)member.getValue());
}
break;
}
}
}
static void toJson(HttpProxyConf obj, JsonObject json) {
toJson(obj, json.getMap());
}
static void toJson(HttpProxyConf obj, java.util.Map<String, Object> json) {
if (obj.getPassword() != null) {
json.put("password", obj.getPassword());
}
if (obj.getPort() != null) {
json.put("port", obj.getPort());
}
if (obj.getPreProxyOptions() != null) {
json.put("preProxyOptions", obj.getPreProxyOptions().toJson());
}
if (obj.getTimeout() != null) {
json.put("timeout", obj.getTimeout());
}
if (obj.getUsername() != null) {
json.put("username", obj.getUsername());
}
}
}
+101 -13
View File
@@ -3,20 +3,24 @@ package cn.qaiu.vx.core;
import cn.qaiu.vx.core.util.CommonUtil;
import cn.qaiu.vx.core.util.ConfigUtil;
import cn.qaiu.vx.core.util.VertxHolder;
import cn.qaiu.vx.core.verticle.HttpProxyVerticle;
import cn.qaiu.vx.core.verticle.PostExecVerticle;
import cn.qaiu.vx.core.verticle.ReverseProxyVerticle;
import cn.qaiu.vx.core.verticle.RouterVerticle;
import cn.qaiu.vx.core.verticle.ServiceVerticle;
import io.vertx.core.*;
import io.vertx.core.dns.AddressResolverOptions;
import io.vertx.core.impl.launcher.commands.VersionCommand;
import io.vertx.core.json.JsonObject;
import io.vertx.core.shareddata.LocalMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID;
import java.util.concurrent.locks.LockSupport;
import static cn.qaiu.vx.core.util.ConfigConstant.*;
@@ -54,15 +58,25 @@ public final class Deploy {
public void start(String[] args, Handler<JsonObject> handle) {
this.mainThread = Thread.currentThread();
this.handle = handle;
if (args.length > 0 && args[0].startsWith("app-")) {
// 启动参数dev或者prod
path.append("-").append(args[0].replace("app-",""));
}
// 读取yml配置
// 读取yml配置,优先当前目录,其次 resources/ 子目录
String configFile = path + ".yml";
if (!Files.exists(Path.of(configFile)) && Files.exists(Path.of("resources", configFile))) {
path.insert(0, "resources/");
LOGGER.info("从 resources/ 目录加载配置: {}", path + ".yml");
}
ConfigUtil.readYamlConfig(path.toString(), tempVertx)
.onSuccess(this::readConf)
.onFailure(Throwable::printStackTrace);
.onFailure(err -> {
LOGGER.error("读取配置文件失败: {}", err.getMessage(), err);
LockSupport.unpark(mainThread);
System.exit(-1);
});
LockSupport.park();
deployVerticle();
}
@@ -104,7 +118,7 @@ public final class Deploy {
System.out.printf(logoTemplate,
CommonUtil.getAppVersion(),
VersionCommand.getVersion(),
"4x",
conf.getString("copyright"),
year
);
@@ -123,17 +137,28 @@ public final class Deploy {
var vertxOptions = vertxConfigELPS == 0 ?
new VertxOptions() : new VertxOptions(vertxConfig);
vertxOptions.setAddressResolverOptions(
new AddressResolverOptions().
addServer("114.114.114.114").
addServer("114.114.115.115").
addServer("8.8.8.8").
addServer("8.8.4.4"));
// vertxOptions.setAddressResolverOptions(
// new AddressResolverOptions().
// addServer("114.114.114.114").
// addServer("114.114.115.115").
// addServer("8.8.8.8").
// addServer("8.8.4.4"));
LOGGER.info("vertxConfigEventLoopPoolSize: {}, eventLoopPoolSize: {}, workerPoolSize: {}", vertxConfigELPS,
vertxOptions.getEventLoopPoolSize(),
vertxOptions.getWorkerPoolSize());
var vertx = Vertx.vertx(vertxOptions);
VertxHolder.init(vertx);
// 注册 ShutdownHook,确保进程退出时优雅关闭资源
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOGGER.info("JVM shutting down, closing Vert.x...");
try {
vertx.close().toCompletionStage().toCompletableFuture().get(10, java.util.concurrent.TimeUnit.SECONDS);
LOGGER.info("Vert.x closed successfully");
} catch (Exception e) {
LOGGER.warn("Vert.x close error or timeout", e);
}
}));
//配置保存在共享数据中
var sharedData = vertx.sharedData();
LocalMap<String, Object> localMap = sharedData.getLocalMap(LOCAL);
@@ -153,12 +178,39 @@ public final class Deploy {
var future2 = vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"));
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy"));
Future.all(future1, future2, future3)
.onSuccess(this::deployWorkVerticalSuccess)
.onFailure(this::deployVerticalFailed);
JsonObject jsonObject = ((JsonObject) localMap.get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
if (jsonObject != null) {
genPwd(jsonObject);
var future4 = vertx.deployVerticle(HttpProxyVerticle.class, getWorkDeploymentOptions("proxy"));
future4.onSuccess(LOGGER::info);
future4.onFailure(e -> LOGGER.error("Other handle error", e));
Future.all(future1, future2, future3, future4)
.onSuccess(this::deployWorkVerticalSuccess)
.onFailure(this::deployVerticalFailed);
} else {
Future.all(future1, future2, future3)
.onSuccess(this::deployWorkVerticalSuccess)
.onFailure(this::deployVerticalFailed);
}
}).onFailure(e -> LOGGER.error("Other handle error", e));
}
private static void genPwd(JsonObject jsonObject) {
if (jsonObject.getBoolean("randUserPwd")) {
var username = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
var password = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
jsonObject.put("username", username);
jsonObject.put("password", password);
}
LOGGER.info("=============server info=================");
LOGGER.info("\nport: {}\nusername: {}\npassword: {}",
jsonObject.getString("port"),
jsonObject.getString("username"),
jsonObject.getString("password"));
LOGGER.info("==============server info================");
}
/**
* 部署失败
*
@@ -178,6 +230,42 @@ public final class Deploy {
var t1 = ((double) (System.currentTimeMillis() - startTime)) / 1000;
var t2 = ((double) System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
LOGGER.info("web服务启动成功 -> 用时: {}s, jvm启动用时: {}s", t1, t2);
// 检查是否处于安装引导模式(数据库未配置)
Object installMode = VertxHolder.getVertxInstance().sharedData()
.getLocalMap(LOCAL).get("installMode");
if (Boolean.TRUE.equals(installMode)) {
LOGGER.info("系统处于安装引导模式,等待用户完成数据库配置后再启动后置初始化...");
return;
}
// 正常模式:部署 PostExecVerticle 执行 AppRun 实现
deployPostExec();
}
/**
* 部署 PostExecVerticle(执行所有 AppRun 实现)
* 安装引导完成后也可手动调用此方法触发后置初始化
*/
public void deployPostExec() {
var vertx = VertxHolder.getVertxInstance();
var postExecFuture = vertx.deployVerticle(PostExecVerticle.class, getWorkDeploymentOptions("postExec", 2));
postExecFuture.onSuccess(id -> {
LOGGER.info("PostExecVerticle 部署成功,AppRun 实现执行完成");
}).onFailure(e -> {
LOGGER.error("PostExecVerticle 部署失败", e);
});
}
/**
* 重新部署 ServiceVerticle,重新注册因 DB 未就绪而失败的服务到 EventBus
* 安装引导完成、DB 初始化后调用
*/
public void redeployServices() {
var vertx = VertxHolder.getVertxInstance();
vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"))
.onSuccess(id -> LOGGER.info("ServiceVerticle 重新部署成功,DB 相关服务已注册"))
.onFailure(e -> LOGGER.error("ServiceVerticle 重新部署失败", e));
}
/**
@@ -9,6 +9,7 @@ import java.lang.annotation.*;
public @interface HandleSortFilter {
/**
* 注册顺序,数字越大越先注册<br>
* 前置拦截器会先执行后注册即数字小的, 后置拦截器会先执行先注册的即数字大的<br>
* 值<0时会过滤掉该处理器
*/
int value() default 0;
@@ -0,0 +1,12 @@
package cn.qaiu.vx.core.base;
import io.vertx.core.json.JsonObject;
public interface AppRun {
/**
* 执行方法
* @param config 启动配置文件
*/
void execute(JsonObject config);
}
@@ -38,6 +38,20 @@ public interface BaseHttpApi {
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
}
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
if (!ctx.response().ended()) {
fireJsonObjectResponse(ctx, jsonObject, statusCode);
}
handleAfterInterceptor(ctx, jsonObject);
}
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
if (!ctx.response().ended()) {
fireJsonResultResponse(ctx, jsonResult, statusCode);
}
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
}
default Set<AfterInterceptor> getAfterInterceptor() {
@@ -0,0 +1,23 @@
package cn.qaiu.vx.core.base;
import cn.qaiu.vx.core.annotaions.HandleSortFilter;
import io.vertx.core.json.JsonObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 默认的AppRun实现示例
* <br>Create date 2024-01-01 00:00:00
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@HandleSortFilter
public class DefaultAppRun implements AppRun {
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultAppRun.class);
@Override
public void execute(JsonObject config) {
LOGGER.info("======> AppRun实现类开始执行,配置数: {}", config.size());
}
}
@@ -23,8 +23,6 @@ import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.*;
import io.vertx.ext.web.handler.sockjs.SockJSHandler;
import io.vertx.ext.web.handler.sockjs.SockJSHandlerOptions;
import io.vertx.ext.web.sstore.LocalSessionStore;
import io.vertx.ext.web.sstore.SessionStore;
import javassist.CtClass;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
@@ -76,15 +74,15 @@ public class RouterHandlerFactory implements BaseHttpApi {
// 主路由
Router mainRouter = Router.router(VertxHolder.getVertxInstance());
mainRouter.route().handler(ctx -> {
String realPath = ctx.request().uri();;
String realPath = ctx.request().uri();
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.substring(REROUTE_PATH_PREFIX.length());
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
ctx.reroute(rePath);
return;
}
LOGGER.debug("The HTTP service request address information ===>path:{}, uri:{}, method:{}",
LOGGER.debug("New request:{}, {}, {}",
ctx.request().path(), ctx.request().absoluteURI(), ctx.request().method());
ctx.response().headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
ctx.response().headers().add(DATE, LocalDateTime.now().format(ISO_LOCAL_DATE_TIME));
@@ -100,16 +98,6 @@ public class RouterHandlerFactory implements BaseHttpApi {
// 配置文件上传路径
mainRouter.route().handler(BodyHandler.create().setUploadsDirectory("uploads"));
// 配置Session管理 - 用于演练场登录状态持久化
// 30天过期时间(毫秒)
SessionStore sessionStore = LocalSessionStore.create(VertxHolder.getVertxInstance());
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionTimeout(30L * 24 * 60 * 60 * 1000) // 30天
.setSessionCookieName("SESSIONID") // Cookie名称
.setCookieHttpOnlyFlag(true) // 防止XSS攻击
.setCookieSecureFlag(false); // 非HTTPS环境设置为false
mainRouter.route().handler(sessionHandler);
// 拦截器
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
Route route0 = mainRouter.route("/*");
@@ -139,8 +127,9 @@ public class RouterHandlerFactory implements BaseHttpApi {
// 错误请求处理
mainRouter.errorHandler(405, ctx -> doFireJsonResultResponse(ctx, JsonResult
.error("Method Not Allowed", 405)));
mainRouter.errorHandler(404, ctx -> ctx.response().setStatusCode(404).setChunked(true)
.end("Internal server error: 404 not found"));
mainRouter.errorHandler(404, ctx -> {
ctx.response().setStatusCode(404).end("404 not found");
});
return mainRouter;
}
@@ -189,10 +178,11 @@ public class RouterHandlerFactory implements BaseHttpApi {
if (ctx.response().ended()) return;
// 超时处理器状态码503
if (ctx.statusCode() == 503 || ctx.failure() == null) {
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员", 500));
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员"), 503);
} else {
ctx.failure().printStackTrace();
doFireJsonResultResponse(ctx, JsonResult.error(ctx.failure().getMessage(), 500));
LOGGER.error("路由处理失败", ctx.failure());
String msg = ctx.failure() != null ? ctx.failure().getMessage() : "未知异常";
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
}
});
} else if (method.isAnnotationPresent(SockRouteMapper.class)) {
@@ -210,7 +200,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
try {
ReflectionUtil.invokeWithArguments(method, instance, sock);
} catch (Throwable e) {
e.printStackTrace();
LOGGER.error("WebSocket处理异常", e);
}
});
if (url.endsWith("*")) {
@@ -246,7 +236,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
*/
private Set<Handler<RoutingContext>> getInterceptorSet() {
// 配置拦截
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toSet());
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
@@ -315,32 +305,41 @@ public class RouterHandlerFactory implements BaseHttpApi {
final MultiMap queryParams = ctx.queryParams();
// 解析body-json参数
// 只处理POST/PUT/PATCH等有body的请求方法,避免GET请求读取body导致"Request has already been read"错误
String httpMethod = ctx.request().method().name();
if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
&& HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())
&& ctx.body() != null && ctx.body().asJsonObject() != null) {
if (HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())) {
JsonObject body = ctx.body().asJsonObject();
if (body != null) {
methodParametersTemp.forEach((k, v) -> {
String typeName = v.getRight().getName();
// 直接绑定 JsonObject 类型参数
if (JsonObject.class.getName().equals(typeName)) {
parameterValueList.put(k, body);
}
// 只解析已配置包名前缀的实体类
if (CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) {
else if (CommonUtil.matchRegList(entityPackagesReg.getList(), typeName)) {
try {
Class<?> aClass = Class.forName(v.getRight().getName());
Class<?> aClass = Class.forName(typeName);
JsonObject data = CommonUtil.getSubJsonForEntity(body, aClass);
if (!data.isEmpty()) {
Object entity = data.mapTo(aClass);
parameterValueList.put(k, entity);
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
LOGGER.error("实体类绑定异常: {}", typeName, e);
}
}
});
} else {
// body 可能是 JsonArray
JsonArray bodyArray = ctx.body().asJsonArray();
if (bodyArray != null) {
methodParametersTemp.forEach((k, v) -> {
if (JsonArray.class.getName().equals(v.getRight().getName())) {
parameterValueList.put(k, bodyArray);
}
});
}
}
} else if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
&& ctx.body() != null) {
} else if (ctx.body() != null) {
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
}
@@ -368,7 +367,21 @@ public class RouterHandlerFactory implements BaseHttpApi {
Object entity = ParamUtil.multiMapToEntity(queryParams, aClass);
parameterValueList.put(k, entity);
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("参数绑定异常: {}", v.getRight().getName(), e);
}
} else if (parameterValueList.get(k) == null
&& JsonObject.class.getName().equals(v.getRight().getName())) {
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonObject
if (ctx.body() != null) {
JsonObject jo = ctx.body().asJsonObject();
if (jo != null) parameterValueList.put(k, jo);
}
} else if (parameterValueList.get(k) == null
&& JsonArray.class.getName().equals(v.getRight().getName())) {
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonArray
if (ctx.body() != null) {
JsonArray ja = ctx.body().asJsonArray();
if (ja != null) parameterValueList.put(k, ja);
}
}
});
@@ -379,40 +392,37 @@ public class RouterHandlerFactory implements BaseHttpApi {
Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray);
if (data != null) {
if (data instanceof JsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) data);
if (data instanceof JsonResult jsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) data, jsonResult.getCode());
}
if (data instanceof JsonObject) {
doFireJsonObjectResponse(ctx, ((JsonObject) data));
} else if (data instanceof Future) { // 处理异步响应
((Future<?>) data).onSuccess(res -> {
if (res instanceof JsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) res);
if (res instanceof JsonResult jsonResult) {
doFireJsonResultResponse(ctx, jsonResult, jsonResult.getCode());
}
if (res instanceof JsonObject) {
doFireJsonObjectResponse(ctx, ((JsonObject) res));
} else if (res != null) {
doFireJsonResultResponse(ctx, JsonResult.data(res));
} else {
handleAfterInterceptor(ctx, null);
doFireJsonResultResponse(ctx, JsonResult.data(null));
}
}).onFailure(e -> doFireJsonResultResponse(ctx, JsonResult.error(e.getMessage())));
}).onFailure(e -> {
LOGGER.error("请求处理失败", e);
String msg = e.getMessage() != null ? e.getMessage() : "服务器内部错误";
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
});
} else {
doFireJsonResultResponse(ctx, JsonResult.data(data));
}
}
} catch (Throwable e) {
e.printStackTrace();
String err = e.getMessage();
if (e.getCause() != null) {
if (e.getCause() instanceof InvocationTargetException) {
err = ((InvocationTargetException) e.getCause()).getTargetException().getMessage();
} else {
err = e.getCause().getMessage();
}
}
doFireJsonResultResponse(ctx, JsonResult.error(err));
LOGGER.error("请求处理异常", e);
String msg = e.getMessage() != null ? e.getMessage() : "服务器内部错误";
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
}
}
@@ -3,10 +3,12 @@ package cn.qaiu.vx.core.interceptor;
import io.vertx.core.Handler;
import io.vertx.ext.web.RoutingContext;
import static cn.qaiu.vx.core.util.ResponseUtil.sendError;
/**
* 前置拦截器接口
* <p>
* 注意:Vert.x是异步非阻塞框架,不能在Event Loop中使用synchronized等阻塞操作!
* 所有操作都应该是非阻塞的,使用Vert.x的上下文数据存储机制保证线程安全。
* </p>
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@@ -14,28 +16,25 @@ public interface BeforeInterceptor extends Handler<RoutingContext> {
String IS_NEXT = "RoutingContextIsNext";
default Handler<RoutingContext> doHandle() {
return ctx -> {
// 加同步锁
synchronized (BeforeInterceptor.class) {
ctx.put(IS_NEXT, false);
BeforeInterceptor.this.handle(ctx);
if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
sendError(ctx, 403);
}
}
// 【优化】移除synchronized锁,Vert.x的RoutingContext本身就是线程安全的
// 每个请求都有独立的RoutingContext,不需要额外加锁
ctx.put(IS_NEXT, false);
handle(ctx); // 调用具体的处理逻辑
// 确保如果没有调用doNext()并且响应未结束,则返回错误
// if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
// sendError(ctx, 403);
// }
};
}
default void doNext(RoutingContext context) {
// 设置上下文状态为可以继续执行
// 添加同步锁保障多线程下执行时序
synchronized (BeforeInterceptor.class) {
context.put(IS_NEXT, true);
context.next();
}
// 【优化】移除synchronized锁
// RoutingContext的put和next操作是线程安全的,不需要额外同步
context.put(IS_NEXT, true);
context.next(); // 继续执行下一个处理器
}
void handle(RoutingContext context);
void handle(RoutingContext context); // 实现具体的拦截处理逻辑
}
@@ -30,7 +30,7 @@ public class JsonResult<T> implements Serializable {
private int code = SUCCESS_CODE;//状态码
private String msg = SUCCESS_MESSAGE; //消息
private String msg = SUCCESS_MESSAGE;//消息
private boolean success = true; //是否成功
@@ -1,7 +1,7 @@
/**
* ModuleGen cn.qaiu.vx.core
*/
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core", useFutures = true)
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core")
package cn.qaiu.vx.core;
import io.vertx.codegen.annotations.ModuleGen;
@@ -5,7 +5,7 @@ import io.vertx.serviceproxy.ServiceProxyBuilder;
/**
* @author Xu Haidong
* Create at 2018/8/15
* @date 2018/8/15
*/
public final class AsyncServiceUtil {
@@ -13,6 +13,7 @@ import java.net.Socket;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
@@ -117,7 +118,7 @@ public class CommonUtil {
return set.stream().filter(c1 -> {
HandleSortFilter s1 = c1.getAnnotation(HandleSortFilter.class);
if (s1 != null) {
return s1.value() > 0;
return s1.value() >= 0;
} else {
return true;
}
@@ -138,7 +139,7 @@ public class CommonUtil {
} catch (Exception e) {
throw new RuntimeException(e);
}
}).collect(Collectors.toSet());
}).collect(Collectors.toCollection(LinkedHashSet::new));
}
private static String appVersion;
@@ -152,7 +153,7 @@ public class CommonUtil {
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
}
} catch (IOException e) {
e.printStackTrace();
LOGGER.error("读取app.properties失败", e);
}
}
return appVersion;
@@ -4,9 +4,15 @@ import io.vertx.config.ConfigRetriever;
import io.vertx.config.ConfigRetrieverOptions;
import io.vertx.config.ConfigStoreOptions;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 异步读取配置工具类
* <br>Create date 2021/9/2 1:23
@@ -24,7 +30,29 @@ public class ConfigUtil {
* @return JsonObject的Future
*/
public static Future<JsonObject> readConfig(String format, String path, Vertx vertx) {
// 读取yml配置
// 支持 classpath: 前缀从类路径读取,否则从文件系统读取
if (path != null && path.startsWith("classpath:")) {
String resource = path.substring("classpath:".length());
// 使用 executeBlocking(Callable) 直接返回 Future<JsonObject>
return vertx.executeBlocking(() -> {
InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
if (is == null) {
throw new RuntimeException("classpath resource not found: " + resource);
}
try (InputStream in = is) {
byte[] bytes = in.readAllBytes();
String content = new String(bytes, StandardCharsets.UTF_8);
if ("json".equalsIgnoreCase(format)) {
return new JsonObject(content);
} else {
throw new RuntimeException("unsupported classpath format: " + format);
}
}
});
}
Promise<JsonObject> promise = Promise.promise();
ConfigStoreOptions store = new ConfigStoreOptions()
.setType("file")
.setFormat(format)
@@ -33,10 +61,45 @@ public class ConfigUtil {
ConfigRetriever retriever = ConfigRetriever
.create(vertx, new ConfigRetrieverOptions().addStore(store));
return retriever.getConfig();
// 异步获取配置
// 成功直接完成 promise
retriever.getConfig()
.onSuccess(config -> {
promise.complete(config);
retriever.close();
})
.onFailure(err -> {
retriever.close();
// 读取失败时,尝试从 resources/ 子目录读取(兼容 Docker 卷挂载场景)
String resourcesPath = "resources/" + path;
if (!path.startsWith("resources/") && Files.exists(Path.of(resourcesPath))) {
ConfigStoreOptions fallbackStore = new ConfigStoreOptions()
.setType("file")
.setFormat(format)
.setConfig(new JsonObject().put("path", resourcesPath));
ConfigRetriever fallbackRetriever = ConfigRetriever
.create(vertx, new ConfigRetrieverOptions().addStore(fallbackStore));
fallbackRetriever.getConfig()
.onSuccess(config -> {
promise.complete(config);
fallbackRetriever.close();
})
.onFailure(e2 -> {
promise.fail(new RuntimeException(
"读取配置文件失败: " + path + " (也尝试了 " + resourcesPath + ")", e2));
fallbackRetriever.close();
});
} else {
promise.fail(new RuntimeException(
"读取配置文件失败: " + path, err));
}
});
return promise.future();
}
/**
* 异步读取Yaml配置文件
*
@@ -0,0 +1,20 @@
package cn.qaiu.vx.core.util;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import java.util.concurrent.ExecutionException;
public class FutureUtils {
public static <T> T getResult(Future<T> future) {
try {
return future.toCompletionStage().toCompletableFuture().get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
public static <T> T getResult(Promise<T> promise) {
return promise.future().toCompletionStage().toCompletableFuture().join();
}
}
@@ -16,7 +16,7 @@ import java.time.format.DateTimeFormatter;
/**
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2023/10/14 9:07
* @date 2023/10/14 9:07
*/
public class JacksonConfig {
@@ -1,7 +1,7 @@
package cn.qaiu.vx.core.util;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* vertx 上下文外的本地容器 为不在vertx线程的方法传递数据
@@ -10,11 +10,10 @@ import java.util.Map;
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class LocalConstant {
private static final Map<String, Object> LOCAL_CONST = new HashMap<>();
private static final Map<String, Object> LOCAL_CONST = new ConcurrentHashMap<>();
public static Map<String, Object> put(String k, Object v) {
if (LOCAL_CONST.containsKey(k)) return LOCAL_CONST;
LOCAL_CONST.put(k, v);
LOCAL_CONST.putIfAbsent(k, v);
return LOCAL_CONST;
}
@@ -36,16 +36,20 @@ public final class ParamUtil {
public static MultiMap paramsToMap(String paramString) {
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
if (paramString == null) return entries;
if (paramString == null || paramString.isEmpty()) return entries;
String[] params = paramString.split("&");
if (params.length == 0) return entries;
for (String param : params) {
String[] kv = param.split("=");
if (param == null || param.isEmpty()) {
continue;
}
String[] kv = param.split("=", 2);
if (kv.length == 2) {
entries.set(kv[0], kv[1]);
} else {
} else if (kv.length == 1) {
entries.set(kv[0], "");
}
// kv.length == 0 时(空字符串),跳过
}
return entries;
}
@@ -25,6 +25,9 @@ import java.net.URL;
import java.text.ParseException;
import java.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
/**
@@ -36,6 +39,10 @@ import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
*/
public final class ReflectionUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(ReflectionUtil.class);
// 缓存Reflections实例,避免重复扫描(每次扫描约35K+值,耗时1-3秒,占用大量内存)
private static final Map<String, Reflections> REFLECTIONS_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
/**
* 以默认配置的基础包路径获取反射器
@@ -47,52 +54,48 @@ public final class ReflectionUtil {
}
/**
* 获取反射器
* 获取反射器(带缓存)
*
* @param packageAddress Package address String
* @return Reflections object
*/
public static Reflections getReflections(String packageAddress) {
List<String> packageAddressList;
if (packageAddress.contains(",")) {
packageAddressList = Arrays.asList(packageAddress.split(","));
} else if (packageAddress.contains(";")) {
packageAddressList = Arrays.asList(packageAddress.split(";"));
} else {
packageAddressList = Collections.singletonList(packageAddress);
}
return getReflections(packageAddressList);
return REFLECTIONS_CACHE.computeIfAbsent(packageAddress, key -> {
List<String> packageAddressList;
if (key.contains(",")) {
packageAddressList = Arrays.asList(key.split(","));
} else if (key.contains(";")) {
packageAddressList = Arrays.asList(key.split(";"));
} else {
packageAddressList = Collections.singletonList(key);
}
return createReflections(packageAddressList);
});
}
/**
* 获取反射器
* 获取反射器(带缓存)
*
* @param packageAddresses Package address List
* @return Reflections object
*/
public static Reflections getReflections(List<String> packageAddresses) {
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
FilterBuilder filterBuilder = new FilterBuilder();
packageAddresses.forEach(str -> {
Collection<URL> urls = ClasspathHelper.forPackage(str.trim());
configurationBuilder.addUrls(urls);
filterBuilder.includePackage(str.trim());
});
String cacheKey = String.join(",", packageAddresses);
return REFLECTIONS_CACHE.computeIfAbsent(cacheKey, key -> createReflections(packageAddresses));
}
// 采坑记录 2021-05-08
// 发现注解api层 没有继承父类时 这里反射一直有问题(Scanner SubTypesScanner was not configured)
// 因此这里需要手动配置各种Scanner扫描器 -- https://blog.csdn.net/qq_29499107/article/details/106889781
configurationBuilder.setScanners(
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
// 会报错.默认为true.
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
);
configurationBuilder.filterInputsBy(filterBuilder);
private static Reflections createReflections(List<String> packageAddresses) {
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder()
.addClassLoaders(Thread.currentThread().getContextClassLoader())
.forPackages(packageAddresses.toArray(new String[0]))
.setScanners(
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
// 会报错.默认为true.
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
);
return new Reflections(configurationBuilder);
}
@@ -130,7 +133,7 @@ public final class ReflectionUtil {
parameterTypes[j - k]));
}
} catch (NotFoundException e) {
e.printStackTrace();
LOGGER.error("获取方法参数失败", e);
}
return paramMap;
}
@@ -185,7 +188,7 @@ public final class ReflectionUtil {
try {
return DateUtils.parseDate(value, fmt);
} catch (ParseException e) {
e.printStackTrace();
LOGGER.error("日期解析失败: {}", value, e);
throw new RuntimeException("无法将格式化日期");
}
default:
@@ -217,7 +220,7 @@ public final class ReflectionUtil {
}
return arr;
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("数组类型转换失败: {}", value, e);
}
return null;
}
@@ -243,7 +246,7 @@ public final class ReflectionUtil {
public static boolean isBasicTypeArray(CtClass ctClass) {
if (!ctClass.isArray()) {
return false;
} else return (ctClass.getName().matches("^(boolen|char|byte|short|int|long|float|double|String)\\[]$"));
} else return (ctClass.getName().matches("^(boolean|char|byte|short|int|long|float|double|String)\\[]$"));
}
/**
@@ -13,6 +13,7 @@ public class ResponseUtil {
public static void redirect(HttpServerResponse response, String url) {
response.putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
.putHeader("Referrer-Policy", "no-referrer")
.putHeader(HttpHeaders.LOCATION, url).setStatusCode(302).end();
}
@@ -22,14 +23,22 @@ public class ResponseUtil {
}
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(200)
.end(jsonObject.encode());
fireJsonObjectResponse(ctx, jsonObject, 200);
}
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject) {
fireJsonObjectResponse(ctx, jsonObject, 200);
}
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(statusCode)
.end(jsonObject.encode());
}
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject, int statusCode) {
ctx.putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(200)
.setStatusCode(statusCode)
.end(jsonObject.encode());
}
@@ -37,6 +46,10 @@ public class ResponseUtil {
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
}
public static <T> void fireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
fireJsonObjectResponse(ctx, jsonResult.toJsonObject(), statusCode);
}
public static <T> void fireJsonResultResponse(HttpServerResponse ctx, JsonResult<T> jsonResult) {
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
}
@@ -1,50 +1,77 @@
package cn.qaiu.vx.core.verticle;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Vertx;
import io.vertx.core.VertxOptions;
import io.vertx.core.dns.AddressResolverOptions;
import io.vertx.core.http.*;
import io.vertx.core.json.JsonObject;
import io.vertx.core.net.NetClient;
import io.vertx.core.net.NetClientOptions;
import io.vertx.core.net.NetSocket;
import io.vertx.core.net.ProxyOptions;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.util.Base64;
import static cn.qaiu.vx.core.util.ConfigConstant.GLOBAL_CONFIG;
import static cn.qaiu.vx.core.util.ConfigConstant.LOCAL;
/**
*
*/
public class HttpProxyVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(HttpProxyVerticle.class);
private HttpClient httpClient;
private NetClient netClient;
private JsonObject proxyPreConf;
private JsonObject proxyServerConf;
@Override
public void start() {
ProxyOptions proxyOptions = new ProxyOptions().setHost("127.0.0.1").setPort(7890);
proxyServerConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
proxyPreConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-pre");
Integer serverPort = proxyServerConf.getInteger("port");
ProxyOptions proxyOptions = null;
if (proxyPreConf != null && StringUtils.isNotBlank(proxyPreConf.getString("ip"))) {
proxyOptions = new ProxyOptions(proxyPreConf);
}
// 初始化 HTTP 客户端,用于向目标服务器发送 HTTP 请求
HttpClientOptions httpClientOptions = new HttpClientOptions();
httpClient = vertx.createHttpClient(httpClientOptions.setProxyOptions(proxyOptions));
if (proxyOptions != null) {
httpClientOptions.setProxyOptions(proxyOptions);
}
httpClient = vertx.createHttpClient(httpClientOptions);
// 创建并启动 HTTP 代理服务器,监听指定端口
HttpServer server = vertx.createHttpServer(new HttpServerOptions().setClientAuth(ClientAuth.REQUIRED));
HttpServerOptions httpServerOptions = new HttpServerOptions();
if (proxyServerConf.containsKey("username") &&
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
httpServerOptions.setClientAuth(ClientAuth.REQUIRED);
}
HttpServer server = vertx.createHttpServer();
server.requestHandler(this::handleClientRequest);
// 初始化 NetClient,用于在 CONNECT 请求中建立 TCP 连接隧道
netClient = vertx.createNetClient(new NetClientOptions()
.setProxyOptions(proxyOptions)
NetClientOptions netClientOptions = new NetClientOptions();
if (proxyOptions != null) {
httpClientOptions.setProxyOptions(proxyOptions);
}
netClient = vertx.createNetClient(netClientOptions
.setConnectTimeout(15000)
.setTrustAll(true));
// 启动 HTTP 代理服务器
server.listen(7891, ar -> {
if (ar.succeeded()) {
System.out.println("HTTP Proxy server started on port 7891");
} else {
System.err.println("Failed to start HTTP Proxy server: " + ar.cause());
}
});
server.listen(serverPort)
.onSuccess(res-> LOGGER.info("HTTP Proxy server started on port {}", serverPort))
.onFailure(err-> LOGGER.error("Failed to start HTTP Proxy server: " + err.getMessage()));
}
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
@@ -66,49 +93,63 @@ public class HttpProxyVerticle extends AbstractVerticle {
}
clientRequest.pause();
// 通过 NetClient 连接目标服务器并创建隧道
netClient.connect(targetPort, targetHost, connectionAttempt -> {
if (connectionAttempt.succeeded()) {
NetSocket targetSocket = connectionAttempt.result();
netClient.connect(targetPort, targetHost)
.onSuccess(targetSocket -> {
// Upgrade client connection to NetSocket and implement bidirectional data flow
clientRequest.toNetSocket()
.onSuccess(clientSocket -> {
// Set up bidirectional data forwarding
clientSocket.handler(targetSocket::write);
targetSocket.handler(clientSocket::write);
// 升级客户端连接到 NetSocket 并实现双向数据流
clientRequest.toNetSocket().onComplete(clientSocketAttempt -> {
if (clientSocketAttempt.succeeded()) {
NetSocket clientSocket = clientSocketAttempt.result();
// 设置双向数据流转发
clientSocket.handler(targetSocket::write);
targetSocket.handler(clientSocket::write);
// 关闭其中一方时关闭另一方
clientSocket.closeHandler(v -> targetSocket.close());
targetSocket.closeHandler(v -> clientSocket.close());
} else {
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.cause().getMessage());
targetSocket.close();
clientRequest.response().setStatusCode(500).end("Internal Server Error");
}
// Close the other socket when one side closes
clientSocket.closeHandler(v -> targetSocket.close());
targetSocket.closeHandler(v -> clientSocket.close());
})
.onFailure(clientSocketAttempt -> {
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.getMessage());
targetSocket.close();
clientRequest.response().setStatusCode(500).end("Internal Server Error");
});
})
.onFailure(connectionAttempt -> {
System.err.println("Failed to connect to target: " + connectionAttempt.getMessage());
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
});
} else {
System.err.println("Failed to connect to target: " + connectionAttempt.cause().getMessage());
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
}
});
}
// 处理客户端的 HTTP 请求
private void handleClientRequest(HttpServerRequest clientRequest) {
String s = clientRequest.headers().get("Proxy-Authorization");
if (s == null) {
clientRequest.response().setStatusCode(403).end();
return;
// 打印来源ip和访问目标URI
LOGGER.debug("source: {}, target: {}", clientRequest.remoteAddress().toString(), clientRequest.uri());
if (proxyServerConf.containsKey("username") &&
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
String s = clientRequest.headers().get("Proxy-Authorization");
if (s == null) {
clientRequest.response().setStatusCode(403).end();
return;
}
String[] split;
try {
split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
} catch (IllegalArgumentException e) {
LOGGER.warn("Proxy-Authorization header is not valid Base64");
clientRequest.response().setStatusCode(403).end();
return;
}
if (split.length <= 1) {
LOGGER.warn("Proxy-Authorization header format invalid: missing username:password separator");
clientRequest.response().setStatusCode(403).end();
return;
}
String username = proxyServerConf.getString("username");
String password = proxyServerConf.getString("password");
if (!split[0].equals(username) || !split[1].equals(password)) {
LOGGER.info("-----auth failed------\nusername: {}", split[0]);
clientRequest.response().setStatusCode(403).end();
return;
}
}
String[] split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
if (split.length > 1) {
System.out.println(split[0]);
System.out.println(split[1]);
// TODO
}
if (clientRequest.method() == HttpMethod.CONNECT) {
// 处理 CONNECT 请求
@@ -129,7 +170,7 @@ public class HttpProxyVerticle extends AbstractVerticle {
}
String targetHost = hostHeader.split(":")[0];
int targetPort = 80; // 默认为 HTTP 的端口
int targetPort = extractPortFromUrl(clientRequest.uri()); // 默认为 HTTP 的端口
clientRequest.pause(); // 暂停客户端请求的读取,避免数据丢失
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
@@ -140,45 +181,63 @@ public class HttpProxyVerticle extends AbstractVerticle {
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
// 将客户端请求的 body 转发给目标服务器
clientRequest.bodyHandler(body -> request.send(body, ar -> {
if (ar.succeeded()) {
var response = ar.result();
clientRequest.response().setStatusCode(response.statusCode());
clientRequest.response().headers().setAll(response.headers());
response.body().onSuccess(b-> clientRequest.response().end(b));
} else {
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to reach target");
}
}));
clientRequest.bodyHandler(body ->
request.send(body)
.onSuccess(response -> {
clientRequest.response().setStatusCode(response.statusCode());
clientRequest.response().headers().setAll(response.headers());
response.body()
.onSuccess(b -> clientRequest.response().end(b))
.onFailure(err -> clientRequest.response()
.setStatusCode(502).end("Bad Gateway: Unable to reach target"));
})
.onFailure(err -> clientRequest.response()
.setStatusCode(502).end("Bad Gateway: Unable to reach target"))
);
})
.onFailure(err -> {
err.printStackTrace();
LOGGER.error("HTTP请求失败", err);
clientRequest.response().setStatusCode(502).end("Bad Gateway: Request failed");
});
}
/**
* 从 URL 中提取端口号
*
* @param urlString URL 字符串
* @return 提取的端口号,如果没有指定端口,则返回默认端口
*/
public static int extractPortFromUrl(String urlString) {
try {
URI uri = new URI(urlString);
int port = uri.getPort();
// 如果 URL 没有指定端口,使用默认端口
if (port == -1) {
if ("https".equalsIgnoreCase(uri.getScheme())) {
port = 443; // HTTPS 默认端口
} else {
port = 80; // HTTP 默认端口
}
}
return port;
} catch (Exception e) {
LOGGER.error("提取端口失败: {}", urlString, e);
// 出现异常时返回 -1,表示提取失败
return -1;
}
}
@Override
public void stop() {
// 停止 HTTP 客户端以释放资源
if (httpClient != null) {
httpClient.close();
}
if (netClient != null) {
netClient.close();
}
}
/**
* TODO add Deploy
* @param args
*/
public static void main(String[] args) {
// 配置 DNS 解析器,使用多个 DNS 服务器来提升解析速度
Vertx vertx = Vertx.vertx(new VertxOptions()
.setAddressResolverOptions(new AddressResolverOptions()
.addServer("114.114.114.114")
.addServer("114.114.115.115")
.addServer("8.8.8.8")
.addServer("8.8.4.4")));
// 部署 Verticle 并启动动态 HTTP 代理服务器
vertx.deployVerticle(new HttpProxyVerticle());
}
}
@@ -0,0 +1,68 @@
package cn.qaiu.vx.core.verticle;
import cn.qaiu.vx.core.base.AppRun;
import cn.qaiu.vx.core.base.DefaultAppRun;
import cn.qaiu.vx.core.util.CommonUtil;
import cn.qaiu.vx.core.util.ReflectionUtil;
import cn.qaiu.vx.core.util.SharedDataUtil;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 后置执行Verticle - 在core启动后立即执行AppRun实现
* <br>Create date 2024-01-01 00:00:00
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class PostExecVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(PostExecVerticle.class);
private static final Set<AppRun> appRunImplementations;
private static final AtomicBoolean lock = new AtomicBoolean(false);
static {
Reflections reflections = ReflectionUtil.getReflections();
Set<Class<? extends AppRun>> subTypesOf = reflections.getSubTypesOf(AppRun.class);
subTypesOf.add(DefaultAppRun.class);
appRunImplementations = CommonUtil.sortClassSet(subTypesOf);
if (appRunImplementations.isEmpty()) {
LOGGER.warn("未找到 AppRun 接口的实现类");
} else {
LOGGER.info("找到 {} 个 AppRun 接口的实现类", appRunImplementations.size());
}
}
@Override
public void start(Promise<Void> startPromise) {
if (!lock.compareAndSet(false, true)) {
return;
}
LOGGER.info("PostExecVerticle 开始执行...");
if (appRunImplementations != null && !appRunImplementations.isEmpty()) {
appRunImplementations.forEach(appRun -> {
try {
LOGGER.info("执行 AppRun 实现: {}", appRun.getClass().getName());
JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
appRun.execute(globalConfig);
LOGGER.info("AppRun 实现 {} 执行完成", appRun.getClass().getName());
} catch (Exception e) {
LOGGER.error("执行 AppRun 实现 {} 时发生错误",appRun.getClass().getName(), e);
}
});
} else {
LOGGER.info("未找到 AppRun 接口的实现类");
}
LOGGER.info("PostExecVerticle 执行完成");
startPromise.complete();
}
}
@@ -5,8 +5,10 @@ import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.http.HttpClient;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpServer;
import io.vertx.core.http.HttpServerOptions;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.core.net.PemKeyCertOptions;
@@ -15,6 +17,9 @@ import io.vertx.ext.web.Router;
import io.vertx.ext.web.handler.StaticHandler;
import io.vertx.ext.web.proxy.handler.ProxyHandler;
import io.vertx.httpproxy.HttpProxy;
import io.vertx.httpproxy.ProxyContext;
import io.vertx.httpproxy.ProxyInterceptor;
import io.vertx.httpproxy.ProxyResponse;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,12 +27,16 @@ import org.slf4j.LoggerFactory;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* <p>反向代理服务</p>
* <p>可以根据配置文件自动生成代理服务</p>
* <p>可以配置多个服务, 配置文件见示例</p>
* <p>【优化】支持高并发场景,连接池复用,避免线程阻塞</p>
* <br>Create date 2021/9/2 0:41
*
* @author <a href="https://qaiu.top">QAIU</a>
@@ -46,14 +55,83 @@ public class ReverseProxyVerticle extends AbstractVerticle {
public static String REROUTE_PATH_PREFIX = "/__rrvpspp"; //re_route_vert_proxy_server_path_prefix 硬编码
/**
* 【优化】HttpClient连接池,按host:port缓存复用,避免每个请求都创建新连接
*/
private final Map<String, HttpClient> httpClientPool = new ConcurrentHashMap<>();
/**
* 【优化】高并发场景下的HttpClient配置
*/
private static final int MAX_POOL_SIZE = 100; // 最大连接池大小
private static final int MAX_WAIT_QUEUE_SIZE = 500; // 最大等待队列大小
private static final int CONNECT_TIMEOUT = 30000; // 连接超时30秒
private static final int IDLE_TIMEOUT = 60; // 空闲超时60秒
private static final boolean KEEP_ALIVE = true; // 启用Keep-Alive
private static final boolean PIPELINING = true; // 启用HTTP管线化
@Override
public void start(Promise<Void> startPromise) {
CONFIG.onSuccess(this::handleProxyConfList);
CONFIG.onSuccess(this::handleProxyConfList).onFailure(e -> {
LOGGER.info("web代理配置已禁用,当前仅支持API调用");
});
// createFileListener
startPromise.complete();
}
/**
* 【优化】Verticle停止时清理HttpClient连接池
*/
@Override
public void stop(Promise<Void> stopPromise) {
LOGGER.info("Stopping ReverseProxyVerticle, closing {} HttpClient connections...", httpClientPool.size());
httpClientPool.values().forEach(client -> {
try {
client.close();
} catch (Exception e) {
LOGGER.warn("Error closing HttpClient: {}", e.getMessage());
}
});
httpClientPool.clear();
stopPromise.complete();
}
/**
* 【优化】获取或创建HttpClient,实现连接池复用
* @param host 目标主机
* @param port 目标端口
* @return HttpClient实例
*/
private HttpClient getOrCreateHttpClient(String host, int port) {
String key = host + ":" + port;
return httpClientPool.computeIfAbsent(key, k -> {
LOGGER.info("Creating new HttpClient for {}", key);
HttpClientOptions options = new HttpClientOptions()
.setMaxPoolSize(MAX_POOL_SIZE) // 连接池大小
.setMaxWaitQueueSize(MAX_WAIT_QUEUE_SIZE) // 等待队列大小
.setConnectTimeout(CONNECT_TIMEOUT) // 连接超时
.setIdleTimeout(IDLE_TIMEOUT) // 空闲超时
.setKeepAlive(KEEP_ALIVE) // Keep-Alive
.setKeepAliveTimeout(120) // Keep-Alive超时120秒
.setPipelining(PIPELINING) // HTTP管线化
.setPipeliningLimit(10) // 管线化限制
.setDecompressionSupported(true) // 支持解压响应
.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法,降低延迟
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
.setReuseAddress(true) // 允许地址重用
.setReusePort(true); // 允许端口重用
return vertx.createHttpClient(options);
});
}
/**
* 全局可信上游代理 IP 集合(如 nginx),仅这些 IP 的 X-Forwarded-For 会被信任
*/
private Set<String> globalTrustedProxies = new HashSet<>();
/**
* 获取主配置文件
*
@@ -61,6 +139,15 @@ public class ReverseProxyVerticle extends AbstractVerticle {
*/
private void handleProxyConfList(JsonObject config) {
serverName = config.getString("server-name");
// 解析全局 trusted-proxies
JsonArray trustedArr = config.getJsonArray("trusted-proxies");
if (trustedArr != null) {
trustedArr.forEach(ip -> {
if (ip instanceof String) {
globalTrustedProxies.add(((String) ip).trim());
}
});
}
JsonArray proxyConfList = config.getJsonArray("proxy");
if (proxyConfList != null) {
proxyConfList.forEach(proxyConf -> {
@@ -71,6 +158,44 @@ public class ReverseProxyVerticle extends AbstractVerticle {
}
}
/**
* 解析真实客户端 IP。
* 若直连来源在可信代理列表中,优先取 X-Real-IP,其次取 X-Forwarded-For 第一个值;
* 否则直接使用直连对端地址。
*/
private String resolveClientIp(HttpServerRequest request) {
String peerIp = request.remoteAddress().host();
if (globalTrustedProxies.contains(peerIp)) {
String realIp = request.getHeader("X-Real-IP");
if (StringUtils.isNotBlank(realIp)) {
return realIp.trim();
}
String xff = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotBlank(xff)) {
return xff.split(",")[0].trim();
}
}
return peerIp;
}
/**
* 解析 proxy-set-headers 中的 nginx 风格变量。
* 支持:$remote_addr、$proxy_add_x_forwarded_for、$scheme、$host
* 其他值作为字面量直接使用。
*/
private String resolveHeaderVariable(String tpl, HttpServerRequest req, String clientIp) {
return switch (tpl) {
case "$remote_addr" -> clientIp;
case "$proxy_add_x_forwarded_for" -> {
String existing = req.getHeader("X-Forwarded-For");
yield StringUtils.isNotBlank(existing) ? existing + ", " + clientIp : clientIp;
}
case "$scheme" -> req.isSSL() ? "https" : "http";
case "$host" -> req.getHeader("Host");
default -> tpl;
};
}
/**
* 处理单个反向代理配置
*
@@ -97,18 +222,25 @@ public class ReverseProxyVerticle extends AbstractVerticle {
proxyConf.put("page404", DEFAULT_PATH_404);
}
final HttpClient httpClient = VertxHolder.getVertxInstance().createHttpClient();
Router proxyRouter = Router.router(vertx);
// Add Server name header
proxyRouter.route().handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
ctx.reroute(rePath);
return;
}
ctx.response().putHeader("Server", serverName);
ctx.next();
});
// http api proxy
if (proxyConf.containsKey("location")) {
handleLocation(proxyConf.getJsonArray("location"), httpClient, proxyRouter);
handleLocation(proxyConf.getJsonArray("location"), proxyRouter);
}
// static server
@@ -117,7 +249,9 @@ public class ReverseProxyVerticle extends AbstractVerticle {
}
// Send page404 page
proxyRouter.errorHandler(404, ctx -> ctx.response().sendFile(proxyConf.getString("page404")));
proxyRouter.errorHandler(404, ctx -> {
ctx.response().sendFile(proxyConf.getString("page404"));
});
HttpServer server = getHttpsServer(proxyConf);
server.requestHandler(proxyRouter);
@@ -128,7 +262,17 @@ public class ReverseProxyVerticle extends AbstractVerticle {
}
private HttpServer getHttpsServer(JsonObject proxyConf) {
HttpServerOptions httpServerOptions = new HttpServerOptions();
HttpServerOptions httpServerOptions = new HttpServerOptions()
// 【优化】高并发服务器配置
.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法
.setCompressionSupported(true) // 启用压缩
.setAcceptBacklog(50000) // 增加积压队列到50000
.setIdleTimeout(120) // 空闲超时120秒
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
.setReuseAddress(true) // 允许地址重用
.setReusePort(true); // 允许端口重用
if (proxyConf.containsKey("ssl")) {
JsonObject sslConfig = proxyConf.getJsonObject("ssl");
@@ -194,10 +338,9 @@ public class ReverseProxyVerticle extends AbstractVerticle {
* 处理Location配置 代理请求Location(和nginx类似?)
*
* @param locationsConf location配置
* @param httpClient 客户端
* @param proxyRouter 代理路由
*/
private void handleLocation(JsonArray locationsConf, HttpClient httpClient, Router proxyRouter) {
private void handleLocation(JsonArray locationsConf, Router proxyRouter) {
locationsConf.stream().map(e -> (JsonObject) e).forEach(location -> {
// 代理规则
@@ -208,14 +351,38 @@ public class ReverseProxyVerticle extends AbstractVerticle {
String host = url.getHost();
int port = url.getPort();
if (port == -1) {
port = 80;
port = 443;
}
String originPath = url.getPath();
LOGGER.info("path {}, originPath {}, to {}:{}", path, originPath, host, port);
// 注意这里不能origin多个代理地址, 一个实例只能代理一个origin
// 【优化】使用连接池获取HttpClient,避免每个location都创建新连接
final HttpClient httpClient = getOrCreateHttpClient(host, port);
final HttpProxy httpProxy = HttpProxy.reverseProxy(httpClient);
httpProxy.origin(port, host);
// proxy-set-headers 支持(nginx 风格变量替换)
if (location.containsKey("proxy-set-headers")) {
final JsonObject headerConf = location.getJsonObject("proxy-set-headers");
httpProxy.addInterceptor(new ProxyInterceptor() {
@Override
public Future<ProxyResponse> handleProxyRequest(ProxyContext ctx) {
HttpServerRequest incoming = ctx.request().proxiedRequest();
String clientIp = resolveClientIp(incoming);
headerConf.forEach(entry -> {
Object val = entry.getValue();
if (val != null) {
String resolved = resolveHeaderVariable(val.toString(), incoming, clientIp);
if (resolved != null) {
ctx.request().putHeader(entry.getKey(), resolved);
}
}
});
return ProxyInterceptor.super.handleProxyRequest(ctx);
}
});
}
if (StringUtils.isEmpty(path)) {
return;
}
@@ -224,24 +391,65 @@ public class ReverseProxyVerticle extends AbstractVerticle {
if (StringUtils.isEmpty(originPath) || path.equals(originPath)) {
Route route = path.startsWith("~") ? proxyRouter.routeWithRegex(path.substring(1))
: proxyRouter.route(path);
// 【优化】为代理处理器添加超时
route.handler(ProxyHandler.create(httpProxy));
} else {
// 配置 /api/, / => 请求 /api/test 代理后 /test
// 配置 /api/, /xxx => 请求 /api/test 代理后 /xxx/test
final String path0 = path;
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
final String path0 = path;
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
proxyRouter.route(originPath0 + "*").handler(ProxyHandler.create(httpProxy));
proxyRouter.route(path0 + "*").handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(path0)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.replaceAll("^" + path0, originPath0);
ctx.reroute(rePath);
} else {
ctx.next();
}
});
proxyRouter.route(originPath0 + "*").handler(ProxyHandler.create(httpProxy));
proxyRouter.route(path0 + "*").handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(path0)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.replaceAll("^" + path0, originPath0);
ctx.reroute(rePath);
} else {
ctx.next();
}
});
// 计算唯一后缀,避免多个 location 冲突
// String uniqueKey = (host + ":" + port + "|" + path).replaceAll("[^a-zA-Z0-9:_|/]", "");
// String uniqueSuffix = Integer.toHexString(uniqueKey.hashCode());
//
//// 规格化 originPath
// //String originPath = url.getPath(); // 原值
// if (StringUtils.isBlank(originPath)) originPath = "/";
//
//// 处理 index.html 的情况:用于首页兜底,其它子路径仍按目录穿透
// String indexFile;
// if (originPath.endsWith(".html")) {
// indexFile = originPath; // 例如 /index.html
// originPath = "/"; // 目录穿透基准改为根
// } else {
// indexFile = null;
// }
//
//// 唯一内部挂载前缀
// final String originMount = REROUTE_PATH_PREFIX + uniqueSuffix + originPath;
//
//// 1) 目标挂载:所有被重写的请求最终到这里走 ProxyHandler
// proxyRouter.route(originMount + "*").handler(ProxyHandler.create(httpProxy));
//
//// 2) 从外部前缀 -> 内部挂载 的重写
// final String path0 = path;
// proxyRouter.route(path0 + "*").handler(ctx -> {
// String uri = ctx.request().uri();
// if (!uri.startsWith(path0)) { ctx.next(); return; }
//
// // 首页兜底:访问 /n2 或 /n2/ 时,重写到 index.html(如果配置了)
// if (indexFile != null && (uri.equals(path0) || uri.equals(path0.substring(0, path0.length()-1)))) {
// String rePath = originMount.endsWith("/") ? (originMount + indexFile.substring(1)) : (originMount + indexFile);
// ctx.reroute(rePath);
// return;
// }
//
// // 一般穿透:/n2/xxx -> originMount + xxx
// String rePath = uri.replaceFirst("^" + path0, originMount);
// ctx.reroute(rePath);
// });
}
} catch (MalformedURLException e) {
@@ -23,12 +23,11 @@ public class RouterVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(RouterVerticle.class);
private static final int port = SharedDataUtil.getValueForServerConfig("port");
private static final Router router = new RouterHandlerFactory(
SharedDataUtil.getJsonStringForServerConfig("contextPath")).createRouter();
private static final JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
private HttpServer server;
private Router router;
static {
LOGGER.info(JacksonConfig.class.getSimpleName() + " >> ");
@@ -49,6 +48,20 @@ public class RouterVerticle extends AbstractVerticle {
options = new HttpServerOptions();
}
options.setPort(port);
// 【优化】高并发服务器配置
options.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法,降低延迟
.setCompressionSupported(true) // 启用压缩
.setAcceptBacklog(50000) // 增加积压队列到50000,防止高并发时连接被拒绝
.setIdleTimeout(120) // 空闲超时120秒
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
.setReuseAddress(true) // 允许地址重用
.setReusePort(true); // 允许端口重用
router = new RouterHandlerFactory(
SharedDataUtil.getJsonStringForServerConfig("contextPath")).createRouter();
server = vertx.createHttpServer(options);
server.requestHandler(router).webSocketHandler(s->{}).listen()
@@ -5,11 +5,15 @@ import cn.qaiu.vx.core.base.BaseAsyncService;
import cn.qaiu.vx.core.util.ReflectionUtil;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.eventbus.MessageConsumer;
import io.vertx.core.json.JsonObject;
import io.vertx.serviceproxy.ServiceBinder;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
@@ -24,26 +28,48 @@ public class ServiceVerticle extends AbstractVerticle {
Logger LOGGER = LoggerFactory.getLogger(ServiceVerticle.class);
private static final AtomicInteger ID = new AtomicInteger(1);
private static final Set<Class<?>> handlers;
private final List<MessageConsumer<JsonObject>> consumers = new ArrayList<>();
static {
Reflections reflections = ReflectionUtil.getReflections();
handlers = reflections.getTypesAnnotatedWith(Service.class);
}
@Override
public void start(Promise<Void> startPromise) {
ServiceBinder binder = new ServiceBinder(vertx);
if (null != handlers && handlers.size() > 0) {
// handlers转为拼接类列表,xxx,yyy,zzz
StringBuilder serviceNames = new StringBuilder();
handlers.forEach(asyncService -> {
try {
serviceNames.append(asyncService.getName()).append("|");
BaseAsyncService asInstance = (BaseAsyncService) ReflectionUtil.newWithNoParam(asyncService);
binder.setAddress(asInstance.getAddress()).register(asInstance.getAsyncInterfaceClass(), asInstance);
String address = asInstance.getAddress();
MessageConsumer<JsonObject> consumer = binder.setAddress(address)
.register(asInstance.getAsyncInterfaceClass(), asInstance);
consumers.add(consumer);
} catch (Exception e) {
LOGGER.error(e.getMessage());
LOGGER.error("Failed to register service: {}", asyncService.getName(), e);
}
});
LOGGER.info("registered async services -> id: {}", ID.getAndIncrement());
LOGGER.info("registered async services -> id: {}, name: {}", ID.getAndIncrement(), serviceNames.toString());
}
startPromise.complete();
}
@Override
public void stop(Promise<Void> stopPromise) {
int count = consumers.size();
consumers.forEach(consumer -> {
try {
consumer.unregister();
} catch (Exception e) {
LOGGER.warn("Failed to unregister service consumer at address: {}", consumer.address(), e);
}
});
consumers.clear();
LOGGER.info("ServiceVerticle stopped, unregistered {} services", count);
stopPromise.complete();
}
}
@@ -8,14 +8,14 @@ import io.vertx.core.net.ProxyOptions;
import java.util.UUID;
@DataObject
@JsonGen(publicConverter = false)
//@JsonGen(publicConverter = false)
public class HttpProxyConf {
public static final String DEFAULT_USERNAME = UUID.randomUUID().toString();
public static final String DEFAULT_PASSWORD = UUID.randomUUID().toString();
public static final Integer DEFAULT_PORT = 6402;
public static final Integer DEFAULT_PORT = 6432;
public static final Integer DEFAULT_TIMEOUT = 15000;
@@ -32,7 +32,7 @@ public class HttpProxyConf {
public HttpProxyConf() {
this.username = DEFAULT_USERNAME;
this.password = DEFAULT_PASSWORD;
this.timeout = DEFAULT_PORT;
this.port = DEFAULT_PORT;
this.timeout = DEFAULT_TIMEOUT;
this.preProxyOptions = new ProxyOptions();
}
@@ -0,0 +1,134 @@
package cn.qaiu.vx.core.test;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.junit.Assert;
import org.junit.Test;
/**
* 单元测试:验证 RouterHandlerFactory 关于 JsonObject/JsonArray 参数绑定的核心分支逻辑是否正确
* (不启动整个 Vert.x 服务器,直接用 Vert.x JsonObject/JsonArray API 模拟验证关键逻辑)
*/
public class JsonBodyBindingLogicTest {
// === 模拟 handlerMethod 中的 JSON body 绑定逻辑 ===
/**
* 模拟:content-type = application/jsonbody 是 JsonObject
* 期望:JsonObject 类型参数被正确绑定
*/
@Test
public void testJsonObjectBinding() {
String bodyStr = "{\"name\":\"test\",\"value\":123}";
// 模拟 ctx.body().asJsonObject()
JsonObject body = parseAsJsonObject(bodyStr);
Assert.assertNotNull("body 应能解析为 JsonObject", body);
// 模拟绑定逻辑中的类型判断
String targetType = JsonObject.class.getName();
boolean matched = JsonObject.class.getName().equals(targetType);
Assert.assertTrue("JsonObject 类型应命中绑定分支", matched);
// 模拟结果
Object bound = body; // parameterValueList.put(k, body)
Assert.assertNotNull("JsonObject 参数应被绑定(非null", bound);
Assert.assertEquals("name字段应为test", "test", ((JsonObject) bound).getString("name"));
Assert.assertEquals("value字段应为123", 123, (int) ((JsonObject) bound).getInteger("value"));
System.out.println("[PASS] testJsonObjectBinding: JsonObject 绑定成功 -> " + bound);
}
/**
* 模拟:content-type = application/jsonbody 是 JsonArray
* 期望:JsonArray 类型参数被正确绑定
*/
@Test
public void testJsonArrayBinding() {
String bodyStr = "[1,2,3]";
// body 解析为 JsonObject 应返回 null
JsonObject bodyAsObj = parseAsJsonObject(bodyStr);
Assert.assertNull("JsonArray body 解析为 JsonObject 应为 null", bodyAsObj);
// 进入 else 分支,解析为 JsonArray
JsonArray bodyArr = parseAsJsonArray(bodyStr);
Assert.assertNotNull("body 应能解析为 JsonArray", bodyArr);
String targetType = JsonArray.class.getName();
boolean matched = JsonArray.class.getName().equals(targetType);
Assert.assertTrue("JsonArray 类型应命中绑定分支", matched);
Object bound = bodyArr;
Assert.assertNotNull("JsonArray 参数应被绑定(非null", bound);
Assert.assertEquals("数组大小应为3", 3, ((JsonArray) bound).size());
System.out.println("[PASS] testJsonArrayBinding: JsonArray 绑定成功, size=" + ((JsonArray) bound).size());
}
/**
* 验证旧代码的 bug:条件 ctx.body().asJsonObject() != null 会把 JsonArray body 排除在外
* 新代码只判断 content-type,在 body==null 时才进 else 分支处理 JsonArray
*/
@Test
public void testOldConditionBug() {
String jsonArrayBody = "[1,2,3]";
// 旧代码条件:content-type==json && asJsonObject()!=null
// 对于 JsonArray bodyasJsonObject() 返回 null,整个 if 跳过
JsonObject wrongParsed = parseAsJsonObject(jsonArrayBody);
boolean oldConditionPassed = wrongParsed != null; // 旧代码的第二个条件
Assert.assertFalse("旧代码 bug: JsonArray body 会导致 asJsonObject()==null,整个分支跳过", oldConditionPassed);
// 新代码:先进 ifbody==null 再走 else 解析 JsonArray
boolean newConditionFirst = true; // content-type 匹配
JsonObject newBody = parseAsJsonObject(jsonArrayBody);
boolean newBodyIsNull = newBody == null; // null -> 进 else
Assert.assertTrue("新代码: body 解析为 null 时应走 else 分支解析 JsonArray", newBodyIsNull);
JsonArray newArr = parseAsJsonArray(jsonArrayBody);
Assert.assertNotNull("新代码: else 分支正确解析出 JsonArray", newArr);
System.out.println("[PASS] testOldConditionBug: 修复验证通过,新代码正确处理 JsonArray body");
}
/**
* 验证:JsonObject 参数旧代码没有绑定分支(只处理实体类)
*/
@Test
public void testOldMissingJsonObjectBranch() {
String bodyStr = "{\"key\":\"value\"}";
JsonObject body = parseAsJsonObject(bodyStr);
// 旧代码只调用 matchRegList(entityPackagesReg, typeName)
// 对于 io.vertx.core.json.JsonObject,该方法返回 false,不会被绑定
String typeName = JsonObject.class.getName(); // "io.vertx.core.json.JsonObject"
// entityPackagesReg 一般是 "cn.qaiu.*" 这类,不会匹配 io.vertx
boolean oldWouldBind = typeName.startsWith("cn.qaiu"); // 模拟旧代码逻辑
Assert.assertFalse("旧代码 bug: JsonObject 参数不会被绑定", oldWouldBind);
// 新代码:增加了 JsonObject 类型判断
boolean newWouldBind = JsonObject.class.getName().equals(typeName);
Assert.assertTrue("新代码: JsonObject 参数应能被绑定", newWouldBind);
System.out.println("[PASS] testOldMissingJsonObjectBranch: 修复验证通过");
}
// ===== 辅助方法:模拟 Vert.x RequestBody 的 asJsonObject/asJsonArray 行为 =====
private JsonObject parseAsJsonObject(String str) {
try {
return new JsonObject(str);
} catch (Exception e) {
return null;
}
}
private JsonArray parseAsJsonArray(String str) {
try {
return new JsonArray(str);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,125 @@
package cn.qaiu.vx.core.test;
import cn.qaiu.vx.core.util.VertxHolder;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* 集成测试: 验证 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定逻辑是否正确
*
* 运行方式: mvn test-compile -pl core && java -cp "core/target/test-classes:core/target/classes:..." \
* cn.qaiu.vx.core.test.RouterHandlerBindingTest
*
* 或直接在 IDE 中运行 main 方法。
*/
public class RouterHandlerBindingTest {
static final int TEST_PORT = 18989;
public static void main(String[] args) throws Exception {
System.out.println("=== RouterHandler JsonObject/JsonArray 绑定测试 ===\n");
// 1. 先初始化 Vert.x 与 VertxHolder ——必须在加载 RouterHandlerFactory 之前
Vertx vertx = Vertx.vertx();
VertxHolder.init(vertx);
// 2. 向 SharedData 注入最小化配置
// baseLocations 指向测试包,使 Reflections 只扫描 TestJsonHandler
vertx.sharedData().getLocalMap("local").put("customConfig", new JsonObject()
.put("baseLocations", "cn.qaiu.vx.core.test")
.put("routeTimeOut", 30000)
.put("entityPackagesReg", new JsonArray()));
// ReverseProxyVerticle.<clinit> 需要 globalConfig.proxyConf(非空字符串即可)
vertx.sharedData().getLocalMap("local").put("globalConfig", new JsonObject()
.put("proxyConf", "proxy.yml"));
// 3. 创建 Router(此时才触发 BaseHttpApi.reflections 静态字段初始化)
// 用反射延迟加载,确保上面的 SharedData 已就绪
cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory factory =
new cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory("api");
io.vertx.ext.web.Router router = factory.createRouter();
// 4. 启动 HTTP 服务器
CountDownLatch latch = new CountDownLatch(1);
vertx.createHttpServer()
.requestHandler(router)
.listen(TEST_PORT, res -> {
if (res.succeeded()) {
System.out.println("✔ 测试服务器启动成功 port=" + TEST_PORT);
} else {
System.err.println("✘ 服务器启动失败: " + res.cause().getMessage());
}
latch.countDown();
});
if (!latch.await(5, TimeUnit.SECONDS)) {
System.err.println("服务器启动超时");
vertx.close();
System.exit(1);
}
Thread.sleep(100); // 等 Vert.x 就绪
// 5. 执行测试
boolean allPassed = true;
allPassed &= testJsonObject();
allPassed &= testJsonArray();
// 6. 关闭
CountDownLatch closeLatch = new CountDownLatch(1);
vertx.close(v -> closeLatch.countDown());
closeLatch.await(3, TimeUnit.SECONDS);
System.out.println("\n" + (allPassed ? "✅ 全部测试通过!" : "❌ 存在测试失败!"));
System.exit(allPassed ? 0 : 1);
}
// ---------- 子测试 ----------
private static boolean testJsonObject() throws Exception {
String bodyStr = "{\"name\":\"test\",\"value\":123}";
String respBody = post("/api/test/json-object", bodyStr);
System.out.println("[JsonObject] 响应: " + respBody);
JsonObject result = new JsonObject(respBody);
JsonObject data = result.getJsonObject("data");
boolean bound = data != null && Boolean.TRUE.equals(data.getBoolean("bound"));
System.out.println("[JsonObject] " + (bound
? "PASS ✅ body 正确绑定为 JsonObject"
: "FAIL ❌ body 未绑定 (null)"));
return bound;
}
private static boolean testJsonArray() throws Exception {
String bodyStr = "[1,2,3]";
String respBody = post("/api/test/json-array", bodyStr);
System.out.println("[JsonArray] 响应: " + respBody);
JsonObject result = new JsonObject(respBody);
JsonObject data = result.getJsonObject("data");
boolean bound = data != null
&& Boolean.TRUE.equals(data.getBoolean("bound"))
&& Integer.valueOf(3).equals(data.getInteger("size"));
System.out.println("[JsonArray] " + (bound
? "PASS ✅ body 正确绑定为 JsonArray, size=3"
: "FAIL ❌ body 未绑定 或 size 不对"));
return bound;
}
private static String post(String path, String body) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + TEST_PORT + path))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
}
@@ -0,0 +1,36 @@
package cn.qaiu.vx.core.test;
import cn.qaiu.vx.core.annotaions.RouteHandler;
import cn.qaiu.vx.core.annotaions.RouteMapping;
import cn.qaiu.vx.core.enums.MIMEType;
import cn.qaiu.vx.core.enums.RouteMethod;
import cn.qaiu.vx.core.model.JsonResult;
import io.vertx.core.Future;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
/**
* 用于测试 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定的测试 Handler
*/
@RouteHandler("test")
public class TestJsonHandler {
/** POST /api/test/json-object Body: {"name":"test","value":123} */
@RouteMapping(value = "/json-object", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
public Future<JsonResult> testJsonObject(JsonObject body) {
// 只返回是否绑定成功及已知字段值,不嵌套原始 body 避免 toJsonObject() 循环
boolean bound = body != null;
String nameVal = bound ? body.getString("name", "") : "";
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
.put("bound", bound)
.put("name", nameVal)));
}
/** POST /api/test/json-array Body: [1,2,3] */
@RouteMapping(value = "/json-array", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
public Future<JsonResult> testJsonArray(JsonArray body) {
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
.put("bound", body != null)
.put("size", body != null ? body.size() : -1)));
}
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -e
# Fix permissions on volume-mounted directories (runs as root)
chown -R appuser:appgroup /app/db /app/logs /app/resources 2>/dev/null || true
# Run Java directly - entrypoint is PID 1, exec makes Java PID 1
# Docker SIGTERM goes directly to Java, triggering ShutdownHook
exec java -Xmx${JVM_XMX:-512M} ${JVM_OPTS} -Duser.timezone=${TZ:-Asia/Shanghai} -jar /app/netdisk-fast-download.jar
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"mvn": "^3.5.0"
}
}
+4 -4
View File
@@ -4,26 +4,26 @@ NFD 解析器模块:聚合各类网盘/分享页解析,统一输出文件列
- 语言:Java 17
- 构建:Maven
- 模块版本:10.1.17
- 模块版本:10.2.5
## 依赖(Maven Central
```xml
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.1.17</version>
<version>10.2.5</version>
</dependency>
```
- Gradle Groovy DSL
```groovy
dependencies {
implementation 'cn.qaiu:parser:10.1.17'
implementation 'cn.qaiu:parser:10.2.5'
}
```
- Gradle Kotlin DSL
```kotlin
dependencies {
implementation("cn.qaiu:parser:10.1.17")
implementation("cn.qaiu:parser:10.2.5")
}
```
+26
View File
@@ -20,6 +20,32 @@
|------|------|------|------|
| url | string | ✅ 是 | 分享链接(需URL编码) |
| pwd | string | ❌ 否 | 分享密码 |
| auth | string | ❌ 否 | 认证参数(AES加密后的JSON,用于需要登录的网盘) |
### 认证参数说明(v0.2.1+
部分网盘(如夸克QK、UC网盘)需要登录后的 Cookie 才能解析。`auth` 参数用于传递认证信息:
**加密方式**
- 算法:AES/ECB/PKCS5Padding
- 密钥:`nfd_auth_key2026`16字节)
- 流程:JSON → AES加密 → Base64 → URL编码
**JSON 结构**
```json
{
"authType": "cookie", // 认证类型: cookie/accesstoken/authorization
"token": "your_cookie_here" // Cookie 或 Token 内容
}
```
**网盘认证要求**
| 网盘 | 认证要求 |
|------|---------|
| 夸克网盘(QK) | **必须** |
| UC网盘(UC) | **必须** |
| 小飞机网盘(FJ) | 大文件需要 |
| 蓝奏优享(IZ) | 大文件需要 |
### 请求示例
+1 -1
View File
@@ -28,7 +28,7 @@
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.1.17</version>
<version>10.2.5</version>
</dependency>
```
+1 -1
View File
@@ -11,7 +11,7 @@
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.1.17</version>
<version>10.2.5</version>
</dependency>
```
+341
View File
@@ -0,0 +1,341 @@
# 认证参数传递指南 (Auth Parameter Guide)
## 概述
本文档描述了网盘解析接口中携带认证参数的方法。通过 `auth` 参数,可以在解析请求时传递临时认证信息(如 Cookie、Token、用户名密码等),使解析器能够访问需要登录或授权的网盘资源。
## 网盘认证要求
| 网盘 | 类型代码 | 认证要求 | 说明 |
|------|---------|---------|------|
| 夸克网盘 | QK | **必须** | 必须配置 Cookie 才能解析和下载 |
| UC网盘 | UC | **必须** | 必须配置 Cookie 才能解析和下载 |
| 小飞机网盘 | FJ | 可选 | 大文件(>100MB)需要配置认证信息 |
| 蓝奏优享 | IZ | 可选 | 大文件需要配置认证信息 |
| 其他网盘 | - | 不需要 | 无需认证即可解析 |
> 💡 **如何获取 Cookie**: 在浏览器中登录对应网盘,打开开发者工具(F12),切换到 Network 标签,刷新页面,在请求头中找到 Cookie 字段并复制完整内容。
## 认证参数格式
### 编码流程
```
JSON对象 → AES加密 → Base64编码 → URL编码
```
### 解码流程
```
URL解码 → Base64解码 → AES解密 → JSON对象
```
### 加密配置
- **加密算法**: AES/ECB/PKCS5Padding
- **密钥长度**: 16位(128位)
- **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置)
### 密钥作用说明(重要)
当前系统中涉及两类不同用途的密钥:
1. `server.authEncryptKey`
- 用途:加解密 `auth` 参数(前端/调用方传入的认证信息)
- 影响范围:`/parser``/json/parser``/v2/linkInfo` 等接口中的 `auth` 参数
- 注意:这是 **AES 对称加密密钥**,要求 16 位
2. `server.donatedAccountFailureTokenSignKey`
- 用途:签名和验签“捐赠账号失败计数 token”(用于防伪造、失败计数)
- 影响范围:捐赠账号失败计数与自动失效逻辑
- 注意:这是 **HMAC 签名密钥**,与 `authEncryptKey` 已解耦,建议使用高强度随机字符串
> 建议:生产环境务必同时自定义这两个密钥,且不要设置为相同值。
## JSON 模型定义
### AuthParam 对象
```json
{
"authType": "string", // 认证类型(必填)
"username": "string", // 用户名
"password": "string", // 密码
"token": "string", // Token/AccessToken/Cookie值
"cookie": "string", // Cookie 字符串
"auth": "string", // Authorization 头内容
"ext1": "string", // 扩展字段1(格式: key:value
"ext2": "string", // 扩展字段2(格式: key:value
"ext3": "string", // 扩展字段3(格式: key:value
"ext4": "string", // 扩展字段4(格式: key:value
"ext5": "string" // 扩展字段5(格式: key:value
}
```
### 认证类型 (authType)
| authType | 说明 | 主要字段 |
|----------|------|---------|
| `accesstoken` | 使用 AccessToken 认证 | `token` |
| `cookie` | 使用 Cookie 认证 | `token` (存放 cookie 值) |
| `authorization` | 使用 Authorization 头认证 | `token` |
| `password` / `username_password` | 用户名密码认证 | `username`, `password` |
| `custom` | 自定义认证(使用扩展字段) | `token`, `ext1`-`ext5` |
### 示例 JSON
#### 1. Token 认证
```json
{
"authType": "accesstoken",
"token": "your_access_token_here"
}
```
#### 2. Cookie 认证
```json
{
"authType": "cookie",
"token": "session_id=abc123; user_token=xyz789"
}
```
#### 3. 用户名密码认证
```json
{
"authType": "password",
"username": "your_username",
"password": "your_password"
}
```
#### 4. 自定义认证
```json
{
"authType": "custom",
"token": "main_token",
"ext1": "refresh_token:your_refresh_token",
"ext2": "device_id:device123"
}
```
## 接口调用示例
### 基础接口
#### 1. 解析并重定向 (GET /parser)
```
GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**参数说明:**
- `url`: 网盘分享链接(必填)
- `pwd`: 提取码(可选)
- `auth`: 加密后的认证参数(可选)
**响应:** 302 重定向到直链
#### 2. 解析返回 JSON (GET /json/parser)
```
GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**响应示例:**
```json
{
"shareKey": "lz:xxxx",
"directLink": "https://...",
"cacheHit": false,
"expires": "2026-02-05 12:00:00",
"expiration": 1738728000000
}
```
#### 3. 获取链接信息 (GET /v2/linkInfo)
```
GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**响应:** 返回下载链接、API 链接、预览链接等信息
## 各语言加密示例
### Java
```java
import cn.qaiu.lz.common.util.AuthParamCodec;
import cn.qaiu.lz.web.model.AuthParam;
// 方式1: 使用 AuthParam 对象
AuthParam authParam = AuthParam.builder()
.authType("accesstoken")
.token("your_token_here")
.build();
String encrypted = AuthParamCodec.encode(authParam);
// 方式2: 快速编码
String encrypted = AuthParamCodec.quickEncode("accesstoken", "your_token_here");
// 方式3: 用户名密码
String encrypted = AuthParamCodec.quickEncodePassword("username", "password");
// 解码
AuthParam decoded = AuthParamCodec.decode(encrypted);
```
### JavaScript (浏览器/Node.js)
```javascript
// 使用 CryptoJS 库
const CryptoJS = require('crypto-js');
const AUTH_KEY = 'nfd_auth_key2026';
// 加密
function encodeAuthParam(authObj) {
const jsonStr = JSON.stringify(authObj);
const encrypted = CryptoJS.AES.encrypt(jsonStr, CryptoJS.enc.Utf8.parse(AUTH_KEY), {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
const base64 = encrypted.toString();
return encodeURIComponent(base64);
}
// 解密
function decodeAuthParam(encryptedAuth) {
const base64 = decodeURIComponent(encryptedAuth);
const decrypted = CryptoJS.AES.decrypt(base64, CryptoJS.enc.Utf8.parse(AUTH_KEY), {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return JSON.parse(decrypted.toString(CryptoJS.enc.Utf8));
}
// 使用示例
const auth = encodeAuthParam({
authType: 'accesstoken',
token: 'your_token_here'
});
const url = `http://127.0.0.1:6400/parser?url=${shareUrl}&auth=${auth}`;
```
### Python
```python
import json
import base64
from urllib.parse import quote, unquote
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
AUTH_KEY = b'nfd_auth_key2026'
def encode_auth_param(auth_obj):
"""加密认证参数"""
json_str = json.dumps(auth_obj, ensure_ascii=False)
cipher = AES.new(AUTH_KEY, AES.MODE_ECB)
padded = pad(json_str.encode('utf-8'), AES.block_size)
encrypted = cipher.encrypt(padded)
base64_str = base64.b64encode(encrypted).decode('utf-8')
return quote(base64_str)
def decode_auth_param(encrypted_auth):
"""解密认证参数"""
base64_str = unquote(encrypted_auth)
encrypted = base64.b64decode(base64_str)
cipher = AES.new(AUTH_KEY, AES.MODE_ECB)
decrypted = unpad(cipher.decrypt(encrypted), AES.block_size)
return json.loads(decrypted.decode('utf-8'))
# 使用示例
auth = encode_auth_param({
'authType': 'accesstoken',
'token': 'your_token_here'
})
url = f'http://127.0.0.1:6400/parser?url={share_url}&auth={auth}'
```
### cURL 命令行
```bash
# 假设已加密的 auth 参数为 ENCRYPTED_AUTH
curl -L "http://127.0.0.1:6400/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH"
# 获取 JSON 响应
curl "http://127.0.0.1:6400/json/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH"
```
## 解析器使用认证信息
解析器可以从 `shareLinkInfo.otherParam.get("auths")` 获取 MultiMap 格式的认证信息:
```java
// 在解析器中获取认证信息
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (auths != null) {
String authType = auths.get("authType");
String token = auths.get("token");
String username = auths.get("username");
String password = auths.get("password");
// 根据 authType 使用相应的认证方式
switch (authType) {
case "accesstoken":
// 使用 token 认证
break;
case "password":
// 使用用户名密码登录
break;
// ...
}
}
```
## 注意事项
1. **安全性**:
- 不要在日志中打印完整的认证参数
- 认证参数通过 HTTPS 传输更安全
- 密钥应妥善保管,建议在生产环境中更换默认密钥
2. **缓存策略**:
- 带有临时认证参数的请求目前不会被缓存
- 每次请求都会重新解析
3. **兼容性**:
- `auth` 参数与原有的 `pwd` 参数可以同时使用
- 不提供 `auth` 参数时,使用后台配置的认证信息
4. **扩展字段**:
- `ext1`-`ext5` 使用 `key:value` 格式
- 适用于需要传递多个自定义参数的场景
## 配置说明
`app-dev.yml` 中配置密钥:
```yaml
server:
# auth参数加密密钥(16位AES密钥)
authEncryptKey: 'your_custom_key16'
# 捐赠账号失败计数token签名密钥(HMAC)
# 建议使用较长随机字符串,并与 authEncryptKey 不同
donatedAccountFailureTokenSignKey: 'your_random_hmac_sign_key'
```
### 密钥管理建议
- 不要在公开仓库提交生产密钥
- 建议通过环境变量或私有配置注入
- 调整 `authEncryptKey` 会影响 `auth` 参数兼容性
- 调整 `donatedAccountFailureTokenSignKey` 会使已签发的失败计数 token 失效(短期可接受)
## 更新日志
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
@@ -0,0 +1,118 @@
# 认证参数传递指南 (简化版)
## JSON 对象模型
### AuthParam 对象
```json
{
"authType": "string", // 认证类型(必填)
"username": "string", // 用户名
"password": "string", // 密码
"token": "string", // Token/AccessToken/Cookie值
"cookie": "string", // Cookie 字符串
"auth": "string", // Authorization 头内容
"ext1": "string", // 扩展字段1(格式: key:value
"ext2": "string", // 扩展字段2(格式: key:value
"ext3": "string", // 扩展字段3(格式: key:value
"ext4": "string", // 扩展字段4(格式: key:value
"ext5": "string" // 扩展字段5(格式: key:value
}
```
## 认证类型
| authType | 说明 | 主要字段 |
|----------|------|---------|
| `accesstoken` | AccessToken 认证 | `token` |
| `cookie` | Cookie 认证 | `token` |
| `authorization` | Authorization 头认证 | `token` |
| `password` | 用户名密码认证 | `username`, `password` |
| `custom` | 自定义认证 | `token`, `ext1`-`ext5` |
## 示例
### Token 认证
```json
{
"authType": "accesstoken",
"token": "your_access_token_here"
}
```
### Cookie 认证
```json
{
"authType": "cookie",
"token": "session_id=abc123; user_token=xyz789"
}
```
### 用户名密码
```json
{
"authType": "password",
"username": "your_username",
"password": "your_password"
}
```
### 自定义认证
```json
{
"authType": "custom",
"token": "main_token",
"ext1": "refresh_token:your_refresh_token",
"ext2": "device_id:device123"
}
```
## 使用说明
1. **编码流程**: JSON对象 → AES加密 → Base64编码 → URL编码
2. **加密配置**: AES/ECB/PKCS5Padding, 密钥: `nfd_auth_key2026` (16位)
3. **接口调用**: `GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}`
## 接口调用示例
### 基础接口
#### 1. 解析并重定向 (GET /parser)
```
GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**参数说明:**
- `url`: 网盘分享链接(必填)
- `pwd`: 提取码(可选)
- `auth`: 加密后的认证参数(可选)
**响应:** 302 重定向到直链
#### 2. 解析返回 JSON (GET /json/parser)
```
GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**响应示例:**
```json
{
"shareKey": "lz:xxxx",
"directLink": "https://...",
"cacheHit": false,
"expires": "2026-02-05 12:00:00",
"expiration": 1738728000000
}
```
#### 3. 获取链接信息 (GET /v2/linkInfo)
```
GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**响应:** 返回下载链接、API 链接、预览链接等信息
+43 -9
View File
@@ -12,7 +12,7 @@
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.2.3</version>
<version>${parserVersion}</version>
<packaging>jar</packaging>
<name>cn.qaiu:parser</name>
@@ -35,9 +35,9 @@
</developers>
<scm>
<connection>scm:git:https://github.com/qaiu/netdisk-fast-download.git</connection>
<developerConnection>scm:git:ssh://[email protected]:qaiu/netdisk-fast-download.git</developerConnection>
<url>https://github.com/qaiu/netdisk-fast-download</url>
<connection>scm:git:https://github.com/${github.owner}/${github.repo}.git</connection>
<developerConnection>scm:git:ssh://[email protected]:${github.owner}/${github.repo}.git</developerConnection>
<url>https://github.com/${github.owner}/${github.repo}</url>
</scm>
<distributionManagement>
@@ -52,20 +52,19 @@
</distributionManagement>
<properties>
<revision>0.1.8</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Versions -->
<vertx.version>4.5.22</vertx.version>
<vertx.version>4.5.27</vertx.version>
<org.reflections.version>0.10.2</org.reflections.version>
<lombok.version>1.18.38</lombok.version>
<slf4j.version>2.0.5</slf4j.version>
<slf4j.version>2.0.16</slf4j.version>
<commons-lang3.version>3.18.0</commons-lang3.version>
<jackson.version>2.14.2</jackson.version>
<logback.version>1.5.19</logback.version>
<jackson.version>2.18.6</jackson.version>
<logback.version>1.5.32</logback.version>
<junit.version>4.13.2</junit.version>
</properties>
@@ -124,6 +123,41 @@
<build>
<plugins>
<!-- 从 git remote origin 自动识别 GitHub 仓库地址 -->
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>4.1.1</version>
<dependencies>
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy</artifactId>
<version>4.0.24</version>
</dependency>
</dependencies>
<executions>
<execution>
<phase>initialize</phase>
<goals><goal>execute</goal></goals>
<configuration>
<scripts>
<script>
def url = 'git remote get-url origin'.execute().text.trim()
def m = (url =~ 'github\\.com[:/]([^/]+)/([^/.]+?)(?:\\.git)?$')
if (m.find()) {
project.properties.setProperty('github.owner', m.group(1))
project.properties.setProperty('github.repo', m.group(2))
} else {
project.properties.setProperty('github.owner', 'qaiu')
project.properties.setProperty('github.repo', 'netdisk-fast-download')
}
</script>
</scripts>
</configuration>
</execution>
</executions>
</plugin>
<!-- 编译 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -86,7 +86,10 @@ public class ShareLinkInfo {
// 将type和shareKey组合成一个字符串作为缓存key
String key = type + ":" + shareKey;
if (type.equals("p115")) {
key += ("_" + otherParam.get("UA").toString().hashCode());
Object ua = otherParam != null ? otherParam.get("UA") : null;
if (ua != null) {
key += ("_" + ua.toString().hashCode());
}
}
return key;
}
@@ -40,23 +40,37 @@ public abstract class PanBase implements IPanTool {
protected Promise<String> promise = Promise.promise();
/**
* Http client
* 共享的 WebClient 实例(线程安全,避免每请求创建导致资源泄漏)
*/
protected WebClient client = WebClient.create(WebClientVertxInit.get(),
private static final WebClient SHARED_CLIENT = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions());
private static final WebClient SHARED_CLIENT_NO_REDIRECTS = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions().setFollowRedirects(false));
private static final WebClient SHARED_CLIENT_DISABLE_UA = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions().setUserAgentEnabled(false));
/**
* Http client session (会话管理, 带cookie请求)
* Http client (默认使用共享实例,代理模式下使用独立实例)
*/
protected WebClient client = SHARED_CLIENT;
/**
* Http client session (会话管理, 带cookie请求, 每实例独立)
*/
protected WebClientSession clientSession = WebClientSession.create(client);
/**
* Http client 不自动跳转
*/
protected WebClient clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions().setFollowRedirects(false));
protected WebClient clientNoRedirects = SHARED_CLIENT_NO_REDIRECTS;
/**
* Http client disable UserAgent
*/
protected WebClient clientDisableUA = SHARED_CLIENT_DISABLE_UA;
protected ShareLinkInfo shareLinkInfo;
/**
* 子类重写此构造方法不需要添加额外逻辑
@@ -68,43 +68,44 @@ public enum PanDomainTemplate {
t-is.cn
*/
LZ("蓝奏云",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(" +
"lanzoul|" +
"lanzouh|" +
"lanosso|" +
"lanpv|" +
"bakstotre|" +
"lanzouo|" +
"lanzov|" +
"lanpw|" +
"ulanzou|" +
"lanzouf|" +
"lanzn|" +
"lanzouj|" +
"lanzouk|" +
"lanzouq|" +
"lanzouv|" +
"lanzoue|" +
"lanzouw|" +
"lanzoub|" +
"lanzouu|" +
"lanwp|" +
"lanzouy|" +
"lanzoup|" +
"woozooo|" +
"lanzv|" +
"dmpdmp|" +
"lanrar|" +
"webgetstore|" +
"lanzb|" +
"lanzoux|" +
"lanzout|" +
"lanzouc|" +
"lanzoui|" +
"lanzoug|" +
"lanzoum" +
")\\.com/(.+/)?(?<KEY>.+)"),
"https://lanzoux.com/{shareKey}",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(?:" +
"(?:lanzoul|" +
"lanzouh|" +
"lanosso|" +
"lanpv|" +
"bakstotre|" +
"lanzouo|" +
"lanzov|" +
"lanpw|" +
"ulanzou|" +
"lanzouf|" +
"lanzn|" +
"lanzouj|" +
"lanzouk|" +
"lanzouq|" +
"lanzouv|" +
"lanzoue|" +
"lanzouw|" +
"lanzoub|" +
"lanzouu|" +
"lanwp|" +
"lanzouy|" +
"lanzoup|" +
"woozooo|" +
"lanzv|" +
"dmpdmp|" +
"lanrar|" +
"webgetstore|" +
"lanzb|" +
"lanzoux|" +
"lanzout|" +
"lanzouc|" +
"lanzoui|" +
"lanzoug|" +
"lanzoum)\\.com" +
"|t-is\\.cn" +
")/(?<KEY>.+)"),
"https://w1.lanzn.com/{shareKey}",
LzTool.class),
// https://www.feijix.com/s/
@@ -114,15 +115,15 @@ public enum PanDomainTemplate {
"https://www.feijix.com/s/{shareKey}",
FjTool.class),
// https://lecloud.lenovo.com/share/
LE("联想乐云",
compile("https://lecloud?\\.lenovo\\.com/share/(?<KEY>.+)"),
// https://lecloud.lenovo.com/share/ https://lecloud.lenovo.com/mshare/
LE("联想乐云",
compile("https://lecloud\\.lenovo\\.com/m?share/(?<KEY>.+)"),
"https://lecloud.lenovo.com/share/{shareKey}",
LeTool.class),
// https://v2.fangcloud.com/s/
FC("亿方云",
compile("https://v2\\.fangcloud\\.(com|cn)/(s|sharing)/(?<KEY>.+)"),
compile("https://v2\\.fangcloud\\.(com|cn)/(s|share|sharing)/(?<KEY>.+)"),
"https://v2.fangcloud.com/s/{shareKey}",
"https://www.fangcloud.com/",
FcTool.class),
@@ -143,9 +144,41 @@ public enum PanDomainTemplate {
compile("https://qfile\\.qq\\.com/q/(?<KEY>.+)"),
"https://qfile.qq.com/q/{shareKey}",
QQscTool.class),
// https://f.ws59.cn/f/或者https://www.wenshushu.cn/f/
// https://f.ws59.cn/f/ 或者 https://www.wenshushu.cn/f/ 等多个镜像域名
/*
f.wsNN.cn (如 f.ws59.cn, f.ws28.cn 等)
www.wenshushu.cn
新增域名:
www.wenxiaozhan.net
www.wenxiaozhan.cn
www.wss.show
www.ws28.cn
www.wss.email
www.wss1.cn
www.ws59.cn
www.wss.cc
www.wss.pet
www.wss.ink
www.wenxiaozhan.com
www.wenshushu.com
www.wss.zone
*/
WS("文叔叔",
compile("https://(f\\.ws(\\d{2})\\.cn|www\\.wenshushu\\.cn)/f/(?<KEY>.+)"),
compile("https://(f\\.ws(\\d{2})\\.cn|" +
"www\\.wenxiaozhan\\.net|" +
"www\\.wenxiaozhan\\.cn|" +
"www\\.wss\\.show|" +
"www\\.ws28\\.cn|" +
"www\\.wss\\.email|" +
"www\\.wss1\\.cn|" +
"www\\.ws59\\.cn|" +
"www\\.wss\\.cc|" +
"www\\.wss\\.pet|" +
"www\\.wss\\.ink|" +
"www\\.wenxiaozhan\\.com|" +
"www\\.wenshushu\\.com|" +
"www\\.wss\\.zone|" +
"www\\.wenshushu\\.cn)/f/(?<KEY>.+)"),
"https://www.wenshushu.cn/f/{shareKey}",
WsTool.class),
// https://www.123pan.com/s/
@@ -199,7 +232,7 @@ public enum PanDomainTemplate {
"123635\\.com|" +
"123242\\.com|" +
"123795\\.com" +
")/s/(?<KEY>.+)(.html)?"),
")/s/(?<KEY>[a-zA-Z0-9_-]+)(?:\\.html)?"),
"https://www.123pan.com/s/{shareKey}",
Ye2Tool.class),
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
@@ -210,19 +243,20 @@ public enum PanDomainTemplate {
EcTool.class),
// https://cowtransfer.com/s/
COW("奶牛快传",
compile("https://(.*)cowtransfer\\.com/s/(?<KEY>.+)"),
compile("https://(?:[a-zA-Z\\d-]+\\.)?cowtransfer\\.com/s/(?<KEY>.+)"),
"https://cowtransfer.com/s/{shareKey}",
CowTool.class),
CT("城通网盘",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
"(?<KEY>[0-9a-zA-Z_-]+)(\\?p=(?<PWD>\\w+))?"),
"https://474b.com/file/{shareKey}",
"https://ctfile.com/file/{shareKey}",
CtTool.class),
// https://url94.ctfile.com/d/64115194-164803691-48508c?p=7609&d=164803691&fk=decb36
CTD("城通网盘-目录",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/d/" +
"(?<KEY>[0-9a-zA-Z_-]+)(\\?p=(?<PWD>\\w+))?"),
"https://ctfile.com/d/{shareKey}",
CtTool.class),
// https://xxx.118pan.com/bxxx
P118("118网盘",
compile("https://(?:[a-zA-Z\\d-]+\\.)?118pan\\.com/b(?<KEY>.+)"),
"https://qaiu.118pan.com/b{shareKey}",
P118Tool.class),
// https://www.vyuyun.com/s/QMa6ie?password=I4KG7H
// https://www.vyuyun.com/s/QMa6ie/file?password=I4KG7H
PVYY("微雨云存储",
@@ -238,7 +272,7 @@ public enum PanDomainTemplate {
PodTool.class),
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
PGD("GoogleDrive",
compile("https://drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
compile("https://(?:[a-zA-Z\\d-]+\\.)?drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
"https://drive.google.com/file/d/{shareKey}/view?usp=sharing",
PgdTool.class),
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
@@ -248,11 +282,11 @@ public enum PanDomainTemplate {
PicTool.class),
// https://www.dropbox.com/scl/fi/cwnbms1yn8u6rcatzyta7/emqx-5.0.26-el7-amd64.tar.gz?rlkey=3uoi4bxz5mv93jmlaws0nlol1&e=8&st=fe0lclc2&dl=0
PDB("dropbox",
compile("https://www.dropbox.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
compile("https://www\\.dropbox\\.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
"https://www.dropbox.com/scl/fi/{shareKey}/?rlkey={pwd}&dl=0",
PdbTool.class),
P115("115网盘",
compile("https://(115|anxia).com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
compile("https://(115|anxia)\\.com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
"https://115.com/s/{shareKey}?password={pwd}",
P115Tool.class),
// 链接:https://www.yunpan.com/surl_yD7wz4VgU9v(提取码:fc70
@@ -263,7 +297,7 @@ public enum PanDomainTemplate {
// https://pan-yz.cldisk.com/external/m/file/953658049102462976
Pcx("超星云盘(需要referer头)",
compile("https://pan-yz\\.cldisk\\.com/external/m/file/(?<KEY>\\w+)"),
compile("https://pan-yz\\.(chaoxing\\.com|cldisk\\.com)/external/m/file/(?<KEY>\\w+)(\\?.*)?"),
"https://pan-yz.cldisk.com/external/m/file/{shareKey}",
PcxTool.class),
// WPS:分享格式:https://www.kdocs.cn/l/ck0azivLlDi3 API格式:https://www.kdocs.cn/api/office/file/{shareKey}/download
@@ -272,6 +306,27 @@ public enum PanDomainTemplate {
compile("https://(?:[a-zA-Z\\d-]+\\.)?kdocs\\.cn/l/(?<KEY>.+)"),
"https://www.kdocs.cn/l/{shareKey}",
PwpsTool.class),
// https://fast.uc.cn/s/33197dd53ace4
// https://drive.uc.cn/s/e623b6da278e4?public=1#/list/share
UC("UC网盘",
compile("https://(fast|drive)\\.uc\\.cn/s/(?<KEY>\\w+)(\\?public=\\d+)?([&#].*)?"),
"https://drive.uc.cn/s/{shareKey}",
UcTool.class),
// https://pan.quark.cn/s/6a325cdaec58
QK("夸克网盘",
compile("https://pan\\.quark\\.cn/s/(?<KEY>\\w+)([&#].*)?"),
"https://pan.quark.cn/s/{shareKey}",
QkTool.class),
// https://xxx.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc
// https://xxx.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg
FS("飞书云盘",
compile("https://[^.]+\\.feishu\\.cn/(?:file|drive/folder)/(?<KEY>[A-Za-z0-9_-]+)(\\?.*)?"),
"https://feishu.cn/file/{shareKey}",
"https://www.feishu.cn/",
FsTool.class),
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
// http://163cn.tv/xxx
MNES("网易云音乐分享",
@@ -280,7 +335,7 @@ public enum PanDomainTemplate {
MnesTool.class),
// https://music.163.com/#/song?id=xxx
MNE("网易云音乐歌曲详情",
compile("https://(y.)?music\\.163\\.com/(#|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
compile("https://(y\\.)?music\\.163\\.com/(?:#/|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
"https://music.163.com/#/song?id={shareKey}",
MnesTool.MneTool.class),
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
@@ -301,7 +356,7 @@ public enum PanDomainTemplate {
MkgsTool.class),
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
MKGS2("酷狗音乐分享2",
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+).html.*"),
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+)\\.html.*"),
"https://www.kugou.com/share/{shareKey}.html",
MkgsTool.Mkgs2Tool.class),
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
@@ -81,16 +81,16 @@ public class ParserCreate {
if (shareKey != null) {
shareLinkInfo.setShareKey(shareKey);
}
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
// 提取密码
try {
String pwd = matcher.group("PWD");
if (StringUtils.isNotEmpty(pwd)) {
shareLinkInfo.setSharePassword(pwd);
}
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
// 设置标准URL
if (customParserConfig.getStandardUrlTemplate() != null) {
String standardUrl = customParserConfig.getStandardUrlTemplate()
@@ -133,7 +133,7 @@ public class ParserCreate {
shareLinkInfo.setSharePassword(pwd);
}
standardUrl = standardUrl.replace("{pwd}", pwd);
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
shareLinkInfo.setShareUrl(shareUrl);
shareLinkInfo.setShareKey(shareKey);
@@ -266,15 +266,15 @@ public class ParserCreate {
if (shareKey != null) {
shareLinkInfo.setShareKey(shareKey);
}
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
try {
String password = matcher.group("PWD");
if (password != null) {
shareLinkInfo.setSharePassword(password);
}
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
// 设置标准URL(如果有模板)
if (customConfig.getStandardUrlTemplate() != null) {
String standardUrl = customConfig.getStandardUrlTemplate()
@@ -11,6 +11,13 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* 客户端下载链接生成器工厂类
* <p>
* 支持的客户端类型:
* <ul>
* <li>CURL - cURL 命令,支持 Cookie</li>
* <li>ARIA2 - Aria2 命令,支持 Cookie</li>
* <li>THUNDER - 迅雷协议,不支持 Cookie</li>
* </ul>
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
@@ -25,16 +32,10 @@ public class ClientLinkGeneratorFactory {
// 静态初始化块,注册默认的生成器
static {
try {
// 注册默认生成器 - 按指定顺序注册
register(new Aria2LinkGenerator());
register(new MotrixLinkGenerator());
register(new BitCometLinkGenerator());
register(new ThunderLinkGenerator());
register(new WgetLinkGenerator());
register(new CurlLinkGenerator());
register(new IdmLinkGenerator());
register(new FdmLinkGenerator());
register(new PowerShellLinkGenerator());
// 注册默认生成器 - 只保留3种(按需求)
register(new CurlLinkGenerator()); // cURL 命令,支持 Cookie
register(new Aria2LinkGenerator()); // Aria2 命令,支持 Cookie
register(new ThunderLinkGenerator()); // 迅雷协议,不支持 Cookie
log.info("客户端链接生成器工厂初始化完成,已注册 {} 个生成器", generators.size());
} catch (Exception e) {
@@ -2,27 +2,32 @@ package cn.qaiu.parser.clientlink;
/**
* 客户端下载工具类型枚举
* <p>
* 支持的客户端类型:
* <ul>
* <li>CURL - cURL 命令行工具,支持 Cookie</li>
* <li>ARIA2 - 多线程下载器,支持 Cookie</li>
* <li>THUNDER - 迅雷下载器,不支持 Cookie(使用迅雷协议)</li>
* </ul>
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public enum ClientLinkType {
ARIA2("aria2", "Aria2"),
MOTRIX("motrix", "Motrix"),
BITCOMET("bitcomet", "比特彗星"),
THUNDER("thunder", "迅雷"),
WGET("wget", "wget 命令"),
CURL("curl", "cURL 命令"),
IDM("idm", "IDM"),
FDM("fdm", "Free Download Manager"),
POWERSHELL("powershell", "PowerShell");
CURL("curl", "cURL 命令", true, "命令行下载工具,支持Cookie"),
ARIA2("aria2", "Aria2", true, "多线程下载器,支持Cookie"),
THUNDER("thunder", "迅雷", false, "迅雷下载器,不支持Cookie");
private final String code;
private final String displayName;
private final boolean supportsCookie;
private final String description;
ClientLinkType(String code, String displayName) {
ClientLinkType(String code, String displayName, boolean supportsCookie, String description) {
this.code = code;
this.displayName = displayName;
this.supportsCookie = supportsCookie;
this.description = description;
}
public String getCode() {
@@ -33,6 +38,14 @@ public enum ClientLinkType {
return displayName;
}
public boolean isSupportsCookie() {
return supportsCookie;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return displayName;
@@ -7,6 +7,13 @@ import java.util.Map;
/**
* 客户端下载链接生成工具类
* 提供便捷的静态方法来生成各种客户端下载链接
* <p>
* 支持的客户端类型:
* <ul>
* <li>CURL - cURL 命令,支持 Cookie</li>
* <li>ARIA2 - Aria2 命令,支持 Cookie</li>
* <li>THUNDER - 迅雷协议,不支持 Cookie</li>
* </ul>
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
@@ -35,7 +42,7 @@ public class ClientLinkUtils {
}
/**
* 生成 curl 命令
* 生成 curl 命令(支持 Cookie
*
* @param info ShareLinkInfo 对象
* @return curl 命令字符串
@@ -45,17 +52,7 @@ public class ClientLinkUtils {
}
/**
* 生成 wget 命令
*
* @param info ShareLinkInfo 对象
* @return wget 命令字符串
*/
public static String generateWgetCommand(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.WGET);
}
/**
* 生成 aria2 命令
* 生成 aria2 命令(支持 Cookie
*
* @param info ShareLinkInfo 对象
* @return aria2 命令字符串
@@ -65,7 +62,7 @@ public class ClientLinkUtils {
}
/**
* 生成迅雷链接
* 生成迅雷链接(不支持 Cookie
*
* @param info ShareLinkInfo 对象
* @return 迅雷协议链接
@@ -74,56 +71,6 @@ public class ClientLinkUtils {
return generateClientLink(info, ClientLinkType.THUNDER);
}
/**
* 生成 IDM 链接
*
* @param info ShareLinkInfo 对象
* @return IDM 协议链接
*/
public static String generateIdmLink(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.IDM);
}
/**
* 生成比特彗星链接
*
* @param info ShareLinkInfo 对象
* @return 比特彗星协议链接
*/
public static String generateBitCometLink(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.BITCOMET);
}
/**
* 生成 Motrix 导入格式
*
* @param info ShareLinkInfo 对象
* @return Motrix JSON 格式字符串
*/
public static String generateMotrixFormat(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.MOTRIX);
}
/**
* 生成 FDM 导入格式
*
* @param info ShareLinkInfo 对象
* @return FDM 格式字符串
*/
public static String generateFdmFormat(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.FDM);
}
/**
* 生成 PowerShell 命令
*
* @param info ShareLinkInfo 对象
* @return PowerShell 命令字符串
*/
public static String generatePowerShellCommand(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.POWERSHELL);
}
/**
* 检查 ShareLinkInfo 是否包含有效的下载元数据
*
@@ -41,6 +41,8 @@ public class Aria2LinkGenerator implements ClientLinkGenerator {
parts.add("--continue"); // 支持断点续传
parts.add("--max-tries=3"); // 最大重试次数
parts.add("--retry-wait=5"); // 重试等待时间
parts.add("-s 8"); // 分成8片段下载
parts.add("-x 8"); // 每个服务器使用8个连接
// 添加URL
parts.add("\"" + meta.getUrl() + "\"");
@@ -1,69 +0,0 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* 比特彗星协议链接生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class BitCometLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
try {
// 比特彗星支持 HTTP 下载,格式类似 IDM
String encodedUrl = Base64.getEncoder().encodeToString(
meta.getUrl().getBytes(StandardCharsets.UTF_8)
);
StringBuilder link = new StringBuilder("bitcomet:///?url=").append(encodedUrl);
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
StringBuilder headerStr = new StringBuilder();
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
if (headerStr.length() > 0) {
headerStr.append("\\r\\n");
}
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
}
String encodedHeaders = Base64.getEncoder().encodeToString(
headerStr.toString().getBytes(StandardCharsets.UTF_8)
);
link.append("&header=").append(encodedHeaders);
}
// 添加文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
String encodedFileName = Base64.getEncoder().encodeToString(
meta.getFileName().getBytes(StandardCharsets.UTF_8)
);
link.append("&filename=").append(encodedFileName);
}
return link.toString();
} catch (Exception e) {
// 如果编码失败,返回简单的URL
return "bitcomet:///?url=" + meta.getUrl();
}
}
@Override
public ClientLinkType getType() {
return ClientLinkType.BITCOMET;
}
}
@@ -1,56 +0,0 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.util.Map;
/**
* Free Download Manager 导入格式生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class FdmLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
// FDM 支持简单的文本格式导入
StringBuilder result = new StringBuilder();
result.append("URL=").append(meta.getUrl()).append("\n");
// 添加文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
result.append("Filename=").append(meta.getFileName()).append("\n");
}
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
result.append("Headers=");
boolean first = true;
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
if (!first) {
result.append("; ");
}
result.append(entry.getKey()).append(": ").append(entry.getValue());
first = false;
}
result.append("\n");
}
result.append("Referer=").append(meta.getReferer() != null ? meta.getReferer() : "").append("\n");
result.append("User-Agent=").append(meta.getUserAgent() != null ? meta.getUserAgent() : "").append("\n");
return result.toString();
}
@Override
public ClientLinkType getType() {
return ClientLinkType.FDM;
}
}
@@ -1,69 +0,0 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* IDM 协议链接生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class IdmLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
try {
// 对URL进行Base64编码
String encodedUrl = Base64.getEncoder().encodeToString(
meta.getUrl().getBytes(StandardCharsets.UTF_8)
);
StringBuilder link = new StringBuilder("idm:///?url=").append(encodedUrl);
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
StringBuilder headerStr = new StringBuilder();
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
if (headerStr.length() > 0) {
headerStr.append("\\r\\n");
}
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
}
String encodedHeaders = Base64.getEncoder().encodeToString(
headerStr.toString().getBytes(StandardCharsets.UTF_8)
);
link.append("&header=").append(encodedHeaders);
}
// 添加文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
String encodedFileName = Base64.getEncoder().encodeToString(
meta.getFileName().getBytes(StandardCharsets.UTF_8)
);
link.append("&filename=").append(encodedFileName);
}
return link.toString();
} catch (Exception e) {
// 如果编码失败,返回简单的URL
return "idm:///?url=" + meta.getUrl();
}
}
@Override
public ClientLinkType getType() {
return ClientLinkType.IDM;
}
}
@@ -1,53 +0,0 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import io.vertx.core.json.JsonObject;
import java.util.Map;
/**
* Motrix 导入格式生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class MotrixLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
// 使用 Vert.x JsonObject 构建 JSON
JsonObject taskJson = new JsonObject();
taskJson.put("url", meta.getUrl());
// 添加文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
taskJson.put("filename", meta.getFileName());
}
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
JsonObject headersJson = new JsonObject();
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
headersJson.put(entry.getKey(), entry.getValue());
}
taskJson.put("headers", headersJson);
}
// 设置输出文件名
String outputFile = meta.getFileName() != null ? meta.getFileName() : "";
taskJson.put("out", outputFile);
return taskJson.encodePrettily();
}
@Override
public ClientLinkType getType() {
return ClientLinkType.MOTRIX;
}
}
@@ -1,98 +0,0 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* PowerShell 命令生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class PowerShellLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
List<String> lines = new ArrayList<>();
// 创建 WebRequestSession
lines.add("$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession");
// 设置 User-Agent(如果存在)
String userAgent = meta.getUserAgent();
if (userAgent == null && meta.getHeaders() != null) {
userAgent = meta.getHeaders().get("User-Agent");
}
if (userAgent != null && !userAgent.trim().isEmpty()) {
lines.add("$session.UserAgent = \"" + escapePowerShellString(userAgent) + "\"");
}
// 构建 Invoke-WebRequest 命令
List<String> invokeParams = new ArrayList<>();
invokeParams.add("Invoke-WebRequest");
invokeParams.add("-UseBasicParsing");
invokeParams.add("-Uri \"" + escapePowerShellString(meta.getUrl()) + "\"");
// 添加 WebSession
invokeParams.add("-WebSession $session");
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
List<String> headerLines = new ArrayList<>();
headerLines.add("-Headers @{");
boolean first = true;
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
if (!first) {
headerLines.add("");
}
headerLines.add(" \"" + escapePowerShellString(entry.getKey()) + "\"=\"" +
escapePowerShellString(entry.getValue()) + "\"");
first = false;
}
headerLines.add("}");
// 将头部参数添加到主命令中
invokeParams.add(String.join("`\n", headerLines));
}
// 设置输出文件(如果指定了文件名)
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
invokeParams.add("-OutFile \"" + escapePowerShellString(meta.getFileName()) + "\"");
}
// 将所有参数连接起来
String invokeCommand = String.join(" `\n", invokeParams);
lines.add(invokeCommand);
return String.join("\n", lines);
}
/**
* 转义 PowerShell 字符串中的特殊字符
*/
private String escapePowerShellString(String str) {
if (str == null) {
return "";
}
return str.replace("`", "``")
.replace("\"", "`\"")
.replace("$", "`$");
}
@Override
public ClientLinkType getType() {
return ClientLinkType.POWERSHELL;
}
}
@@ -1,51 +0,0 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* wget 命令生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class WgetLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
List<String> parts = new ArrayList<>();
parts.add("wget");
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
parts.add("--header=\"" + entry.getKey() + ": " + entry.getValue() + "\"");
}
}
// 设置输出文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
parts.add("-O");
parts.add("\"" + meta.getFileName() + "\"");
}
// 添加URL
parts.add("\"" + meta.getUrl() + "\"");
return String.join(" \\\n ", parts);
}
@Override
public ClientLinkType getType() {
return ClientLinkType.WGET;
}
}
@@ -61,7 +61,7 @@ public class JsHttpClient {
};
public JsHttpClient() {
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());;
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());
this.clientSession = WebClientSession.create(client);
this.headers = MultiMap.caseInsensitiveMultiMap();
// 设置默认的Accept-Encoding头以支持压缩响应
@@ -244,19 +244,17 @@ public class JsHttpClient {
if (data != null) {
if (data instanceof String) {
request.sendBuffer(Buffer.buffer((String) data));
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
request.sendJson(data);
return request.sendJson(data);
}
} else {
request.send();
return request.send();
}
return request.send();
});
}
@@ -276,19 +274,17 @@ public class JsHttpClient {
if (data != null) {
if (data instanceof String) {
request.sendBuffer(Buffer.buffer((String) data));
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
request.sendJson(data);
return request.sendJson(data);
}
} else {
request.send();
return request.send();
}
return request.send();
});
}
@@ -322,19 +318,17 @@ public class JsHttpClient {
if (data != null) {
if (data instanceof String) {
request.sendBuffer(Buffer.buffer((String) data));
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
request.sendJson(data);
return request.sendJson(data);
}
} else {
request.send();
return request.send();
}
return request.send();
});
}
@@ -540,8 +534,8 @@ public class JsHttpClient {
} else {
promise.fail(result.cause());
}
}).onFailure(Throwable::printStackTrace);
}).onFailure(e -> log.error("HTTP请求失败", e));
// 等待响应完成(使用配置的超时时间)
HttpResponse<Buffer> response = promise.future().toCompletionStage()
.toCompletableFuture()
@@ -683,4 +677,13 @@ public class JsHttpClient {
return buffer.length();
}
}
/**
* 关闭 WebClient 释放连接池资源
*/
public void close() {
if (client != null) {
client.close();
}
}
}
@@ -29,12 +29,13 @@ import java.util.stream.Collectors;
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/10/17
*/
public class JsParserExecutor implements IPanTool {
public class JsParserExecutor implements IPanTool, AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(JsParserExecutor.class);
private static final WorkerExecutor EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
private static volatile WorkerExecutor EXECUTOR;
private static final Object EXECUTOR_LOCK = new Object();
private static String FETCH_RUNTIME_JS = null;
private final CustomParserConfig config;
@@ -146,12 +147,57 @@ public class JsParserExecutor implements IPanTool {
}
}
/**
* 释放资源(ScriptEngine 和 HttpClient),避免内存泄漏
*/
@Override
public void close() {
if (httpClient != null) {
httpClient.close();
}
// 清除 ScriptEngine 持有的 Java 对象引用,帮助 GC 回收
if (engine != null) {
engine.put("http", null);
engine.put("logger", null);
engine.put("shareLinkInfo", null);
engine.put("JavaFetch", null);
}
}
/**
* 关闭全局 WorkerExecutor(应在应用关闭时调用)
*/
public static void shutdownExecutor() {
synchronized (EXECUTOR_LOCK) {
if (EXECUTOR != null) {
EXECUTOR.close();
EXECUTOR = null;
log.info("JsParserExecutor WorkerExecutor 已关闭");
}
}
}
/**
* 获取或创建 WorkerExecutor(懒加载)
*/
private static WorkerExecutor getExecutor() {
if (EXECUTOR != null) {
return EXECUTOR;
}
synchronized (EXECUTOR_LOCK) {
if (EXECUTOR == null) {
EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
}
return EXECUTOR;
}
}
@Override
public Future<String> parse() {
jsLogger.info("开始执行JavaScript解析器: {}", config.getType());
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
return EXECUTOR.executeBlocking(() -> {
return getExecutor().executeBlocking(() -> {
// 直接调用全局parse函数
Object parseFunction = engine.get("parse");
if (parseFunction == null) {
@@ -173,7 +219,7 @@ public class JsParserExecutor implements IPanTool {
} else {
throw new RuntimeException("parse函数类型错误");
}
});
}).onComplete(ar -> close());
}
@Override
@@ -181,7 +227,7 @@ public class JsParserExecutor implements IPanTool {
jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType());
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
return EXECUTOR.executeBlocking(() -> {
return getExecutor().executeBlocking(() -> {
// 直接调用全局parseFileList函数
Object parseFileListFunction = engine.get("parseFileList");
if (parseFileListFunction == null) {
@@ -206,7 +252,7 @@ public class JsParserExecutor implements IPanTool {
} else {
throw new RuntimeException("parseFileList函数类型错误");
}
});
}).onComplete(ar -> close());
}
@Override
@@ -214,7 +260,7 @@ public class JsParserExecutor implements IPanTool {
jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType());
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
return EXECUTOR.executeBlocking(() -> {
return getExecutor().executeBlocking(() -> {
// 直接调用全局parseById函数
Object parseByIdFunction = engine.get("parseById");
if (parseByIdFunction == null) {
@@ -237,7 +283,7 @@ public class JsParserExecutor implements IPanTool {
} else {
throw new RuntimeException("parseById函数类型错误");
}
});
}).onComplete(ar -> close());
}
/**
@@ -111,7 +111,7 @@ public class JsPlaygroundExecutor {
playgroundLogger.infoJava("✅ Fetch API和Promise polyfill注入成功");
}
playgroundLogger.infoJava("🔒 安全的JavaScript引擎初始化成功(演练场)");
playgroundLogger.infoJava("初始化成功");
// 执行JavaScript代码
engine.eval(jsCode);
@@ -355,7 +355,7 @@ public class JsPlaygroundExecutor {
*/
public List<JsPlaygroundLogger.LogEntry> getLogs() {
List<JsPlaygroundLogger.LogEntry> logs = playgroundLogger.getLogs();
System.out.println("[JsPlaygroundExecutor] 获取日志,数量: " + logs.size());
log.debug("获取日志,数量: {}", logs.size());
return logs;
}
@@ -4,6 +4,9 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 演练场日志收集器
* 收集JavaScript执行过程中的日志信息
@@ -12,8 +15,11 @@ import java.util.List;
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class JsPlaygroundLogger {
private static final Logger log = LoggerFactory.getLogger(JsPlaygroundLogger.class);
// 使用线程安全的列表
private static final int MAX_LOG_SIZE = 1000;
private final List<LogEntry> logs = Collections.synchronizedList(new ArrayList<>());
/**
@@ -59,6 +65,18 @@ public class JsPlaygroundLogger {
return obj.toString();
}
/**
* 添加日志条目超过最大容量时移除最早的条目
*/
private void addLog(LogEntry entry) {
synchronized (logs) {
if (logs.size() >= MAX_LOG_SIZE) {
logs.remove(0);
}
logs.add(entry);
}
}
/**
* 记录日志内部方法
* @param level 日志级别
@@ -67,8 +85,8 @@ public class JsPlaygroundLogger {
*/
private void log(String level, Object message, String source) {
String msg = toString(message);
logs.add(new LogEntry(level, msg, source));
System.out.println("[" + source + "PlaygroundLogger] " + level + ": " + msg);
addLog(new LogEntry(level, msg, source));
log.debug("[{}PlaygroundLogger] {}: {}", source, level, msg);
}
/**
@@ -111,8 +129,8 @@ public class JsPlaygroundLogger {
if (throwable != null) {
msg = msg + ": " + throwable.getMessage();
}
logs.add(new LogEntry("ERROR", msg, "JS"));
System.out.println("[JSPlaygroundLogger] ERROR: " + msg);
addLog(new LogEntry("ERROR", msg, "JS"));
log.debug("[JSPlaygroundLogger] ERROR: {}", msg);
}
// ===== 以下是供Java层调用的内部方法 =====
@@ -153,8 +171,8 @@ public class JsPlaygroundLogger {
if (throwable != null) {
msg = msg + ": " + throwable.getMessage();
}
logs.add(new LogEntry("ERROR", msg, "JAVA"));
System.out.println("[JAVAPlaygroundLogger] ERROR: " + msg);
addLog(new LogEntry("ERROR", msg, "JAVA"));
log.debug("[JAVAPlaygroundLogger] ERROR: {}", msg);
}
/**
@@ -139,21 +139,20 @@ public class JsScriptLoader {
try {
String jarPath = jarUrl.getPath().substring(5, jarUrl.getPath().indexOf("!"));
JarFile jarFile = new JarFile(jarPath);
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
if (entryName.startsWith(RESOURCE_PATH + "/") &&
entryName.endsWith(".js") &&
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
resourceFiles.add(entryName);
try (JarFile jarFile = new JarFile(jarPath)) {
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
if (entryName.startsWith(RESOURCE_PATH + "/") &&
entryName.endsWith(".js") &&
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
resourceFiles.add(entryName);
}
}
}
jarFile.close();
} catch (Exception e) {
log.debug("解析JAR包资源文件失败", e);
}
@@ -1,16 +1,23 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.RandomStringGenerator;
import cn.qaiu.util.FileSizeConverter;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.uritemplate.UriTemplate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* <a href="https://www.ctfile.com">诚通网盘</a>
@@ -18,19 +25,34 @@ import java.util.Map;
public class CtTool extends PanBase {
private static final String API_URL_PREFIX = "https://webapi.ctfile.com";
// https://webapi.ctfile.com/getfile.php?path=f&f=55050874-1246660795-6464f6&
// passcode=7548&token=30wiijxs1fzhb6brw0p9m6&r=0.5885881231735761&
// ref=&url=https%3A%2F%2F474b.com%2Ff%2F55050874-1246660795-6464f6%3Fp%3D7548
// https://webapi.ctfile.com/getfile.php?path=f&f=64115194-17569800420720-06c697&
// passcode=7609&r=0.6611183001986635&ref=&url=https%3A%2F%2Furl94.ctfile.com%2Ff%2F64115194-17569800420720-06c697%3Fp%3D7609
private static final String API1 = API_URL_PREFIX + "/getfile.php?path={path}" +
"&f={shareKey}&passcode={pwd}&token={token}&r={rand}&ref=";
"&f={shareKey}&passcode={pwd}&r={rand}&ref=&url={url}";
//https://webapi.ctfile.com/get_file_url.php?uid=55050874&fid=1246660795&folder_id=0&
// file_chk=054bc20461f5c63ff82015b9e69fb7fc&mb=1&token=30wiijxs1fzhb6brw0p9m6&app=0&
// acheck=1&verifycode=&rd=0.965929071503574
// https://webapi.ctfile.com/get_file_url.php?uid=64115194&fid=17569800420720&folder_id=0&
// share_id=&file_chk=af5c8757a49cbc69a557eb3da59b246c&start_time=1780471868&wait_seconds=0&
// mb=0&app=0&acheck=1&verifycode=1780471868.2951fe63abedf36ec02f34ed5711ce70&rd=0.36350981353622636
private static final String API2 = API_URL_PREFIX + "/get_file_url.php?" +
"uid={uid}&fid={fid}&folder_id=0&file_chk={file_chk}&mb=0&token={token}&app=0&acheck=0&verifycode=" +
"&rd={rand}";
"uid={uid}&fid={fid}&folder_id=0&share_id=&file_chk={file_chk}" +
"&start_time={start_time}&wait_seconds={wait_seconds}&mb=0&app=0&acheck=1" +
"&verifycode={verifycode}&rd={rand}";
// https://webapi.ctfile.com/getdir.php?path=d&d=64115194-164803691-48508c&
// folder_id=164803691&fk=decb36&passcode=7609&r=0.23...&ref=&url=https://url94.ctfile.com/d/...
private static final String API_GETDIR = API_URL_PREFIX + "/getdir.php?path={path}" +
"&d={shareKey}&folder_id={folder_id}&fk={fk}&passcode={pwd}&r={rand}&ref=&url={url}";
// DataTables参数用于获取目录文件列表
private static final String FILE_LIST_PARAMS = "&sEcho=1&iColumns=4&sColumns=%2C%2C%2C" +
"&iDisplayStart=0&iDisplayLength=500&mDataProp_0=0&mDataProp_1=1&mDataProp_2=2&mDataProp_3=3" +
"&iSortCol_0=3&sSortDir_0=desc&iSortingCols=1";
// 文件列表HTML解析正则
private static final Pattern FILE_ID_PATTERN = Pattern.compile("value=\"f(\\d+)\"");
private static final Pattern FILE_HREF_PATTERN = Pattern.compile("href=\"#/f/([^\"]+)\"");
private static final Pattern FILE_NAME_PATTERN = Pattern.compile(">([^<]+)</a>");
private static final Pattern FILE_ICON_PATTERN = Pattern.compile("alt=\"([^\"]+)\"");
/**
* 子类重写此构造方法不需要添加额外逻辑
@@ -57,7 +79,6 @@ public class CtTool extends PanBase {
}
String[] split = shareKey.split("-");
String uid = split[0], fid = split[1];
String token = RandomStringGenerator.generateRandomString();
// 获取url path
int i1 = shareLinkInfo.getShareUrl().indexOf("com/");
int i2 = shareLinkInfo.getShareUrl().lastIndexOf("/");
@@ -67,8 +88,8 @@ public class CtTool extends PanBase {
.setTemplateParam("path", path)
.setTemplateParam("shareKey", shareKey)
.setTemplateParam("pwd", shareLinkInfo.getSharePassword())
.setTemplateParam("token", token)
.setTemplateParam("r", Math.random() + "");
.setTemplateParam("rand", String.valueOf(Math.random()))
.setTemplateParam("url", shareLinkInfo.getShareUrl());
bufferHttpRequest1
.send().onSuccess(res -> {
@@ -77,23 +98,40 @@ public class CtTool extends PanBase {
var fileJson = resJson.getJsonObject("file");
if (fileJson.containsKey("file_chk")) {
var file_chk = fileJson.getString("file_chk");
String startTime = fileJson.getValue("start_time").toString();
String waitSeconds = fileJson.getValue("wait_seconds").toString();
String verifycode = fileJson.getString("verifycode");
// 提取文件信息并存储
FileInfo fileInfo = new FileInfo()
.setFileName(fileJson.getString("file_name"))
.setFileId(String.valueOf(fileJson.getLong("file_id", 0L)))
.setSizeStr(fileJson.getString("file_size"))
.setCreateTime(fileJson.getString("file_time"))
.setCreateBy(fileJson.getString("username"))
.setFileType("file")
.setPanType(shareLinkInfo.getType());
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
HttpRequest<Buffer> bufferHttpRequest2 = clientSession.getAbs(UriTemplate.of(API2))
.setTemplateParam("uid", uid)
.setTemplateParam("fid", fid)
.setTemplateParam("file_chk", file_chk)
.setTemplateParam("token", token)
.setTemplateParam("rd", Math.random() + "");
.setTemplateParam("start_time", startTime)
.setTemplateParam("wait_seconds", waitSeconds)
.setTemplateParam("verifycode", verifycode)
.setTemplateParam("rand", String.valueOf(Math.random()));
bufferHttpRequest2
.send().onSuccess(res2 -> {
JsonObject resJson2 = asJson(res2);
if (resJson2.containsKey("downurl")) {
String downloadUrl = resJson2.getString("downurl");
// 存储下载元数据包括必要的请求头
Map<String, String> headers = new HashMap<>();
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
headers.put("Referer", shareLinkInfo.getShareUrl());
// 使用新的 completeWithMeta 方法
completeWithMeta(downloadUrl, headers);
} else {
@@ -109,4 +147,132 @@ public class CtTool extends PanBase {
}).onFailure(handleFail(bufferHttpRequest1.queryParams().toString()));
return promise.future();
}
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> listPromise = Promise.promise();
final String shareKey = shareLinkInfo.getShareKey();
final String shareUrl = shareLinkInfo.getShareUrl();
final String pwd = shareLinkInfo.getSharePassword();
// shareKey格式: uid-folder_id-hash (例如 64115194-164803691-48508c)
String[] split = shareKey.split("-");
if (split.length < 2) {
listPromise.fail(baseMsg() + " shareKey格式不正确: " + shareKey);
return listPromise.future();
}
String folderId = split[1];
// 从分享URL中提取fk参数
String fk = extractQueryParam(shareUrl, "fk");
// 从URL中提取path (例如从 "https://url94.ctfile.com/d/xxx?p=..." 中提取 "d")
int comIdx = shareUrl.indexOf("com/");
int qIdx = shareUrl.indexOf('?');
String pathAndKey = qIdx > 0 ? shareUrl.substring(comIdx + 4, qIdx) : shareUrl.substring(comIdx + 4);
int slashIdx = pathAndKey.indexOf('/');
String path = slashIdx > 0 ? pathAndKey.substring(0, slashIdx) : pathAndKey;
clientSession.getAbs(UriTemplate.of(API_GETDIR))
.setTemplateParam("path", path)
.setTemplateParam("shareKey", shareKey)
.setTemplateParam("folder_id", folderId)
.setTemplateParam("fk", fk != null ? fk : "")
.setTemplateParam("pwd", pwd != null ? pwd : "")
.setTemplateParam("rand", String.valueOf(Math.random()))
.setTemplateParam("url", shareUrl)
.send().onSuccess(res -> {
var resJson = asJson(res);
if (!resJson.containsKey("file")) {
listPromise.fail(baseMsg() + " 目录解析失败: " + resJson.encode());
return;
}
var dirInfo = resJson.getJsonObject("file");
String fileListRelUrl = dirInfo.getString("url");
if (fileListRelUrl == null) {
listPromise.fail(baseMsg() + " 文件列表URL为空");
return;
}
String fileListUrl = API_URL_PREFIX + fileListRelUrl + FILE_LIST_PARAMS;
clientSession.getAbs(fileListUrl)
.send().onSuccess(res2 -> {
var listJson = asJson(res2);
JsonArray aaData = listJson.getJsonArray("aaData");
if (aaData == null) {
listPromise.fail(baseMsg() + " 文件列表为空");
return;
}
List<FileInfo> fileList = new ArrayList<>();
String panType = shareLinkInfo.getType();
for (int i = 0; i < aaData.size(); i++) {
var row = aaData.getJsonArray(i);
try {
String checkboxHtml = row.getString(0);
String nameCellHtml = row.getString(1);
String sizeStr = row.getString(2).trim();
// 从checkbox HTML中提取文件ID
String fileId = null;
Matcher idMatcher = FILE_ID_PATTERN.matcher(checkboxHtml);
if (idMatcher.find()) fileId = idMatcher.group(1);
// 从文件名单元格HTML中提取临时分享key
String fileShareKey = null;
Matcher hrefMatcher = FILE_HREF_PATTERN.matcher(nameCellHtml);
if (hrefMatcher.find()) fileShareKey = hrefMatcher.group(1);
// 提取文件名
String fileName = null;
Matcher nameMatcher = FILE_NAME_PATTERN.matcher(nameCellHtml);
if (nameMatcher.find()) fileName = nameMatcher.group(1).trim();
// 提取文件图标/类型
String fileIcon = null;
Matcher iconMatcher = FILE_ICON_PATTERN.matcher(nameCellHtml);
if (iconMatcher.find()) fileIcon = iconMatcher.group(1);
if (fileName == null || fileShareKey == null) continue;
long sizeBytes = 0;
try {
sizeBytes = FileSizeConverter.convertToBytes(sizeStr);
} catch (Exception ignored) {}
FileInfo fileInfo = new FileInfo()
.setFileName(fileName)
.setFileId(fileId)
.setSizeStr(sizeStr)
.setSize(sizeBytes)
.setFileType(fileIcon)
.setFileIcon(fileIcon)
.setPanType(panType)
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), panType, fileShareKey));
fileList.add(fileInfo);
} catch (Exception e) {
log.warn("解析文件行失败: {}", e.getMessage());
}
}
listPromise.complete(fileList);
}).onFailure(listPromise::fail);
}).onFailure(listPromise::fail);
return listPromise.future();
}
private String extractQueryParam(String url, String paramName) {
if (url == null) return null;
int qIdx = url.indexOf('?');
if (qIdx < 0) return null;
String query = url.substring(qIdx + 1);
for (String param : query.split("&")) {
int eqIdx = param.indexOf('=');
if (eqIdx > 0 && param.substring(0, eqIdx).equals(paramName)) {
return param.substring(eqIdx + 1);
}
}
return null;
}
}
@@ -3,9 +3,7 @@ package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.AESUtils;
import cn.qaiu.util.FileSizeConverter;
import cn.qaiu.util.UUIDUtil;
import cn.qaiu.util.*;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
@@ -13,12 +11,14 @@ import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.uritemplate.UriTemplate;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
/**
* 小飞机网盘
@@ -26,69 +26,113 @@ import java.util.List;
* @version V016_230609
*/
public class FjTool extends PanBase {
public static final String REFERER_URL = "https://share.feijipan.com/";
private static final String API_URL_PREFIX = "https://api.feejii.com/ws/";
public static final String API_URL0 = "https://api.feijipan.com";
private static final String API_URL_PREFIX = "https://api.feijipan.com/ws/";
private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" +
"&uuid={uuid}&extra=2&timestamp={ts}&shareId={shareId}&type=0&offset=1&limit=60";
/// recommend/list?devType=6&devModel=Chrome&uuid={uuid}&extra=2&timestamp={ts}&shareId={shareId}&type=0&offset=1&limit=60
// recommend/list?devType=6&devModel=Chrome&uuid={uuid}&extra=2&timestamp={ts}&shareId=JoUTkZYj&type=0&offset=1&limit=60
private static final String LOGIN_URL = API_URL_PREFIX +
"login?uuid={uuid}&devType=6&devCode={uuid}&devModel=chrome&devVersion=127&appVersion=&timestamp={ts}&appToken=&extra=2";
private static final String TOKEN_VERIFY_URL = API_URL0 +
"/app/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2&timestamp={ts}";
private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" +
"&devType=6&uuid={uuid}&timestamp={ts}&auth={auth}&shareId={dataKey}";
// https://api.feijipan.com/ws/file/redirect?downloadId={fidEncode}&enable=1&devType=6&uuid={uuid}&timestamp={ts}&auth={auth}&shareId={dataKey}
//https://api.feijipan.com/ws/file/redirect?
// downloadId=DBD34FFEDB71708FA5C284527F78E9EC104A9667FFEEA62CB6E00B54A3E0F5BB
// &enable=1
// &devType=6
// &uuid=rTaNVSgmwY5MbEEuiMmQL
// &timestamp=839E6B5E19223B8DF730A52F44062D48
// &auth=F799422BCD9D05D7CCC5C9C53C1092C7029B420536135C3B4B7E064F49459DCC
// &shareId=4wF7grHR
private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX +
"file/redirect?downloadId={fidEncode}&enable=1&devType=6&uuid={uuid}&timestamp={ts}&auth={auth}&shareId={dataKey}";
private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" +
"={uuid}&extra=2&timestamp={ts}";
// https://api.feijipan.com/ws/buy/vip/list?devType=6&devModel=Chrome&uuid=WQAl5yBy1naGudJEILBvE&extra=2&timestamp=E2C53155F6D09417A27981561134CB73
// https://api.feijipan.com/ws/share/list?devType=6&devModel=Chrome&uuid=pwRWqwbk1J-KMTlRZowrn&extra=2&timestamp=C5F8A68C53121AB21FA35BA3529E8758&shareId=fmAuOh3m&folderId=28986333&offset=1&limit=60
private static final String FILE_LIST_URL = API_URL_PREFIX + "/share/list?devType=6&devModel=Chrome&uuid" +
"={uuid}&extra=2&timestamp={ts}&shareId={shareId}&folderId" +
"={folderId}&offset=1&limit=60";
private static final MultiMap header;
private static final MultiMap header0;
long nowTs = System.currentTimeMillis();
String tsEncode = AESUtils.encrypt2Hex(Long.toString(nowTs));
String uuid = UUIDUtil.fjUuid(); // 也可以使用 UUID.randomUUID().toString()
static {
header = MultiMap.caseInsensitiveMultiMap();
header.set("Accept", "application/json, text/plain, */*");
header.set("Accept-Encoding", "gzip, deflate, br, zstd");
header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
header.set("Cache-Control", "no-cache");
header.set("Connection", "keep-alive");
header.set("Content-Length", "0");
header.set("DNT", "1");
header.set("Host", "api.feijipan.com");
header.set("Origin", "https://www.feijix.com");
header.set("Pragma", "no-cache");
header.set("Referer", "https://www.feijix.com/");
header.set("Sec-Fetch-Dest", "empty");
header.set("Sec-Fetch-Mode", "cors");
header.set("Sec-Fetch-Site", "cross-site");
header.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
header.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"");
header.set("sec-ch-ua-mobile", "?0");
header.set("sec-ch-ua-platform", "\"Windows\"");
header0 = MultiMap.caseInsensitiveMultiMap();
header0.set("Accept-Encoding", "gzip, deflate");
header0.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
header0.set("Cache-Control", "no-cache");
header0.set("Connection", "keep-alive");
header0.set("Content-Length", "0");
header0.set("DNT", "1");
header0.set("Pragma", "no-cache");
header0.set("Referer", "https://www.feijipan.com/");
header0.set("Sec-Fetch-Dest", "empty");
header0.set("Sec-Fetch-Mode", "cors");
header0.set("Sec-Fetch-Site", "cross-site");
header0.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
header0.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"");
header0.set("sec-ch-ua-mobile", "?0");
header0.set("sec-ch-ua-platform", "\"Windows\"");
header = HeaderUtils.parseHeaders("""
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Cache-Control: no-cache
Connection: keep-alive
DNT: 1
Pragma: no-cache
Referer: https://www.feijix.com/
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0
sec-ch-ua: "Microsoft Edge";v="135", "Not-A.Brand";v="8", "Chromium";v="135"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
""");
}
// String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
static volatile String token = null;
static volatile String userId = null;
public static volatile boolean authFlag = true;
public FjTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@Override
public Future<String> parse() {
// 240530 此处shareId又改为了原始的shareId
// String.valueOf(AESUtils.idEncrypt(dataKey));
final String shareId = shareLinkInfo.getShareKey();
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
long nowTs = System.currentTimeMillis();
String tsEncode = AESUtils.encrypt2Hex(Long.toString(nowTs));
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
// 获取用户id
if (auths.contains("userId")) {
FjTool.userId = auths.get("userId");
log.info("已配置用户ID: {}", FjTool.userId);
} else {
log.warn("未配置用户ID, 可能会导致解析失败");
}
}
// 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
@@ -98,120 +142,370 @@ public class FjTool extends PanBase {
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.send().onSuccess(r0 -> { // 忽略res
// 第一次请求 获取文件信息
// POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60
client.postAbs(UriTemplate.of(url))
.putHeaders(header)
.putHeaders(header0)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.send().onSuccess(res -> {
JsonObject resJson = asJson(res);
if (resJson.getInteger("code") != 200) {
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
JsonObject resJson;
try {
resJson = asJson(res);
} catch (Exception e) {
log.error("获取文件信息失败: {}", res.bodyAsString());
return;
}
if (resJson.getJsonArray("list").isEmpty()) {
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
if (resJson.getInteger("code") != 200) {
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
return;
}
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
return;
}
// 文件Id
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
// 如果是目录返回目录ID
if (!fileInfo.containsKey("fileList") || fileInfo.getJsonArray("fileList").isEmpty()) {
fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo);
return;
}
JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0);
if (fileList.getInteger("fileType") == 2) {
promise.complete(fileList.getInteger("folderId").toString());
return;
}
// 提取文件信息
extractFileInfo(fileList, fileInfo);
getDownURL(resJson);
}).onFailure(handleFail("请求1"));
String fileId = fileInfo.getString("fileIds");
String userId = fileInfo.getString("userId");
// 其他参数
long nowTs2 = System.currentTimeMillis();
String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2));
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + userId);
String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2);
// 第二次请求
HttpRequest<Buffer> httpRequest =
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", shareId);
// System.out.println(httpRequest.toString());
httpRequest.send().onSuccess(res2 -> {
MultiMap headers = res2.headers();
if (!headers.contains("Location")) {
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res.headers());
return;
}
promise.complete(headers.get("Location"));
}).onFailure(handleFail(SECOND_REQUEST_URL));
}).onFailure(handleFail(FIRST_REQUEST_URL));
}).onFailure(handleFail(FIRST_REQUEST_URL));
}).onFailure(handleFail("请求1"));
return promise.future();
}
private void getDownURL(JsonObject resJson) {
String dataKey = shareLinkInfo.getShareKey();
// 文件Id
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
String fileId = fileInfo.getString("fileIds");
String userId = fileInfo.getString("userId");
// 其他参数
long nowTs2 = System.currentTimeMillis();
String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2));
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId);
String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2);
// 检查是否有认证信息
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
// 检查是否为临时认证临时认证每次都尝试登录
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
// 如果是临时认证或者是后台配置且authFlag为true则尝试使用认证
if (isTempAuth || authFlag) {
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
HttpRequest<Buffer> httpRequest =
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey)
;
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (token == null) {
// 执行登录
login(tsEncode2, auths).onFailure(failRes-> {
log.warn("登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest.setTemplateParam("fidEncode", AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId))
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
// 验证token
client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.putHeaders(header0).send().onSuccess(res -> {
if (asJson(res).getInteger("code") != 200) {
login(tsEncode2, auths).onFailure(failRes -> {
log.warn("重新登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest
.setTemplateParam("fidEncode", fidEncode)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
httpRequest
.setTemplateParam("fidEncode", AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId))
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
}
}).onFailure(handleFail("Token验证"));
}
} else {
// authFlag false使用免登录解析
log.debug("authFlag=false,使用免登录解析");
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
}
} else {
// 没有认证信息使用免登录解析
log.debug("无认证信息,使用免登录解析");
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
}
}
private Future<Void> login(String tsEncode2, MultiMap auths) {
Promise<Void> promise1 = Promise.promise();
// 如果配置了用户ID 则不登录
if (FjTool.userId != null) {
promise1.complete();
return promise1.future();
}
client.postAbs(UriTemplate.of(LOGIN_URL))
.setTemplateParam("uuid",uuid)
.setTemplateParam("ts", tsEncode2)
.putHeaders(header0)
.sendJsonObject(JsonObject.of("loginName", auths.get("username"), "loginPwd", auths.get("password")))
.onSuccess(res2->{
JsonObject json = asJson(res2);
if (json.getInteger("code") == 200) {
token = json.getJsonObject("data").getString("appToken");
MultiMap h0 = MultiMap.caseInsensitiveMultiMap();
h0.addAll(header0);
h0.set("appToken", token);
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.putHeaders(h0).send().onSuccess(res -> {
if (asJson(res).getInteger("code") == 200) {
if (FjTool.userId == null) {
FjTool.userId = asJson(res).getJsonObject("map").getString("userId");
}
log.info("验证成功 userId: {}", FjTool.userId);
promise1.complete();
} else {
promise1.fail("验证失败: " + res.bodyAsString());
}
});
} else {
// 检查是否为临时认证
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
if (isTempAuth) {
// 临时认证失败直接返回错误不影响后台配置的认证
log.warn("临时认证失败: {}", json.getString("msg"));
promise1.fail("临时认证失败: " + json.getString("msg"));
} else {
// 后台配置的认证失败设置authFlag并返回失败让下次请求使用免登陆解析
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
authFlag = false;
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
}
}
}).onFailure(err -> {
log.error("登录请求异常: {}", err.getMessage());
promise1.fail("登录请求异常: " + err.getMessage());
});
return promise1.future();
}
/**
* 从接口返回数据中提取文件信息
*/
private void extractFileInfo(JsonObject fileList, JsonObject shareInfo) {
try {
// 文件名
String fileName = fileList.getString("fileName");
shareLinkInfo.getOtherParam().put("fileName", fileName);
// 文件大小 (KB -> Bytes)
Long fileSize = fileList.getLong("fileSize", 0L) * 1024;
shareLinkInfo.getOtherParam().put("fileSize", fileSize);
shareLinkInfo.getOtherParam().put("fileSizeFormat", FileSizeConverter.convertToReadableSize(fileSize));
// 文件图标
String fileIcon = fileList.getString("fileIcon");
if (StringUtils.isNotBlank(fileIcon)) {
shareLinkInfo.getOtherParam().put("fileIcon", fileIcon);
}
// 文件ID
Long fileId = fileList.getLong("fileId");
if (fileId != null) {
shareLinkInfo.getOtherParam().put("fileId", fileId.toString());
}
// 文件类型 (1=文件, 2=目录)
Integer fileType = fileList.getInteger("fileType", 1);
shareLinkInfo.getOtherParam().put("fileType", fileType == 1 ? "file" : "folder");
// 下载次数
Integer downloads = fileList.getInteger("fileDownloads", 0);
shareLinkInfo.getOtherParam().put("downloadCount", downloads);
// 点赞数
Integer likes = fileList.getInteger("fileLikes", 0);
shareLinkInfo.getOtherParam().put("likeCount", likes);
// 评论数
Integer comments = fileList.getInteger("fileComments", 0);
shareLinkInfo.getOtherParam().put("commentCount", comments);
// 评分
Double stars = fileList.getDouble("fileStars", 0.0);
shareLinkInfo.getOtherParam().put("stars", stars);
// 更新时间
String updateTime = fileList.getString("updTime");
if (StringUtils.isNotBlank(updateTime)) {
shareLinkInfo.getOtherParam().put("updateTime", updateTime);
}
// 创建时间
String createTime = null;
// 分享信息
if (shareInfo != null) {
// 分享ID
Integer shareId = shareInfo.getInteger("shareId");
if (shareId != null) {
shareLinkInfo.getOtherParam().put("shareId", shareId.toString());
}
// 上传时间
String addTime = shareInfo.getString("addTime");
if (StringUtils.isNotBlank(addTime)) {
shareLinkInfo.getOtherParam().put("createTime", addTime);
createTime = addTime;
}
// 预览次数
Integer previewNum = shareInfo.getInteger("previewNum", 0);
shareLinkInfo.getOtherParam().put("previewCount", previewNum);
// 用户信息
JsonObject userMap = shareInfo.getJsonObject("map");
if (userMap != null) {
String userName = userMap.getString("userName");
if (StringUtils.isNotBlank(userName)) {
shareLinkInfo.getOtherParam().put("userName", userName);
}
// VIP信息
Integer isVip = userMap.getInteger("isVip", 0);
shareLinkInfo.getOtherParam().put("isVip", isVip == 1);
}
}
// 创建 FileInfo 对象并存入 otherParam
FileInfo fileInfoObj = new FileInfo()
.setPanType(shareLinkInfo.getType())
.setFileName(fileName)
.setFileId(fileId != null ? fileId.toString() : null)
.setSize(fileSize)
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
.setFileType(fileType == 1 ? "file" : "folder")
.setFileIcon(fileIcon)
.setDownloadCount(downloads)
.setCreateTime(createTime)
.setUpdateTime(updateTime);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfoObj);
log.debug("提取文件信息成功: fileName={}, fileSize={}, downloads={}",
fileName, fileSize, downloads);
} catch (Exception e) {
log.warn("提取文件信息失败: {}", e.getMessage());
}
}
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
if (!headers.contains("Location") || headers.get("Location") == null) {
fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误: " + res2.bodyAsString());
return;
}
promise.complete(headers.get("Location"));
}
// 目录解析
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> promise = Promise.promise();
Promise<List<FileInfo>> promise0 = Promise.promise();
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
// 如果参数里的目录ID不为空则直接解析目录
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
if (dirId != null && !dirId.isEmpty()) {
uuid = shareLinkInfo.getOtherParam().get("uuid").toString();
parserDir(dirId, shareId, promise);
return promise.future();
Object uuidObj = shareLinkInfo.getOtherParam().get("uuid");
if (uuidObj != null) {
uuid = uuidObj.toString();
}
parserDir(dirId, shareId, promise0);
return promise0.future();
}
parse().onSuccess(id -> {
if (id != null && id.matches("^[a-zA-Z0-9]+$")) {
parserDir(id, shareId, promise);
} else {
promise.fail("解析目录ID失败");
}
parserDir(id, shareId, promise0);
}).onFailure(failRes -> {
log.error("解析目录失败: {}", failRes.getMessage());
promise.fail(failRes);
promise0.fail(failRes);
});
return promise.future();
return promise0.future();
}
private void parserDir(String id, String shareId, Promise<List<FileInfo>> promise) {
// id以http开头直接返回 封装数组返回
if (id != null && (id.startsWith("http://") || id.startsWith("https://"))) {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(id)
.setFileId(id)
.setFileType("file")
.setParserUrl(id)
.setPanType(shareLinkInfo.getType());
List<FileInfo> result = new ArrayList<>();
result.add(fileInfo);
promise.complete(result);
return;
}
log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode);
// 开始解析目录: 164312216, shareId: bPMsbg5K, uuid: 0fmVWTx2Ea4zFwkpd7KXf, ts: 20865d7b7f00828279f437cd1f097860
// 拿到目录ID
client.postAbs(UriTemplate.of(FILE_LIST_URL))
.putHeaders(header)
.putHeaders(header0)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.setTemplateParam("folderId", id)
.send().onSuccess(res -> {
JsonObject jsonObject;
JsonArray list;
try {
jsonObject = asJson(res);
JsonObject jsonObject = asJson(res);
log.debug("目录列表: {}", jsonObject.encodePrettily());
list = jsonObject.getJsonArray("list");
} catch (Exception e) {
promise.fail(FIRST_REQUEST_URL + " 解析JSON失败: " + res.bodyAsString());
log.error("解析目录失败: {}", res.bodyAsString());
return;
}
// System.out.println(jsonObject.encodePrettily());
JsonArray list = jsonObject.getJsonArray("list");
ArrayList<FileInfo> result = new ArrayList<>();
list.forEach(item->{
JsonObject fileJson = (JsonObject) item;
@@ -224,7 +518,7 @@ public class FjTool extends PanBase {
// 其他参数
long nowTs2 = System.currentTimeMillis();
String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2));
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + userId);
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId);
String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2);
// 回传用到的参数
@@ -239,8 +533,7 @@ public class FjTool extends PanBase {
"ts", tsEncode2,
"auth", auth,
"shareId", shareId);
byte[] encode = Base64.getEncoder().encode(entries.encode().getBytes());
String param = new String(encode);
String param = CommonUtils.urlBase64Encode(entries.encode());
if (fileJson.getInteger("fileType") == 2) {
// 如果是目录
@@ -280,17 +573,19 @@ public class FjTool extends PanBase {
result.add(fileInfo);
});
promise.complete(result);
}).onFailure(failRes -> {
log.error("解析目录请求失败: {}", failRes.getMessage());
promise.fail(failRes);
});;
});
}
@Override
public Future<String> parseById() {
// 第二次请求
JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson");
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
if (paramJson == null) {
promise.fail("缺少 paramJson 参数");
return promise.future();
}
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
.setTemplateParam("uuid", paramJson.getString("uuid"))
.setTemplateParam("ts", paramJson.getString("ts"))
@@ -299,11 +594,16 @@ public class FjTool extends PanBase {
.putHeaders(header).send().onSuccess(res2 -> {
MultiMap headers = res2.headers();
if (!headers.contains("Location")) {
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res2.headers());
fail(SECOND_REQUEST_URL_VIP + " 未找到重定向URL: \n" + res2.headers());
return;
}
promise.complete(headers.get("Location"));
}).onFailure(handleFail(SECOND_REQUEST_URL));
}).onFailure(handleFail(SECOND_REQUEST_URL_VIP));
return promise.future();
}
public static void resetToken() {
token = null;
authFlag = true;
}
}
@@ -0,0 +1,496 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.CommonUtils;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* <a href="https://www.feishu.cn/">飞书云盘</a>
* <p>
* 支持飞书公开分享文件和文件夹的解析
* <ul>
* <li>文件链接: https://xxx.feishu.cn/file/{token}</li>
* <li>文件夹链接: https://xxx.feishu.cn/drive/folder/{token}</li>
* </ul>
* 飞书下载需要先获取匿名会话Cookie然后使用Cookie请求下载接口
* </p>
*/
public class FsTool extends PanBase {
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
/**
* 飞书 obj_type: type=12 表示上传文件可下载
*/
private static final int OBJ_TYPE_FILE = 12;
/**
* v3 列表 API 支持的 obj_type
*/
private static final int[] LIST_OBJ_TYPES = {
0, 2, 22, 44, 3, 30, 8, 11, 12, 84, 123, 124
};
/** 每页返回条目数 */
private static final int PAGE_SIZE = 50;
/**
* 从分享链接中提取 tenant 的正则
*/
private static final Pattern TENANT_PATTERN =
Pattern.compile("https://([^.]+)\\.feishu\\.cn/");
/** 解析 Content-Disposition: filename*=UTF-8''xxx */
private static final Pattern CD_FILENAME_STAR_PATTERN =
Pattern.compile("filename\\*=UTF-8''(.+?)(?:;|$)");
/** 解析 Content-Disposition: filename="xxx" 或 filename=xxx */
private static final Pattern CD_FILENAME_PATTERN =
Pattern.compile("filename=\"?([^\";]+)\"?");
/** 解析 Content-Range 中的总大小 */
private static final Pattern CONTENT_RANGE_SIZE_PATTERN =
Pattern.compile("/(\\d+)");
public FsTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@Override
public Future<String> parse() {
String shareUrl = shareLinkInfo.getShareUrl();
String tenant = extractTenant(shareUrl);
String token = shareLinkInfo.getShareKey();
if (tenant == null || token == null) {
fail("无法从链接中提取tenant或token: {}", shareUrl);
return promise.future();
}
boolean isFolder = shareUrl.contains("/drive/folder/");
if (isFolder) {
fetchSessionAndParseFolder(tenant, token, shareUrl);
} else {
fetchSessionAndParseFile(tenant, token, shareUrl);
}
return promise.future();
}
/**
* 获取匿名session后解析文件
*/
private void fetchSessionAndParseFile(String tenant, String token, String shareUrl) {
clientSession.getAbs(shareUrl)
.putHeader("User-Agent", UA)
.putHeader("Accept", "text/html,*/*")
.send()
.onSuccess(res -> {
String dlUrl = buildDownloadUrl(tenant, token);
// Range探测获取文件名和大小
clientSession.getAbs(dlUrl)
.putHeader("User-Agent", UA)
.putHeader("Referer", shareUrl)
.putHeader("Range", "bytes=0-0")
.send()
.onSuccess(probeRes -> {
String fileName = parseFileNameFromContentDisposition(
probeRes.getHeader("Content-Disposition"));
Map<String, String> headers = new HashMap<>();
headers.put("Referer", shareUrl);
headers.put("User-Agent", UA);
String cookies = extractCookiesFromResponse(probeRes);
if (cookies != null && !cookies.isEmpty()) {
headers.put("Cookie", cookies);
}
if (fileName != null) {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(fileName);
fileInfo.setFileId(token);
fileInfo.setFileType("file");
fileInfo.setPanType(shareLinkInfo.getType());
fileInfo.setParserUrl(buildRedirectUrl(shareUrl, token));
parseSizeFromContentRange(
probeRes.getHeader("Content-Range"), fileInfo);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
}
completeWithMeta(dlUrl, headers);
})
.onFailure(handleFail("探测文件信息失败"));
})
.onFailure(handleFail("获取匿名会话失败"));
}
/**
* 获取匿名session后解析文件夹取第一个可下载文件
*/
private void fetchSessionAndParseFolder(String tenant, String folderToken,
String shareUrl) {
clientSession.getAbs(shareUrl)
.putHeader("User-Agent", UA)
.putHeader("Accept", "text/html,*/*")
.send()
.onSuccess(res ->
listFolderAll(tenant, folderToken, "").onSuccess(items -> {
if (items.isEmpty()) {
fail("文件夹中没有可下载的文件");
return;
}
FileInfo first = items.get(0);
String objToken = first.getFileId();
String dlUrl = buildDownloadUrl(tenant, objToken);
String referer = "https://" + tenant
+ ".feishu.cn/drive/folder/" + folderToken;
Map<String, String> headers = new HashMap<>();
headers.put("Referer", referer);
headers.put("User-Agent", UA);
shareLinkInfo.getOtherParam().put("fileInfo", first);
completeWithMeta(dlUrl, headers);
}).onFailure(t -> fail("列出文件夹内容失败: {}", t.getMessage())))
.onFailure(handleFail("获取匿名会话失败"));
}
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> listPromise = Promise.promise();
String shareUrl = shareLinkInfo.getShareUrl();
String tenant = extractTenant(shareUrl);
String token = shareLinkInfo.getShareKey();
if (tenant == null || token == null) {
listPromise.fail("无法从链接中提取tenant或token: " + shareUrl);
return listPromise.future();
}
boolean isFolder = shareUrl.contains("/drive/folder/");
clientSession.getAbs(shareUrl)
.putHeader("User-Agent", UA)
.putHeader("Accept", "text/html,*/*")
.send()
.onSuccess(res -> {
if (isFolder) {
listFolderAll(tenant, token, "")
.onSuccess(listPromise::complete)
.onFailure(listPromise::fail);
} else {
probeSingleFile(tenant, token, shareUrl)
.onSuccess(fileInfo -> {
List<FileInfo> list = new ArrayList<>();
list.add(fileInfo);
listPromise.complete(list);
})
.onFailure(listPromise::fail);
}
})
.onFailure(t -> listPromise.fail("获取匿名会话失败: " + t.getMessage()));
return listPromise.future();
}
/**
* 分页获取文件夹所有可下载文件
*/
private Future<List<FileInfo>> listFolderAll(String tenant, String folderToken,
String pageLabel) {
Promise<List<FileInfo>> p = Promise.promise();
listFolderPage(tenant, folderToken, pageLabel).onSuccess(pageResult -> {
List<FileInfo> items = new ArrayList<>(pageResult.items);
if (pageResult.hasMore) {
listFolderAll(tenant, folderToken, pageResult.nextLabel)
.onSuccess(moreItems -> {
items.addAll(moreItems);
p.complete(items);
})
.onFailure(p::fail);
} else {
p.complete(items);
}
}).onFailure(p::fail);
return p.future();
}
/**
* 列出文件夹内容单页
*/
private Future<FolderPageResult> listFolderPage(String tenant, String folderToken,
String pageLabel) {
Promise<FolderPageResult> p = Promise.promise();
String baseUrl = "https://" + tenant + ".feishu.cn";
StringBuilder urlBuilder = new StringBuilder();
urlBuilder.append(baseUrl)
.append("/space/api/explorer/v3/children/list/")
.append("?length=").append(PAGE_SIZE)
.append("&asc=1&rank=5&token=").append(folderToken);
for (int type : LIST_OBJ_TYPES) {
urlBuilder.append("&obj_type=").append(type);
}
if (pageLabel != null && !pageLabel.isEmpty()) {
urlBuilder.append("&last_label=").append(pageLabel);
}
String url = urlBuilder.toString();
String referer = baseUrl + "/drive/folder/" + folderToken;
clientSession.getAbs(url)
.putHeader("User-Agent", UA)
.putHeader("Accept", "application/json, text/plain, */*")
.putHeader("Referer", referer)
.send()
.onSuccess(res -> {
try {
JsonObject json = asJson(res);
int code = json.getInteger("code", -1);
if (code != 0) {
p.fail("飞书API错误: " + json.getString("msg"));
return;
}
JsonObject data = json.getJsonObject("data");
JsonObject entities = data.getJsonObject("entities",
new JsonObject());
JsonObject nodes = entities.getJsonObject("nodes",
new JsonObject());
JsonArray nodeList = data.getJsonArray("node_list",
new JsonArray());
List<FileInfo> items = new ArrayList<>();
for (int i = 0; i < nodeList.size(); i++) {
String nid = nodeList.getString(i);
JsonObject node = nodes.getJsonObject(nid,
new JsonObject());
int objType = node.getInteger("type", -1);
String objToken = node.getString("obj_token", "");
String name = node.getString("name", "unknown");
// 排除文件夹自身节点
if (objToken.equals(folderToken)) {
continue;
}
// 只返回可下载的文件(type=12)
if (objType == OBJ_TYPE_FILE) {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(name);
fileInfo.setFileId(objToken);
fileInfo.setPanType(shareLinkInfo.getType());
fileInfo.setFileType("file");
JsonObject extra = node.getJsonObject("extra",
new JsonObject());
try {
long size = Long.parseLong(
extra.getString("size", "0"));
fileInfo.setSize(size);
} catch (NumberFormatException e) {
log.warn("无法解析文件大小: {}", extra.getString("size"), e);
}
fileInfo.setParserUrl(buildRedirectUrl(
shareLinkInfo.getShareUrl(), objToken));
// 添加下载所需的请求头到extParameters
Map<String, Object> extParams = new HashMap<>();
Map<String, String> downloadHeaders = new HashMap<>();
downloadHeaders.put("Referer", referer);
downloadHeaders.put("User-Agent", UA);
extParams.put("downloadHeaders", downloadHeaders);
fileInfo.setExtParameters(extParams);
items.add(fileInfo);
}
}
boolean hasMore = data.getBoolean("has_more", false);
String nextLabel = data.getString("last_label", "");
p.complete(new FolderPageResult(items, hasMore, nextLabel));
} catch (Exception e) {
p.fail("解析文件列表响应失败: " + e.getMessage());
}
})
.onFailure(t -> p.fail("请求文件列表失败: " + t.getMessage()));
return p.future();
}
/**
* 探测单个文件信息
*/
private Future<FileInfo> probeSingleFile(String tenant, String token,
String referer) {
Promise<FileInfo> p = Promise.promise();
String dlUrl = buildDownloadUrl(tenant, token);
clientSession.getAbs(dlUrl)
.putHeader("User-Agent", UA)
.putHeader("Referer", referer)
.putHeader("Range", "bytes=0-0")
.send()
.onSuccess(probeRes -> {
FileInfo fileInfo = new FileInfo();
String fileName = parseFileNameFromContentDisposition(
probeRes.getHeader("Content-Disposition"));
if (fileName != null) {
fileInfo.setFileName(fileName);
}
parseSizeFromContentRange(
probeRes.getHeader("Content-Range"), fileInfo);
fileInfo.setFileId(token);
fileInfo.setPanType(shareLinkInfo.getType());
fileInfo.setFileType("file");
fileInfo.setParserUrl(buildRedirectUrl(referer, token));
// 添加下载所需的请求头到extParameters
Map<String, Object> extParams = new HashMap<>();
Map<String, String> downloadHeaders = new HashMap<>();
downloadHeaders.put("Referer", referer);
downloadHeaders.put("User-Agent", UA);
extParams.put("downloadHeaders", downloadHeaders);
fileInfo.setExtParameters(extParams);
p.complete(fileInfo);
})
.onFailure(t -> p.fail("探测文件失败: " + t.getMessage()));
return p.future();
}
@Override
public Future<String> parseById() {
Promise<String> parsePromise = Promise.promise();
try {
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
if (paramJson == null) {
parsePromise.fail("缺少 paramJson 参数");
return parsePromise.future();
}
String shareUrl = paramJson.getString("shareUrl");
String objToken = paramJson.getString("objToken");
String tenant = extractTenant(shareUrl);
if (shareUrl == null || objToken == null || tenant == null) {
parsePromise.fail("飞书目录文件下载参数不完整");
return parsePromise.future();
}
parsePromise.complete(buildDownloadUrl(tenant, objToken));
} catch (Exception e) {
parsePromise.fail("解析飞书目录文件参数失败: " + e.getMessage());
}
return parsePromise.future();
}
// 工具方法
private String buildRedirectUrl(String shareUrl, String objToken) {
JsonObject paramJson = new JsonObject()
.put("shareUrl", shareUrl)
.put("objToken", objToken);
return String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(),
shareLinkInfo.getType(),
CommonUtils.urlBase64Encode(paramJson.encode()));
}
private String buildDownloadUrl(String tenant, String objToken) {
return "https://" + tenant
+ ".feishu.cn/space/api/box/stream/download/all/" + objToken;
}
private String extractTenant(String url) {
if (url == null) return null;
Matcher m = TENANT_PATTERN.matcher(url);
if (m.find()) {
return m.group(1);
}
return null;
}
/**
* 从Content-Disposition头解析文件名
* 支持 filename*=UTF-8''xxx filename="xxx" 两种格式
*/
private String parseFileNameFromContentDisposition(String cd) {
if (cd == null || cd.isEmpty()) return null;
// 优先解析 filename*=UTF-8''xxx
Matcher m1 = CD_FILENAME_STAR_PATTERN.matcher(cd);
if (m1.find()) {
try {
return URLDecoder.decode(m1.group(1).trim(), StandardCharsets.UTF_8);
} catch (IllegalArgumentException ignored) {
}
}
// 降级解析 filename="xxx" filename=xxx
Matcher m2 = CD_FILENAME_PATTERN.matcher(cd);
if (m2.find()) {
try {
return URLDecoder.decode(m2.group(1).trim(), StandardCharsets.UTF_8);
} catch (IllegalArgumentException ignored) {
}
}
return null;
}
private void parseSizeFromContentRange(String cr, FileInfo fileInfo) {
if (cr != null) {
Matcher m = CONTENT_RANGE_SIZE_PATTERN.matcher(cr);
if (m.find()) {
fileInfo.setSize(Long.parseLong(m.group(1)));
}
}
}
private String extractCookiesFromResponse(
io.vertx.ext.web.client.HttpResponse<?> response) {
List<String> setCookies = response.cookies();
if (setCookies == null || setCookies.isEmpty()) return null;
StringBuilder sb = new StringBuilder();
for (String cookie : setCookies) {
String nameValue = cookie.split(";")[0].trim();
if (!sb.isEmpty()) sb.append("; ");
sb.append(nameValue);
}
return sb.toString();
}
/**
* 文件夹分页结果
*/
private record FolderPageResult(List<FileInfo> items, boolean hasMore,
String nextLabel) {
}
}
@@ -0,0 +1,55 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.IPanTool;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import org.apache.commons.lang3.StringUtils;
import java.util.List;
/**
* 蓝奏云优享解析器选择器
* 根据配置的鉴权方式选择不同的解析器
* - 如果配置了 username password则使用 IzToolWithAuth (支持大文件)
* - 否则使用 IzTool (免登录仅支持小文件)
*/
public class IzSelectorTool implements IPanTool {
private final IPanTool selectedTool;
public IzSelectorTool(ShareLinkInfo shareLinkInfo) {
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
// 检查是否配置了账号密码
if (auths.contains("username") && auths.contains("password")) {
String username = auths.get("username");
String password = auths.get("password");
if (StringUtils.isNotEmpty(username) && StringUtils.isNotEmpty(password)) {
// 使用 IzToolWithAuth (账密登录支持大文件)
this.selectedTool = new IzToolWithAuth(shareLinkInfo);
return;
}
}
}
// 无认证信息或认证信息无效使用免登录版本仅支持小文件
this.selectedTool = new IzTool(shareLinkInfo);
}
@Override
public Future<String> parse() {
return selectedTool.parse();
}
@Override
public Future<List<FileInfo>> parseFileList() {
return selectedTool.parseFileList();
}
@Override
public Future<String> parseById() {
return selectedTool.parseById();
}
}
@@ -4,17 +4,25 @@ import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.AESUtils;
import cn.qaiu.util.AcwScV2Generator;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.util.FileSizeConverter;
import cn.qaiu.util.UUIDUtil;
import io.netty.handler.codec.http.cookie.DefaultCookie;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.ext.web.client.WebClientSession;
import io.vertx.uritemplate.UriTemplate;
import org.apache.commons.lang3.StringUtils;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 蓝奏云优享
@@ -22,14 +30,25 @@ import java.util.*;
*/
public class IzTool extends PanBase {
private static final String API_URL0 = "https://api.ilanzou.com/";
private static final String API_URL_PREFIX = "https://api.ilanzou.com/unproved/";
private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" +
"&uuid={uuid}&extra=2&timestamp={ts}&shareId={shareId}&type=0&offset=1&limit=60";
private static final String LOGIN_URL = API_URL_PREFIX +
"login?uuid={uuid}&devType=6&devCode={uuid}&devModel=chrome&devVersion=127&appVersion=&timestamp={ts}&appToken=&extra=2";
// https://api.ilanzou.com/proved/user/info/map?devType=3&devModel=Chrome&uuid=TInRHH3QzRaMo-Ajl2PkJ&extra=2&timestamp=EC2C6E7F45EB21338A17A7621E0BB437
private static final String TOKEN_VERIFY_URL = API_URL0 +
"proved/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2&timestamp={ts}";
private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" +
"&devType=6&uuid={uuid}&timestamp={ts}&auth={auth}&shareId={dataKey}";
// downloadId=x&enable=1&devType=6&uuid=x&timestamp=x&auth=x&shareId=lGFndCM
private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX + "file/redirect?uuid={uuid}&devType=6&devCode={uuid}" +
"&devModel=chrome&devVersion=127&appVersion=&timestamp={ts}&appToken={appToken}&enable=1&downloadId={fidEncode}&auth={auth}";
private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" +
"={uuid}&extra=2&timestamp={ts}";
@@ -38,16 +57,15 @@ public class IzTool extends PanBase {
"={uuid}&extra=2&timestamp={ts}&shareId={shareId}&folderId" +
"={folderId}&offset=1&limit=60";
long nowTs = System.currentTimeMillis();
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
String uuid = UUID.randomUUID().toString();
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
private static final MultiMap header;
static {
header = MultiMap.caseInsensitiveMultiMap();
header.set("Accept", "application/json, text/plain, */*");
header.set("Accept-Encoding", "gzip, deflate, br, zstd");
header.set("Accept-Encoding", "gzip, deflate");
header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
header.set("Cache-Control", "no-cache");
header.set("Connection", "keep-alive");
@@ -65,85 +83,376 @@ public class IzTool extends PanBase {
header.set("sec-ch-ua-mobile", "?0");
header.set("sec-ch-ua-platform", "\"Windows\"");
}
public IzTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
public static volatile String token = null;
public static volatile boolean authFlag = true;
public Future<String> parse() {
String shareId = shareLinkInfo.getShareKey();
// 24.5.12 ilanzou改规则无需计算shareId
// String shareId = String.valueOf(AESUtils.idEncryptIz(dataKey));
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
long nowTs = System.currentTimeMillis();
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
// 第一次请求 获取文件信息
// POST https://api.ilanzou.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
: (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword());
client.postAbs(UriTemplate.of(VIP_REQUEST_URL))
// 检查并输出认证状态
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, token={}",
isTempAuth, authFlag, token != null ? "已登录(" + token.substring(0, Math.min(8, token.length())) + "...)" : "未登录");
// 如果需要认证但还没有token先执行登录
if ((isTempAuth || authFlag) && token == null) {
log.info("文件解析需要登录,开始执行登录流程...");
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
return login(tsEncode, auths)
.compose(v -> {
log.info("文件解析预登录成功,继续解析流程");
return parseWithAuth(shareId, tsEncode);
})
.onFailure(err -> {
log.warn("文件解析预登录失败: {},尝试使用免登录模式", err.getMessage());
// 登录失败继续使用免登录模式
});
} else if (token != null) {
log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(8, token.length())));
}
} else {
log.debug("文件解析无认证信息,使用免登录模式");
}
return parseWithAuth(shareId, tsEncode);
}
private Future<String> parseWithAuth(String shareId, String tsEncode) {
// 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口
webClientSession.postAbs(UriTemplate.of(VIP_REQUEST_URL))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.send().onSuccess(r0 -> { // 忽略res
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
: (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword());
// 第一次请求 获取文件信息
// POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60
client.postAbs(UriTemplate.of(url))
webClientSession.postAbs(UriTemplate.of(url))
.putHeaders(header)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.send().onSuccess(res -> {
JsonObject resJson = asJson(res);
if (resJson.getInteger("code") != 200) {
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
return;
}
if (resJson.getJsonArray("list").isEmpty()) {
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
return;
}
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
return;
}
// 文件Id
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
// 如果是目录返回目录ID
if (!fileInfo.containsKey("fileList") || fileInfo.getJsonArray("fileList").isEmpty()) {
fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo);
return;
}
JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0);
if (fileList.getInteger("fileType") == 2) {
promise.complete(fileList.getInteger("folderId").toString());
String resBody = asText(res);
// 检查是否包含 cookie 验证
if (resBody.contains("var arg1='")) {
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(resBody);
// 重新请求
webClientSession.postAbs(UriTemplate.of(url))
.putHeaders(header)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.send().onSuccess(res2 -> {
processFirstResponse(res2);
}).onFailure(handleFail("请求1-重试"));
return;
}
processFirstResponse(res);
}).onFailure(handleFail("请求1"));
}).onFailure(handleFail("请求1"));
String fileId = fileInfo.getString("fileIds");
String userId = fileInfo.getString("userId");
// 其他参数
// String fidEncode = AESUtils.encrypt2HexIz(fileId + "|");
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs);
// 第二次请求
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.setTemplateParam("auth", auth)
.setTemplateParam("shareId", shareId)
.putHeaders(header).send().onSuccess(res2 -> {
MultiMap headers = res2.headers();
if (!headers.contains("Location")) {
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + headers);
return;
}
promise.complete(headers.get("Location"));
}).onFailure(handleFail(SECOND_REQUEST_URL));
}).onFailure(handleFail(FIRST_REQUEST_URL));
});
return promise.future();
}
/**
* 设置 cookie
*/
private void setCookie(String html) {
int beginIndex = html.indexOf("arg1='") + 6;
String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex));
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
// 创建一个 Cookie 并放入 CookieStore
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
nettyCookie.setDomain(".ilanzou.com"); // 设置域名
nettyCookie.setPath("/"); // 设置路径
nettyCookie.setSecure(false);
nettyCookie.setHttpOnly(false);
webClientSession.cookieStore().put(nettyCookie);
}
/**
* 处理第一次请求的响应
*/
private void processFirstResponse(HttpResponse<Buffer> res) {
JsonObject resJson = asJson(res);
if (resJson.getInteger("code") != 200) {
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
return;
}
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
return;
}
// 文件Id
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
// 如果是目录返回目录ID
if (!fileInfo.containsKey("fileList") || fileInfo.getJsonArray("fileList").isEmpty()) {
fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo);
return;
}
JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0);
if (fileList.getInteger("fileType") == 2) {
promise.complete(fileList.getInteger("folderId").toString());
return;
}
// 提取文件信息
extractFileInfo(fileList, fileInfo);
getDownURL(resJson);
}
private void getDownURL(JsonObject resJson) {
String dataKey = shareLinkInfo.getShareKey();
// 文件Id
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
String fileId = fileInfo.getString("fileIds");
String userId = fileInfo.getString("userId");
// 其他参数
long nowTs2 = System.currentTimeMillis();
String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2));
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2);
// 检查是否有认证信息
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
// 检查是否为临时认证临时认证每次都尝试登录
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
// 如果是临时认证或者是后台配置且authFlag为true则尝试使用认证
if (isTempAuth || authFlag) {
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
HttpRequest<Buffer> httpRequest =
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey);
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (token == null) {
// 执行登录
login(tsEncode2, auths).onFailure(failRes-> {
log.warn("登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
// 验证token
webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.putHeaders(header).send().onSuccess(res -> {
// log.info("res: {}",asJson(res));
if (asJson(res).getInteger("code") != 200) {
login(tsEncode2, auths).onFailure(failRes -> {
log.warn("重新登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
}
}).onFailure(handleFail("Token验证"));
}
} else {
// authFlag false使用免登录解析
log.debug("authFlag=false,使用免登录解析");
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"));
}
} else {
// 没有认证信息使用免登录解析
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) {
Promise<Void> promise1 = Promise.promise();
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
.setTemplateParam("uuid",uuid)
.setTemplateParam("ts", tsEncode2)
.putHeaders(header)
.sendJsonObject(JsonObject.of("loginName", auths.get("username"), "loginPwd", auths.get("password")))
.onSuccess(res2->{
JsonObject json = asJson(res2);
if (json.getInteger("code") == 200) {
token = json.getJsonObject("data").getString("appToken");
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
promise1.complete();
} else {
// 检查是否为临时认证
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
if (isTempAuth) {
// 临时认证失败直接返回错误不影响后台配置的认证
log.warn("临时认证失败: {}", json.getString("msg"));
promise1.fail("临时认证失败: " + json.getString("msg"));
} else {
// 后台配置的认证失败设置authFlag并返回失败让下次请求使用免登陆解析
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
authFlag = false;
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
}
}
}).onFailure(err -> {
log.error("登录请求异常: {}", err.getMessage());
promise1.fail("登录请求异常: " + err.getMessage());
});
return promise1.future();
}
/**
* 从接口返回数据中提取文件信息
*/
private void extractFileInfo(JsonObject fileList, JsonObject shareInfo) {
try {
// 文件名
String fileName = fileList.getString("fileName");
shareLinkInfo.getOtherParam().put("fileName", fileName);
// 文件大小 (KB -> Bytes)
Long fileSize = fileList.getLong("fileSize", 0L) * 1024;
shareLinkInfo.getOtherParam().put("fileSize", fileSize);
shareLinkInfo.getOtherParam().put("fileSizeFormat", FileSizeConverter.convertToReadableSize(fileSize));
// 文件图标
String fileIcon = fileList.getString("fileIcon");
if (StringUtils.isNotBlank(fileIcon)) {
shareLinkInfo.getOtherParam().put("fileIcon", fileIcon);
}
// 文件ID
Long fileId = fileList.getLong("fileId");
if (fileId != null) {
shareLinkInfo.getOtherParam().put("fileId", fileId.toString());
}
// 文件类型 (1=文件, 2=目录)
Integer fileType = fileList.getInteger("fileType", 1);
shareLinkInfo.getOtherParam().put("fileType", fileType == 1 ? "file" : "folder");
// 下载次数
Integer downloads = fileList.getInteger("fileDownloads", 0);
shareLinkInfo.getOtherParam().put("downloadCount", downloads);
// 点赞数
Integer likes = fileList.getInteger("fileLikes", 0);
shareLinkInfo.getOtherParam().put("likeCount", likes);
// 评论数
Integer comments = fileList.getInteger("fileComments", 0);
shareLinkInfo.getOtherParam().put("commentCount", comments);
// 评分
Double stars = fileList.getDouble("fileStars", 0.0);
shareLinkInfo.getOtherParam().put("stars", stars);
// 更新时间
String updateTime = fileList.getString("updTime");
if (StringUtils.isNotBlank(updateTime)) {
shareLinkInfo.getOtherParam().put("updateTime", updateTime);
}
// 创建时间
String createTime = null;
// 分享信息
if (shareInfo != null) {
// 分享ID
Integer shareId = shareInfo.getInteger("shareId");
if (shareId != null) {
shareLinkInfo.getOtherParam().put("shareId", shareId.toString());
}
// 上传时间
String addTime = shareInfo.getString("addTime");
if (StringUtils.isNotBlank(addTime)) {
shareLinkInfo.getOtherParam().put("createTime", addTime);
createTime = addTime;
}
// 预览次数
Integer previewNum = shareInfo.getInteger("previewNum", 0);
shareLinkInfo.getOtherParam().put("previewCount", previewNum);
// 用户信息
JsonObject userMap = shareInfo.getJsonObject("map");
if (userMap != null) {
String userName = userMap.getString("userName");
if (StringUtils.isNotBlank(userName)) {
shareLinkInfo.getOtherParam().put("userName", userName);
}
// VIP信息
Integer isVip = userMap.getInteger("isVip", 0);
shareLinkInfo.getOtherParam().put("isVip", isVip == 1);
}
}
// 创建 FileInfo 对象并存入 otherParam
FileInfo fileInfoObj = new FileInfo()
.setPanType(shareLinkInfo.getType())
.setFileName(fileName)
.setFileId(fileList.getLong("fileId") != null ? fileList.getLong("fileId").toString() : null)
.setSize(fileSize)
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
.setFileType(fileType == 1 ? "file" : "folder")
.setFileIcon(fileList.getString("fileIcon"))
.setDownloadCount(downloads)
.setCreateTime(createTime)
.setUpdateTime(updateTime);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfoObj);
log.debug("提取文件信息成功: fileName={}, fileSize={}, downloads={}",
fileName, fileSize, downloads);
} catch (Exception e) {
log.warn("提取文件信息失败: {}", e.getMessage());
}
}
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
if (!headers.contains("Location") || StringUtils.isBlank(headers.get("Location"))) {
fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误");
return;
}
promise.complete(headers.get("Location"));
}
// 目录解析
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> promise = Promise.promise();
@@ -153,16 +462,15 @@ public class IzTool extends PanBase {
// 如果参数里的目录ID不为空则直接解析目录
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
if (dirId != null && !dirId.isEmpty()) {
uuid = shareLinkInfo.getOtherParam().get("uuid").toString();
Object uuidObj = shareLinkInfo.getOtherParam().get("uuid");
if (uuidObj != null) {
uuid = uuidObj.toString();
}
parserDir(dirId, shareId, promise);
return promise.future();
}
parse().onSuccess(id -> {
if (id != null && id.matches("^[a-zA-Z0-9]+$")) {
parserDir(id, shareId, promise);
} else {
promise.fail("解析目录ID失败");
}
parserDir(id, shareId, promise);
}).onFailure(failRes -> {
log.error("解析目录失败: {}", failRes.getMessage());
promise.fail(failRes);
@@ -171,113 +479,160 @@ public class IzTool extends PanBase {
}
private void parserDir(String id, String shareId, Promise<List<FileInfo>> promise) {
if (id != null && (id.startsWith("http://") || id.startsWith("https://"))) {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(id)
.setFileId(id)
.setFileType("file")
.setParserUrl(id)
.setPanType(shareLinkInfo.getType());
List<FileInfo> result = new ArrayList<>();
result.add(fileInfo);
promise.complete(result);
return;
}
long nowTs = System.currentTimeMillis();
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode);
// 开始解析目录: 164312216, shareId: bPMsbg5K, uuid: 0fmVWTx2Ea4zFwkpd7KXf, ts: 20865d7b7f00828279f437cd1f097860
// 拿到目录ID
client.postAbs(UriTemplate.of(FILE_LIST_URL))
webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL))
.putHeaders(header)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.setTemplateParam("folderId", id)
.send().onSuccess(res -> {
JsonObject jsonObject;
try {
jsonObject = asJson(res);
} catch (Exception e) {
promise.fail(FIRST_REQUEST_URL + " 解析JSON失败: " + res.bodyAsString());
String resBody = asText(res);
// 检查是否包含 cookie 验证
if (resBody.contains("var arg1='")) {
log.debug("目录解析需要 cookie 验证,重新创建 session");
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(resBody);
// 重新请求目录列表
webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL))
.putHeaders(header)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.setTemplateParam("folderId", id)
.send().onSuccess(res2 -> {
processDirResponse(res2, shareId, promise);
}).onFailure(err -> {
log.error("目录解析重试失败: {}", err.getMessage());
promise.fail("目录解析失败: " + err.getMessage());
});
return;
}
// System.out.println(jsonObject.encodePrettily());
JsonArray list = jsonObject.getJsonArray("list");
ArrayList<FileInfo> result = new ArrayList<>();
list.forEach(item->{
JsonObject fileJson = (JsonObject) item;
FileInfo fileInfo = new FileInfo();
// 映射已知字段
String fileId = fileJson.getString("fileId");
String userId = fileJson.getString("userId");
// 回传用到的参数
//"fidEncode", paramJson.getString("fidEncode"))
//"uuid", paramJson.getString("uuid"))
//"ts", paramJson.getString("ts"))
//"auth", paramJson.getString("auth"))
//"shareId", paramJson.getString("shareId"))
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs);
JsonObject entries = JsonObject.of(
"fidEncode", fidEncode,
"uuid", uuid,
"ts", tsEncode,
"auth", auth,
"shareId", shareId);
byte[] encode = Base64.getEncoder().encode(entries.encode().getBytes());
String param = new String(encode);
if (fileJson.getInteger("fileType") == 2) {
// 如果是目录
fileInfo.setFileName(fileJson.getString("name"))
.setFileId(fileJson.getString("folderId"))
.setCreateTime(fileJson.getString("updTime"))
.setFileType("folder")
.setSize(0L)
.setSizeStr("0B")
.setCreateBy(fileJson.getLong("userId").toString())
.setDownloadCount(fileJson.getInteger("fileDownloads"))
.setCreateTime(fileJson.getString("updTime"))
.setFileIcon(fileJson.getString("fileIcon"))
.setPanType(shareLinkInfo.getType())
// 设置目录解析的URL
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&uuid=%s", getDomainName(),
shareLinkInfo.getShareUrl(), fileJson.getString("folderId"), uuid));
result.add(fileInfo);
return;
}
long fileSize = fileJson.getLong("fileSize") * 1024;
fileInfo.setFileName(fileJson.getString("fileName"))
.setFileId(fileId)
.setCreateTime(fileJson.getString("createTime"))
.setFileType("file")
.setSize(fileSize)
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
.setCreateBy(fileJson.getLong("userId").toString())
.setDownloadCount(fileJson.getInteger("fileDownloads"))
.setCreateTime(fileJson.getString("updTime"))
.setFileIcon(fileJson.getString("fileIcon"))
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(),
shareLinkInfo.getType(), param))
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(),
shareLinkInfo.getType(), param));
result.add(fileInfo);
});
promise.complete(result);
}).onFailure(failRes -> {
log.error("解析目录请求失败: {}", failRes.getMessage());
promise.fail(failRes);
processDirResponse(res, shareId, promise);
}).onFailure(err -> {
log.error("目录解析请求失败: {}", err.getMessage());
promise.fail("目录解析失败: " + err.getMessage());
});
}
/**
* 处理目录解析响应
*/
private void processDirResponse(HttpResponse<Buffer> res, String shareId, Promise<List<FileInfo>> promise) {
try {
JsonObject jsonObject = asJson(res);
log.debug("目录解析响应: {}", jsonObject.encodePrettily());
if (!jsonObject.containsKey("list")) {
log.error("目录解析响应缺少 list 字段: {}", jsonObject);
promise.fail("目录解析失败: 响应格式错误");
return;
}
JsonArray list = jsonObject.getJsonArray("list");
ArrayList<FileInfo> result = new ArrayList<>();
list.forEach(item->{
JsonObject fileJson = (JsonObject) item;
FileInfo fileInfo = new FileInfo();
// 映射已知字段
String fileId = fileJson.getString("fileId");
String userId = fileJson.getString("userId");
// 其他参数 - 每个文件使用新的时间戳
long nowTs2 = System.currentTimeMillis();
String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2));
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2);
// 回传用到的参数
JsonObject entries = JsonObject.of(
"fidEncode", fidEncode,
"uuid", uuid,
"ts", tsEncode2,
"auth", auth,
"shareId", shareId);
String param = CommonUtils.urlBase64Encode(entries.encode());
if (fileJson.getInteger("fileType") == 2) {
// 如果是目录
fileInfo.setFileName(fileJson.getString("name"))
.setFileId(fileJson.getString("folderId"))
.setCreateTime(fileJson.getString("updTime"))
.setFileType("folder")
.setSize(0L)
.setSizeStr("0B")
.setCreateBy(fileJson.getLong("userId").toString())
.setDownloadCount(fileJson.getInteger("fileDownloads"))
.setCreateTime(fileJson.getString("updTime"))
.setFileIcon(fileJson.getString("fileIcon"))
.setPanType(shareLinkInfo.getType())
// 设置目录解析的URL
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&uuid=%s", getDomainName(),
shareLinkInfo.getShareUrl(), fileJson.getString("folderId"), uuid));
result.add(fileInfo);
return;
}
long fileSize = fileJson.getLong("fileSize") * 1024;
fileInfo.setFileName(fileJson.getString("fileName"))
.setFileId(fileId)
.setCreateTime(fileJson.getString("createTime"))
.setFileType("file")
.setSize(fileSize)
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
.setCreateBy(fileJson.getLong("userId").toString())
.setDownloadCount(fileJson.getInteger("fileDownloads"))
.setCreateTime(fileJson.getString("updTime"))
.setFileIcon(fileJson.getString("fileIcon"))
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(),
shareLinkInfo.getType(), param))
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(),
shareLinkInfo.getType(), param));
result.add(fileInfo);
});
promise.complete(result);
} catch (Exception e) {
log.error("处理目录响应异常: {}", e.getMessage(), e);
promise.fail("目录解析失败: " + e.getMessage());
}
}
@Override
public Future<String> parseById() {
// 第二次请求
JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson");
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
// 使用免登录接口
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
.setTemplateParam("uuid", paramJson.getString("uuid"))
.setTemplateParam("ts", paramJson.getString("ts"))
.setTemplateParam("auth", paramJson.getString("auth"))
.setTemplateParam("shareId", paramJson.getString("shareId"))
.putHeaders(header).send().onSuccess(res2 -> {
MultiMap headers = res2.headers();
if (!headers.contains("Location")) {
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res2.headers());
return;
}
promise.complete(headers.get("Location"));
}).onFailure(handleFail(SECOND_REQUEST_URL));
.setTemplateParam("dataKey", paramJson.getString("shareId"))
.send().onSuccess(this::down).onFailure(handleFail("parseById"));
return promise.future();
}
public static void resetToken() {
token = null;
authFlag = true;
}
}
@@ -0,0 +1,658 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.AESUtils;
import cn.qaiu.util.AcwScV2Generator;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.util.FileSizeConverter;
import io.netty.handler.codec.http.cookie.DefaultCookie;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.ext.web.client.WebClientSession;
import io.vertx.uritemplate.UriTemplate;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 蓝奏云优享 - 需要登录版本支持大文件
*/
public class IzToolWithAuth extends PanBase {
private static final String API_URL0 = "https://api.ilanzou.com/";
private static final String API_URL_PREFIX = "https://api.ilanzou.com/unproved/";
private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" +
"&uuid={uuid}&extra=2&timestamp={ts}&shareId={shareId}&type=0&offset=1&limit=60";
private static final String LOGIN_URL = API_URL_PREFIX +
"login?uuid={uuid}&devType=6&devCode={uuid}&devModel=chrome&devVersion=127&appVersion=&timestamp={ts}&appToken=&extra=2";
// https://api.ilanzou.com/proved/user/info/map?devType=3&devModel=Chrome&uuid=TInRHH3QzRaMo-Ajl2PkJ&extra=2&timestamp=EC2C6E7F45EB21338A17A7621E0BB437
private static final String TOKEN_VERIFY_URL = API_URL0 +
"proved/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2&timestamp={ts}";
private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" +
"&devType=6&uuid={uuid}&timestamp={ts}&auth={auth}&shareId={dataKey}";
private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX + "file/redirect?uuid={uuid}&devType=6&devCode={uuid}" +
"&devModel=chrome&devVersion=127&appVersion=&timestamp={ts}&appToken={appToken}&enable=1&downloadId={fidEncode}&auth={auth}";
private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" +
"={uuid}&extra=2&timestamp={ts}";
private static final String FILE_LIST_URL = API_URL_PREFIX + "/share/list?devType=6&devModel=Chrome&uuid" +
"={uuid}&extra=2&timestamp={ts}&shareId={shareId}&folderId" +
"={folderId}&offset=1&limit=60";
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
private static final MultiMap header;
static {
header = MultiMap.caseInsensitiveMultiMap();
header.set("Accept", "application/json, text/plain, */*");
header.set("Accept-Encoding", "gzip, deflate");
header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
header.set("Cache-Control", "no-cache");
header.set("Connection", "keep-alive");
header.set("Content-Length", "0");
header.set("DNT", "1");
header.set("Host", "api.ilanzou.com");
header.set("Origin", "https://www.ilanzou.com/");
header.set("Pragma", "no-cache");
header.set("Referer", "https://www.ilanzou.com/");
header.set("Sec-Fetch-Dest", "empty");
header.set("Sec-Fetch-Mode", "cors");
header.set("Sec-Fetch-Site", "cross-site");
header.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
header.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"");
header.set("sec-ch-ua-mobile", "?0");
header.set("sec-ch-ua-platform", "\"Windows\"");
}
public IzToolWithAuth(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
public static volatile String token = null;
public static volatile boolean authFlag = true;
public Future<String> parse() {
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
long nowTs = System.currentTimeMillis();
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
// 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口
webClientSession.postAbs(UriTemplate.of(VIP_REQUEST_URL))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.send().onSuccess(r0 -> { // 忽略res
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
: (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword());
// 第一次请求 获取文件信息
// POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60
webClientSession.postAbs(UriTemplate.of(url))
.putHeaders(header)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.send().onSuccess(res -> {
String resBody = asText(res);
// 检查是否包含 cookie 验证
if (resBody.contains("var arg1='")) {
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(resBody);
// 重新请求
webClientSession.postAbs(UriTemplate.of(url))
.putHeaders(header)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.send().onSuccess(res2 -> {
processFirstResponse(res2);
}).onFailure(handleFail("请求1-重试"));
return;
}
processFirstResponse(res);
}).onFailure(handleFail("请求1"));
}).onFailure(handleFail("请求1"));
return promise.future();
}
/**
* 设置 cookie
*/
private void setCookie(String html) {
int beginIndex = html.indexOf("arg1='") + 6;
String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex));
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
// 创建一个 Cookie 并放入 CookieStore
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
nettyCookie.setDomain(".ilanzou.com"); // 设置域名
nettyCookie.setPath("/"); // 设置路径
nettyCookie.setSecure(false);
nettyCookie.setHttpOnly(false);
webClientSession.cookieStore().put(nettyCookie);
}
/**
* 处理第一次请求的响应
*/
private void processFirstResponse(HttpResponse<Buffer> res) {
JsonObject resJson = asJson(res);
if (resJson.getInteger("code") != 200) {
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
return;
}
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
return;
}
// 文件Id
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
// 如果是目录返回目录ID
if (!fileInfo.containsKey("fileList") || fileInfo.getJsonArray("fileList").isEmpty()) {
fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo);
return;
}
JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0);
if (fileList.getInteger("fileType") == 2) {
promise.complete(fileList.getInteger("folderId").toString());
return;
}
// 提取文件信息
extractFileInfo(fileList, fileInfo);
getDownURL(resJson);
}
private void getDownURL(JsonObject resJson) {
String dataKey = shareLinkInfo.getShareKey();
// 文件Id
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
String fileId = fileInfo.getString("fileIds");
String userId = fileInfo.getString("userId");
// 其他参数
long nowTs2 = System.currentTimeMillis();
String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2));
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2);
// 检查是否有认证信息
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
// 检查是否为临时认证临时认证每次都尝试登录
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
// 如果是临时认证或者是后台配置且authFlag为true则尝试使用认证
if (isTempAuth || authFlag) {
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
HttpRequest<Buffer> httpRequest =
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey);
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (token == null) {
// 执行登录
login(tsEncode2, auths).onFailure(failRes-> {
log.warn("登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
// 验证token
webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.putHeaders(header).send().onSuccess(res -> {
// log.info("res: {}",asJson(res));
if (asJson(res).getInteger("code") != 200) {
login(tsEncode2, auths).onFailure(failRes -> {
log.warn("重新登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
}
}).onFailure(handleFail("Token验证"));
}
} else {
// authFlag false使用免登录解析
log.debug("authFlag=false,使用免登录解析");
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"));
}
} else {
// 没有认证信息使用免登录解析
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) {
Promise<Void> promise1 = Promise.promise();
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
.setTemplateParam("uuid",uuid)
.setTemplateParam("ts", tsEncode2)
.putHeaders(header)
.sendJsonObject(JsonObject.of("loginName", auths.get("username"), "loginPwd", auths.get("password")))
.onSuccess(res2->{
JsonObject json = asJson(res2);
if (json.getInteger("code") == 200) {
token = json.getJsonObject("data").getString("appToken");
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
promise1.complete();
} else {
// 检查是否为临时认证
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
if (isTempAuth) {
// 临时认证失败直接返回错误不影响后台配置的认证
log.warn("临时认证失败: {}", json.getString("msg"));
promise1.fail("临时认证失败: " + json.getString("msg"));
} else {
// 后台配置的认证失败设置authFlag并返回失败让下次请求使用免登陆解析
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
authFlag = false;
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
}
}
}).onFailure(err -> {
log.error("登录请求异常: {}", err.getMessage());
promise1.fail("登录请求异常: " + err.getMessage());
});
return promise1.future();
}
/**
* 从接口返回数据中提取文件信息
*/
private void extractFileInfo(JsonObject fileList, JsonObject shareInfo) {
try {
// 文件名
String fileName = fileList.getString("fileName");
shareLinkInfo.getOtherParam().put("fileName", fileName);
// 文件大小 (KB -> Bytes)
Long fileSize = fileList.getLong("fileSize", 0L) * 1024;
shareLinkInfo.getOtherParam().put("fileSize", fileSize);
shareLinkInfo.getOtherParam().put("fileSizeFormat", FileSizeConverter.convertToReadableSize(fileSize));
// 文件图标
String fileIcon = fileList.getString("fileIcon");
if (StringUtils.isNotBlank(fileIcon)) {
shareLinkInfo.getOtherParam().put("fileIcon", fileIcon);
}
// 文件ID
Long fileId = fileList.getLong("fileId");
if (fileId != null) {
shareLinkInfo.getOtherParam().put("fileId", fileId.toString());
}
// 文件类型 (1=文件, 2=目录)
Integer fileType = fileList.getInteger("fileType", 1);
shareLinkInfo.getOtherParam().put("fileType", fileType == 1 ? "file" : "folder");
// 下载次数
Integer downloads = fileList.getInteger("fileDownloads", 0);
shareLinkInfo.getOtherParam().put("downloadCount", downloads);
// 点赞数
Integer likes = fileList.getInteger("fileLikes", 0);
shareLinkInfo.getOtherParam().put("likeCount", likes);
// 评论数
Integer comments = fileList.getInteger("fileComments", 0);
shareLinkInfo.getOtherParam().put("commentCount", comments);
// 评分
Double stars = fileList.getDouble("fileStars", 0.0);
shareLinkInfo.getOtherParam().put("stars", stars);
// 更新时间
String updateTime = fileList.getString("updTime");
if (StringUtils.isNotBlank(updateTime)) {
shareLinkInfo.getOtherParam().put("updateTime", updateTime);
}
// 创建时间
String createTime = null;
// 分享信息
if (shareInfo != null) {
// 分享ID
Integer shareId = shareInfo.getInteger("shareId");
if (shareId != null) {
shareLinkInfo.getOtherParam().put("shareId", shareId.toString());
}
// 上传时间
String addTime = shareInfo.getString("addTime");
if (StringUtils.isNotBlank(addTime)) {
shareLinkInfo.getOtherParam().put("createTime", addTime);
createTime = addTime;
}
// 预览次数
Integer previewNum = shareInfo.getInteger("previewNum", 0);
shareLinkInfo.getOtherParam().put("previewCount", previewNum);
// 用户信息
JsonObject userMap = shareInfo.getJsonObject("map");
if (userMap != null) {
String userName = userMap.getString("userName");
if (StringUtils.isNotBlank(userName)) {
shareLinkInfo.getOtherParam().put("userName", userName);
}
// VIP信息
Integer isVip = userMap.getInteger("isVip", 0);
shareLinkInfo.getOtherParam().put("isVip", isVip == 1);
}
}
// 创建 FileInfo 对象并存入 otherParam
FileInfo fileInfoObj = new FileInfo()
.setPanType(shareLinkInfo.getType())
.setFileName(fileName)
.setFileId(fileList.getLong("fileId") != null ? fileList.getLong("fileId").toString() : null)
.setSize(fileSize)
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
.setFileType(fileType == 1 ? "file" : "folder")
.setFileIcon(fileList.getString("fileIcon"))
.setDownloadCount(downloads)
.setCreateTime(createTime)
.setUpdateTime(updateTime);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfoObj);
log.debug("提取文件信息成功: fileName={}, fileSize={}, downloads={}",
fileName, fileSize, downloads);
} catch (Exception e) {
log.warn("提取文件信息失败: {}", e.getMessage());
}
}
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
if (!headers.contains("Location") || StringUtils.isBlank(headers.get("Location"))) {
fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误");
return;
}
promise.complete(headers.get("Location"));
}
// 目录解析
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> promise = Promise.promise();
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
// 如果参数里的目录ID不为空则直接解析目录
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
if (dirId != null && !dirId.isEmpty()) {
Object uuidObj = shareLinkInfo.getOtherParam().get("uuid");
uuid = uuidObj != null ? uuidObj.toString() : null;
parserDir(dirId, shareId, promise);
return promise.future();
}
parse().onSuccess(id -> {
parserDir(id, shareId, promise);
}).onFailure(failRes -> {
log.error("解析目录失败: {}", failRes.getMessage());
promise.fail(failRes);
});
return promise.future();
}
private void parserDir(String id, String shareId, Promise<List<FileInfo>> promise) {
if (id != null && (id.startsWith("http://") || id.startsWith("https://"))) {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(id)
.setFileId(id)
.setFileType("file")
.setParserUrl(id)
.setPanType(shareLinkInfo.getType());
List<FileInfo> result = new ArrayList<>();
result.add(fileInfo);
promise.complete(result);
return;
}
long nowTs = System.currentTimeMillis();
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode);
// 检查是否需要登录有认证信息且需要使用认证
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
log.debug("目录解析检查认证: isTempAuth={}, authFlag={}, token={}", isTempAuth, authFlag, token != null ? "已有" : "null");
if ((isTempAuth || authFlag) && token == null) {
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
log.info("目录解析需要登录,开始执行登录...");
// 先登录获取 token
login(tsEncode, auths)
.onFailure(err -> {
log.warn("目录解析登录失败,使用免登录模式: {}", err.getMessage());
// 登录失败继续使用免登录
requestDirList(id, shareId, tsEncode, promise);
})
.onSuccess(r -> {
log.info("目录解析登录成功,token={}, 使用 VIP 模式", token != null ? token.substring(0, Math.min(8, token.length())) + "..." : "null");
requestDirList(id, shareId, tsEncode, promise);
});
return;
} else if (token != null) {
log.debug("目录解析已有 token,直接使用 VIP 模式");
} else {
log.debug("目录解析: authFlag=false 或为临时认证但已失败,使用免登录模式");
}
} else {
log.debug("目录解析无认证信息,使用免登录模式");
}
// 无需登录或已登录直接请求
requestDirList(id, shareId, tsEncode, promise);
}
/**
* 请求目录列表
*/
private void requestDirList(String id, String shareId, String tsEncode, Promise<List<FileInfo>> promise) {
webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL))
.putHeaders(header)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.setTemplateParam("folderId", id)
.send().onSuccess(res -> {
String resBody = asText(res);
// 检查是否包含 cookie 验证
if (resBody.contains("var arg1='")) {
log.debug("目录解析需要 cookie 验证,重新创建 session");
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(resBody);
// 重新请求目录列表
webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL))
.putHeaders(header)
.setTemplateParam("shareId", shareId)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode)
.setTemplateParam("folderId", id)
.send().onSuccess(res2 -> {
processDirResponse(res2, shareId, promise);
}).onFailure(err -> {
log.error("目录解析重试失败: {}", err.getMessage());
promise.fail("目录解析失败: " + err.getMessage());
});
return;
}
processDirResponse(res, shareId, promise);
}).onFailure(err -> {
log.error("目录解析请求失败: {}", err.getMessage());
promise.fail("目录解析失败: " + err.getMessage());
});
}
/**
* 处理目录解析响应
*/
private void processDirResponse(HttpResponse<Buffer> res, String shareId, Promise<List<FileInfo>> promise) {
try {
JsonObject jsonObject = asJson(res);
log.debug("目录解析响应: {}", jsonObject.encodePrettily());
if (!jsonObject.containsKey("list")) {
log.error("目录解析响应缺少 list 字段: {}", jsonObject);
promise.fail("目录解析失败: 响应格式错误");
return;
}
JsonArray list = jsonObject.getJsonArray("list");
ArrayList<FileInfo> result = new ArrayList<>();
list.forEach(item->{
JsonObject fileJson = (JsonObject) item;
FileInfo fileInfo = new FileInfo();
// 映射已知字段
String fileId = fileJson.getString("fileId");
String userId = fileJson.getString("userId");
// 其他参数 - 每个文件使用新的时间戳
long nowTs2 = System.currentTimeMillis();
String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2));
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2);
// 回传用到的参数包含 token
JsonObject entries = JsonObject.of(
"fidEncode", fidEncode,
"uuid", uuid,
"ts", tsEncode2,
"auth", auth,
"shareId", shareId,
"appToken", token != null ? token : "");
String param = CommonUtils.urlBase64Encode(entries.encode());
if (fileJson.getInteger("fileType") == 2) {
// 如果是目录
fileInfo.setFileName(fileJson.getString("name"))
.setFileId(fileJson.getString("folderId"))
.setCreateTime(fileJson.getString("updTime"))
.setFileType("folder")
.setSize(0L)
.setSizeStr("0B")
.setCreateBy(fileJson.getLong("userId").toString())
.setDownloadCount(fileJson.getInteger("fileDownloads"))
.setCreateTime(fileJson.getString("updTime"))
.setFileIcon(fileJson.getString("fileIcon"))
.setPanType(shareLinkInfo.getType())
// 设置目录解析的URL
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&uuid=%s", getDomainName(),
shareLinkInfo.getShareUrl(), fileJson.getString("folderId"), uuid));
result.add(fileInfo);
return;
}
long fileSize = fileJson.getLong("fileSize") * 1024;
fileInfo.setFileName(fileJson.getString("fileName"))
.setFileId(fileId)
.setCreateTime(fileJson.getString("createTime"))
.setFileType("file")
.setSize(fileSize)
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
.setCreateBy(fileJson.getLong("userId").toString())
.setDownloadCount(fileJson.getInteger("fileDownloads"))
.setCreateTime(fileJson.getString("updTime"))
.setFileIcon(fileJson.getString("fileIcon"))
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(),
shareLinkInfo.getType(), param))
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(),
shareLinkInfo.getType(), param));
result.add(fileInfo);
});
promise.complete(result);
} catch (Exception e) {
log.error("处理目录响应异常: {}", e.getMessage(), e);
promise.fail("目录解析失败: " + e.getMessage());
}
}
@Override
public Future<String> parseById() {
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
String appToken = paramJson.getString("appToken", "");
// 如果有 token使用 VIP 接口
if (StringUtils.isNotBlank(appToken)) {
log.debug("parseById 使用 VIP 接口, appToken={}", appToken.substring(0, Math.min(8, appToken.length())) + "...");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.putHeaders(header)
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
.setTemplateParam("uuid", paramJson.getString("uuid"))
.setTemplateParam("ts", paramJson.getString("ts"))
.setTemplateParam("auth", paramJson.getString("auth"))
.setTemplateParam("appToken", appToken)
.send().onSuccess(this::down).onFailure(handleFail("parseById-VIP"));
} else {
// token使用免登录接口
log.debug("parseById 使用免登录接口");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
.setTemplateParam("uuid", paramJson.getString("uuid"))
.setTemplateParam("ts", paramJson.getString("ts"))
.setTemplateParam("auth", paramJson.getString("auth"))
.setTemplateParam("dataKey", paramJson.getString("shareId"))
.send().onSuccess(this::down).onFailure(handleFail("parseById"));
}
return promise.future();
}
public static void resetToken() {
token = null;
authFlag = true;
}
}
@@ -1,30 +1,74 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.FileSizeConverter;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Random;
import java.util.UUID;
/**
* <a href="https://lecloud.lenovo.com/">联想乐云</a>
*/
public class LeTool extends PanBase {
private static final String API_URL_PREFIX = "https://lecloud.lenovo.com/share/api/clouddiskapi/share/public/v1/";
private static final String API_URL_PREFIX = "https://lecloud.lenovo.com/mshare/api/clouddiskapi/share/public/v1/";
private static final String DEFAULT_FILE_TYPE = "file";
private static final int FILE_TYPE_DIRECTORY = 0; // 目录类型
private static final Random RANDOM = new Random();
private static final MultiMap HEADERS;
static {
HEADERS = MultiMap.caseInsensitiveMultiMap();
HEADERS.set("Accept", "application/json, text/plain, */*");
HEADERS.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6");
HEADERS.set("Cache-Control", "no-cache");
HEADERS.set("Connection", "keep-alive");
HEADERS.set("Content-Type", "application/json");
HEADERS.set("DNT", "1");
HEADERS.set("Origin", "https://lecloud.lenovo.com");
HEADERS.set("Pragma", "no-cache");
HEADERS.set("Sec-Fetch-Dest", "empty");
HEADERS.set("Sec-Fetch-Mode", "cors");
HEADERS.set("Sec-Fetch-Site", "same-origin");
HEADERS.set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1 Edg/143.0.0.0");
}
public LeTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
/**
* 获取干净的 shareId去掉可能的查询参数
* URL https://lecloud.lenovo.com/share/5eoN3RA5PLhQcH4zE?path=... 会导致 shareKey 包含查询参数
*/
private String getCleanShareId() {
String shareKey = shareLinkInfo.getShareKey();
if (shareKey != null && shareKey.contains("?")) {
return shareKey.split("\\?")[0];
}
return shareKey;
}
public Future<String> parse() {
final String dataKey = shareLinkInfo.getShareKey();
final String dataKey = getCleanShareId();
final String pwd = shareLinkInfo.getSharePassword();
// {"shareId":"xxx","password":"xxx","directoryId":"-1"}
String apiUrl1 = API_URL_PREFIX + "shareInfo";
client.postAbs(apiUrl1)
.sendJsonObject(JsonObject.of("shareId", dataKey, "password", pwd, "directoryId", -1))
.putHeaders(HEADERS)
.sendJsonObject(JsonObject.of("shareId", dataKey, "password", pwd, "directoryId", "-1"))
.onSuccess(res -> {
JsonObject resJson = asJson(res);
if (resJson.containsKey("result")) {
@@ -44,10 +88,22 @@ public class LeTool extends PanBase {
}
JsonObject fileInfoJson = files.getJsonObject(0);
if (fileInfoJson != null) {
// TODO 文件大小fileSize和文件名fileName
// Extract and populate FileInfo
FileInfo fileInfo = createFileInfo(fileInfoJson);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
// 判断是否为目录
Integer fileType = fileInfoJson.getInteger("fileType");
if (fileType != null && fileType == FILE_TYPE_DIRECTORY) {
// 如果是目录返回目录ID
String fileId = fileInfoJson.getString("fileId");
promise.complete(fileId);
return;
}
String fileId = fileInfoJson.getString("fileId");
// 根据文件ID获取跳转链接
getDownURL(dataKey, fileId);
// 根据文件ID获取跳转链接随机选择方式失败自动fallback
getDownURLWithFallback(dataKey, fileId);
}
} else {
fail("{}: {}", resJson.getString("errcode"), resJson.getString("errmsg"));
@@ -59,13 +115,213 @@ public class LeTool extends PanBase {
return promise.future();
}
private void getDownURL(String key, String fileId) {
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> listPromise = Promise.promise();
String dataKey = getCleanShareId();
// 如果参数里的目录ID不为空则直接解析目录
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
if (dirId == null || dirId.isEmpty()) {
// 如果没有指定目录ID使用根目录ID "-1"
dirId = "-1";
}
// 直接请求shareInfo接口解析目录
parseDirectory(dirId, dataKey, listPromise);
return listPromise.future();
}
/**
* 解析目录下的文件列表
*/
private void parseDirectory(String directoryId, String shareId, Promise<List<FileInfo>> promise) {
String pwd = shareLinkInfo.getSharePassword();
if (pwd == null) {
pwd = "";
}
String apiUrl = API_URL_PREFIX + "shareInfo";
JsonObject requestBody = JsonObject.of("shareId", shareId, "password", pwd, "directoryId", directoryId);
log.info("解析目录请求: url={}, body={}", apiUrl, requestBody.encode());
client.postAbs(apiUrl)
.putHeaders(HEADERS)
.sendJsonObject(requestBody)
.onSuccess(res -> {
JsonObject resJson = asJson(res);
if (!resJson.containsKey("result") || !resJson.getBoolean("result")) {
promise.fail("解析目录失败: " + resJson.encode());
return;
}
JsonObject dataJson = resJson.getJsonObject("data");
if (!dataJson.getBoolean("passwordVerified")) {
promise.fail("密码验证失败");
return;
}
JsonArray files = dataJson.getJsonArray("files");
if (files == null || files.isEmpty()) {
promise.complete(new ArrayList<>());
return;
}
List<FileInfo> fileList = new ArrayList<>();
for (int i = 0; i < files.size(); i++) {
JsonObject fileJson = files.getJsonObject(i);
FileInfo fileInfo = createFileInfoForList(fileJson, shareId);
fileList.add(fileInfo);
}
promise.complete(fileList);
})
.onFailure(err -> {
log.error("解析目录请求失败: {}", err.getMessage());
promise.fail(err);
});
}
/**
* 为文件列表创建 FileInfo 对象
*/
private FileInfo createFileInfoForList(JsonObject fileJson, String shareId) {
FileInfo fileInfo = new FileInfo();
try {
String fileId = fileJson.getString("fileId");
String fileName = fileJson.getString("fileName");
Long fileSize = fileJson.getLong("fileSize");
Integer fileType = fileJson.getInteger("fileType");
fileInfo.setFileId(fileId);
fileInfo.setFileName(fileName);
fileInfo.setPanType(shareLinkInfo.getType());
// 判断是否为目录
if (fileType != null && fileType == FILE_TYPE_DIRECTORY) {
// 目录类型
fileInfo.setFileType("folder");
fileInfo.setSize(0L);
fileInfo.setSizeStr("0B");
// 设置目录解析的URL - fileId 需要进行 URL 编码以保持特殊字符的编码状态
try {
String encodedFileId = URLEncoder.encode(fileId, "UTF-8");
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s",
getDomainName(),
shareLinkInfo.getShareUrl(),
encodedFileId));
} catch (UnsupportedEncodingException e) {
log.error("URL编码失败: {}", e.getMessage());
// 降级方案直接使用原始 fileId
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s",
getDomainName(),
shareLinkInfo.getShareUrl(),
fileId));
}
} else {
// 文件类型
fileInfo.setFileType(fileType != null ? String.valueOf(fileType) : DEFAULT_FILE_TYPE);
fileInfo.setSize(fileSize);
fileInfo.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize));
// 创建参数JSON并编码为Base64
JsonObject paramJson = JsonObject.of(
"shareId", shareId,
"fileId", fileId
);
String paramBase64 = Base64.getEncoder().encodeToString(paramJson.encode().getBytes());
// 设置解析URL和预览URL
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(),
shareLinkInfo.getType(),
paramBase64))
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s",
getDomainName(),
shareLinkInfo.getType(),
paramBase64));
}
} catch (Exception e) {
log.warn("创建文件信息失败: {}", e.getMessage());
}
return fileInfo;
}
@Override
public Future<String> parseById() {
Promise<String> parsePromise = Promise.promise();
try {
// 从参数中获取解析所需的信息
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
String shareId = paramJson.getString("shareId");
String fileId = paramJson.getString("fileId");
// 调用获取下载链接随机选择方式失败自动fallback
getDownURLWithFallbackForById(shareId, fileId, parsePromise);
} catch (Exception e) {
parsePromise.fail("解析参数失败: " + e.getMessage());
}
return parsePromise.future();
}
/**
* 根据文件ID获取下载URL (用于 parseById)
*/
private void getDownURLForById(String shareId, String fileId, Promise<String> promise) {
String uuid = UUID.randomUUID().toString();
JsonArray fileIds = JsonArray.of(fileId);
String apiUrl2 = API_URL_PREFIX + "packageDownloadWithFileIds";
String apiUrl = API_URL_PREFIX + "packageDownloadWithFileIds";
client.postAbs(apiUrl)
.putHeaders(HEADERS)
.sendJsonObject(JsonObject.of("fileIds", fileIds, "shareId", shareId, "browserId", uuid))
.onSuccess(res -> {
JsonObject resJson = asJson(res);
if (resJson.containsKey("result")) {
if (resJson.getBoolean("result")) {
JsonObject dataJson = resJson.getJsonObject("data");
String downloadUrl = dataJson.getString("downloadUrl");
if (downloadUrl == null) {
promise.fail("Result JSON数据异常: downloadUrl不存在");
return;
}
// 获取重定向链接
clientNoRedirects.getAbs(downloadUrl).send()
.onSuccess(res2 -> promise.complete(res2.headers().get("Location")))
.onFailure(err -> promise.fail(err));
} else {
promise.fail(resJson.getString("errcode") + ": " + resJson.getString("errmsg"));
}
} else {
promise.fail("Result JSON数据异常: result字段不存在");
}
}).onFailure(err -> promise.fail(err));
}
/**
* 通过 packageDownloadWithFileIds 接口获取下载链接
* 需要两步先获取 downloadUrl再请求 302 跳转
*
* @param shareId 分享ID
* @param fileId 文件ID
* @param promise 完成时会写入此 promise
*/
private void getDownURL(String shareId, String fileId, Promise<String> promise) {
String uuid = UUID.randomUUID().toString();
JsonArray fileIds = JsonArray.of(fileId);
String apiUrl = API_URL_PREFIX + "packageDownloadWithFileIds";
// {"fileIds":[123],"shareId":"xxx","browserId":"uuid"}
client.postAbs(apiUrl2)
.sendJsonObject(JsonObject.of("fileIds", fileIds, "shareId", key, "browserId", uuid))
client.postAbs(apiUrl)
.putHeaders(HEADERS)
.sendJsonObject(JsonObject.of("fileIds", fileIds, "shareId", shareId, "browserId", uuid))
.onSuccess(res -> {
JsonObject resJson = asJson(res);
if (resJson.containsKey("result")) {
@@ -74,19 +330,153 @@ public class LeTool extends PanBase {
// 获取重定向链接跳转链接
String downloadUrl = dataJson.getString("downloadUrl");
if (downloadUrl == null) {
fail("Result JSON数据异常: downloadUrl不存在");
promise.fail("Result JSON数据异常: downloadUrl不存在");
return;
}
// 获取重定向链接跳转链接
clientNoRedirects.getAbs(downloadUrl).send()
.onSuccess(res2 -> promise.complete(res2.headers().get("Location")))
.onFailure(handleFail(downloadUrl));
.onFailure(err -> promise.fail(err));
} else {
fail("{}: {}", resJson.getString("errcode"), resJson.getString("errmsg"));
promise.fail(resJson.getString("errcode") + ": " + resJson.getString("errmsg"));
}
} else {
fail("Result JSON数据异常: result字段不存在");
promise.fail("Result JSON数据异常: result字段不存在");
}
}).onFailure(handleFail(apiUrl2));
}).onFailure(err -> promise.fail(err));
}
/**
* 通过 directDownload 接口获取下载链接
* 相比 packageDownloadWithFileIds 少一次请求直接返回302
*
* @param shareId 分享ID
* @param fileId 文件ID
* @param promise 完成时会写入此 promise
*/
private void getDownURLDirect(String shareId, String fileId, Promise<String> promise) {
String uuid = UUID.randomUUID().toString();
String apiUrl = API_URL_PREFIX + "directDownload"
+ "?shareId=" + shareId
+ "&fileId=" + fileId
+ "&browserId=" + uuid;
clientNoRedirects.getAbs(apiUrl)
.putHeaders(HEADERS)
.send()
.onSuccess(res -> {
String location = res.headers().get("Location");
if (location != null && !location.isEmpty()) {
promise.complete(location);
} else {
log.warn("directDownload 返回非302响应: shareId={}, fileId={}, statusCode={}", shareId, fileId, res.statusCode());
promise.fail("directDownload 未返回有效的 Location, statusCode=" + res.statusCode());
}
})
.onFailure(err -> {
log.warn("directDownload 请求失败: shareId={}, fileId={}, error={}", shareId, fileId, err.getMessage());
promise.fail(err);
});
}
/**
* 随机选择下载方式并带 fallback用于 parse
* 先随机选择 directDownload packageDownloadWithFileIds失败则尝试另一个
*/
private void getDownURLWithFallback(String shareId, String fileId) {
boolean useDirect = RANDOM.nextBoolean();
log.info("乐云下载方式选择: shareId={}, fileId={}, method={}", shareId, fileId, useDirect ? "directDownload" : "packageDownloadWithFileIds");
Promise<String> fallbackPromise = Promise.promise();
fallbackPromise.future().onSuccess(url -> {
promise.complete(url);
}).onFailure(err -> {
log.warn("乐云第一种下载方式失败,尝试另一种: {}", err.getMessage());
if (useDirect) {
getDownURL(shareId, fileId, promise);
} else {
getDownURLDirect(shareId, fileId, promise);
}
});
if (useDirect) {
getDownURLDirect(shareId, fileId, fallbackPromise);
} else {
getDownURL(shareId, fileId, fallbackPromise);
}
}
/**
* 随机选择下载方式并带 fallback用于 parseById
* 先随机选择 directDownload packageDownloadWithFileIds失败则尝试另一个
*/
private void getDownURLWithFallbackForById(String shareId, String fileId, Promise<String> promise) {
boolean useDirect = RANDOM.nextBoolean();
log.info("乐云下载方式选择(parseById): shareId={}, fileId={}, method={}", shareId, fileId, useDirect ? "directDownload" : "packageDownloadWithFileIds");
Promise<String> fallbackPromise = Promise.promise();
fallbackPromise.future().onSuccess(url -> {
promise.complete(url);
}).onFailure(err -> {
log.warn("乐云第一种下载方式失败,尝试另一种: {}", err.getMessage());
if (useDirect) {
getDownURLForById(shareId, fileId, promise);
} else {
getDownURLDirect(shareId, fileId, promise);
}
});
if (useDirect) {
getDownURLDirect(shareId, fileId, fallbackPromise);
} else {
getDownURLForById(shareId, fileId, fallbackPromise);
}
}
/**
* Create FileInfo object from JSON response
* Uses exact field names from the API response without fallback checks
*/
private FileInfo createFileInfo(JsonObject fileInfoJson) {
FileInfo fileInfo = new FileInfo();
try {
// Set fileId
String fileId = fileInfoJson.getString("fileId");
if (fileId != null) {
fileInfo.setFileId(fileId);
}
// Set fileName
String fileName = fileInfoJson.getString("fileName");
if (fileName != null) {
fileInfo.setFileName(fileName);
}
// Set file size
Long fileSize = fileInfoJson.getLong("fileSize");
if (fileSize != null) {
fileInfo.setSize(fileSize);
// Convert to readable size string
fileInfo.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize));
}
// Set fileType (API returns it as an integer)
Integer fileTypeInt = fileInfoJson.getInteger("fileType");
if (fileTypeInt != null) {
fileInfo.setFileType(String.valueOf(fileTypeInt));
} else {
// Default to generic file type if not available
fileInfo.setFileType(DEFAULT_FILE_TYPE);
}
// Set panType
fileInfo.setPanType(shareLinkInfo.getType());
} catch (Exception e) {
log.warn("Error extracting file info from JSON: {}", e.getMessage());
}
return fileInfo;
}
}
@@ -11,14 +11,13 @@ import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.client.WebClientSession;
import org.apache.commons.lang3.RegExUtils;
import org.openjdk.nashorn.api.scripting.ScriptObjectMirror;
import javax.script.ScriptException;
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -29,13 +28,14 @@ import java.util.regex.Pattern;
*/
public class LzTool extends PanBase {
public static final String SHARE_URL_PREFIX = "https://wwwwp.lanzoup.com";
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
public static final String SHARE_URL_PREFIX = "https://w1.lanzn.com/";
MultiMap headers0 = HeaderUtils.parseHeaders("""
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Cache-Control: max-age=0
Cookie: codelen=1; pc_ad1=1
DNT: 1
Priority: u=0, i
Sec-CH-UA: "Chromium";v="140", "Not=A?Brand";v="24", "Microsoft Edge";v="140"
@@ -63,53 +63,134 @@ public class LzTool extends PanBase {
.putHeaders(headers0)
.send().onSuccess(res -> {
String html = asText(res);
try {
setFileInfo(html, shareLinkInfo);
} catch (Exception e) {
e.printStackTrace();
}
// 匹配iframe
Pattern compile = Pattern.compile("src=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\"");
Matcher matcher = compile.matcher(html);
// 没有Iframe说明是加密分享, 匹配sign通过密码请求下载页面
if (!matcher.find()) {
try {
String jsText = getJsByPwd(pwd, html, "document.getElementById('rpt')");
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "down_p");
getDownURL(sUrl, client, scriptObjectMirror);
} catch (Exception e) {
fail(e, "js引擎执行失败");
}
} else {
// 没有密码
String iframePath = matcher.group(1);
client.getAbs(SHARE_URL_PREFIX + iframePath).send().onSuccess(res2 -> {
String html2 = res2.bodyAsString();
if (html.contains("var arg1='")) {
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(html, sUrl);
webClientSession.getAbs(sUrl)
.putHeaders(headers0)
.send().onSuccess(res2 -> {
String html2 = asText(res2);
doParser(html2, pwd, sUrl);
});
// 去TMD正则
// Matcher matcher2 = Pattern.compile("'sign'\s*:\s*'(\\w+)'").matcher(html2);
String jsText = getJsText(html2);
if (jsText == null) {
fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": js脚本匹配失败, 可能分享已失效");
return;
}
try {
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, null);
getDownURL(sUrl, client, scriptObjectMirror);
} catch (ScriptException | NoSuchMethodException e) {
fail(e, "js引擎执行失败");
}
}).onFailure(handleFail(SHARE_URL_PREFIX));
} else {
doParser(html, pwd, sUrl);
}
}).onFailure(handleFail(sUrl));
return promise.future();
}
private void doParser(String html, String pwd, String sUrl) {
// 检测是否为目录分享链接 ( /s//b/ 路径段或 b 开头的路径段)
if (sUrl.matches(".*/(s|b)/[^/]+.*") || sUrl.matches(".*/b[^/]+.*")) {
fail("该链接为蓝奏云目录分享,请使用目录解析接口");
return;
}
// 若仍是校验页 (parse()中cookie域名与实际URL不匹配时会出现), 重试一次
if (html.contains("var arg1='")) {
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(html, sUrl);
webClientSession.getAbs(sUrl).putHeaders(headers0).send().onSuccess(res -> {
String html2 = asText(res);
if (html2.contains("var arg1='")) {
fail("蓝奏云反爬校验失败,请稍后重试");
return;
}
doParserInternal(html2, pwd, sUrl);
}).onFailure(handleFail(sUrl));
return;
}
doParserInternal(html, pwd, sUrl);
}
private void doParserInternal(String html, String pwd, String sUrl) {
try {
setFileInfo(html, shareLinkInfo);
} catch (Exception e) {
log.error("文件信息解析异常", e);
}
// 匹配iframe
Pattern compile = Pattern.compile("src=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\"");
Matcher matcher = compile.matcher(html);
// 没有Iframe说明是加密分享, 匹配sign通过密码请求下载页面
if (!matcher.find()) {
try {
String jsText = getJsByPwd(pwd, html, "document.getElementById('rpt')");
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "down_p");
getDownURL(sUrl, scriptObjectMirror);
} catch (Exception e) {
fail(e, "js引擎执行失败");
}
} else {
// 没有密码
String iframePath = matcher.group(1);
String absoluteURI = SHARE_URL_PREFIX + iframePath;
webClientSession.getAbs(absoluteURI).putHeaders(headers0).send().onSuccess(res2 -> {
String html2 = asText(res2);
String jsText = getJsText(html2);
if (jsText == null) {
headers0.add("Referer", absoluteURI);
setCookie(html2, absoluteURI);
webClientSession.getAbs(absoluteURI).send().onSuccess(res3 -> {
String html3 = asText(res3);
String jsText3 = getJsText(html3);
if (jsText3 != null) {
try {
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText3, null);
getDownURL(sUrl, scriptObjectMirror);
} catch (ScriptException | NoSuchMethodException e) {
fail(e, "引擎执行失败");
}
} else {
fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效");
}
});
} else {
try {
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, null);
getDownURL(sUrl, scriptObjectMirror);
} catch (ScriptException | NoSuchMethodException e) {
fail(e, "js引擎执行失败");
}
}
}).onFailure(handleFail(SHARE_URL_PREFIX));
}
}
private void setCookie(String html, String url) {
int beginIndex = html.indexOf("arg1='") + 6;
int endIndex = html.indexOf("';", beginIndex);
if (beginIndex < 6 || endIndex == -1 || endIndex <= beginIndex) {
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
return;
}
String arg1 = html.substring(beginIndex, endIndex);
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
// URL 中动态提取域名 lanzoum.com, lanzoux.com
String domain = ".lanzn.com"; // 默认兜底
try {
java.net.URL urlObj = new java.net.URL(url);
String host = urlObj.getHost(); // e.g. "dzvip.lanzoum.com"
int firstDot = host.indexOf('.');
if (firstDot >= 0) {
domain = host.substring(firstDot); // e.g. ".lanzoum.com"
}
} catch (MalformedURLException ignored) {}
// 创建一个 Cookie 并放入 CookieStore
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
nettyCookie.setDomain(domain);
nettyCookie.setPath("/");
nettyCookie.setSecure(false);
nettyCookie.setHttpOnly(false);
webClientSession.cookieStore().put(nettyCookie);
}
private String getJsByPwd(String pwd, String html, String subText) {
String jsText = getJsText(html);
if (jsText == null) {
throw new RuntimeException("js脚本匹配失败, 可能分享已失效");
throw new RuntimeException("获取失败1, 可能分享已失效");
}
jsText = jsText.replace("document.getElementById('pwd').value", "\"" + pwd + "\"");
int i = jsText.indexOf(subText);
@@ -131,13 +212,13 @@ public class LzTool extends PanBase {
return html.substring(startPos, endPos).replaceAll("<!--.*-->", "");
}
private void getDownURL(String key, WebClient client, Map<String, ?> obj) {
private void getDownURL(String key, Map<String, ?> obj) {
if (obj == null) {
fail("需要访问密码");
return;
}
Map<?, ?> signMap = (Map<?, ?>)obj.get("data");
String url0 = obj.get("url").toString();
String url0 = String.valueOf(obj.get("url"));
MultiMap map = MultiMap.caseInsensitiveMultiMap();
signMap.forEach((k, v) -> {
map.add((String) k, v.toString());
@@ -163,7 +244,7 @@ public class LzTool extends PanBase {
headers.set("referer", key);
// action=downprocess&signs=%3Fctdf&websignkey=I5gl&sign=BWMGOF1sBTRWXwI9BjZdYVA7BDhfNAIyUG9UawJtUGMIPlAhACkCa1UyUTAAYFxvUj5XY1E7UGFXaFVq&websign=&kd=1&ves=1
String url = SHARE_URL_PREFIX + url0;
client.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> {
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> {
try {
JsonObject urlJson = asJson(res2);
String name = urlJson.getString("inf");
@@ -172,13 +253,12 @@ public class LzTool extends PanBase {
return;
}
// 文件名
if (urlJson.containsKey("inf") && urlJson.getMap().get("inf") instanceof Character) {
if (urlJson.containsKey("inf") && urlJson.getMap().get("inf") instanceof CharSequence) {
((FileInfo)shareLinkInfo.getOtherParam().get("fileInfo")).setFileName(name);
}
String downUrl = urlJson.getString("dom") + "/file/" + urlJson.getString("url");
headers.remove("Referer");
WebClientSession webClientSession = WebClientSession.create(client);
webClientSession.getAbs(downUrl).putHeaders(headers).send()
.onSuccess(res3 -> {
String location = res3.headers().get("Location");
@@ -189,25 +269,34 @@ public class LzTool extends PanBase {
int beginIndex = text.indexOf("arg1='") + 6;
String arg1 = text.substring(beginIndex, text.indexOf("';", beginIndex));
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
// downUrl 中动态提取域名
String downDomain = ".lanrar.com";
try {
java.net.URL du = new java.net.URL(downUrl);
String h = du.getHost();
int dot = h.indexOf('.');
if (dot >= 0) downDomain = h.substring(dot);
} catch (MalformedURLException ignored) {}
// 创建一个 Cookie 并放入 CookieStore
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
nettyCookie.setDomain(".lanrar.com"); // 设置域名
nettyCookie.setPath("/"); // 设置路径
nettyCookie.setDomain(downDomain);
nettyCookie.setPath("/");
nettyCookie.setSecure(false);
nettyCookie.setHttpOnly(false);
webClientSession.cookieStore().put(nettyCookie);
webClientSession.getAbs(downUrl).putHeaders(headers).send()
WebClientSession webClientSession2 = WebClientSession.create(clientNoRedirects);
webClientSession2.cookieStore().put(nettyCookie);
webClientSession2.getAbs(downUrl).putHeaders(headers).send()
.onSuccess(res4 -> {
String location0 = res4.headers().get("Location");
if (location0 == null) {
fail(downUrl + " -> 直链获取失败, 可能分享已失效");
fail(downUrl + " -> 直链获取失败2, 可能分享已失效");
} else {
setDateAndComplate(location0);
setDateAndComplete(location0);
}
}).onFailure(handleFail(downUrl));
return;
}
setDateAndComplate(location);
setDateAndComplete(location);
})
.onFailure(handleFail(downUrl));
} catch (Exception e) {
@@ -216,7 +305,7 @@ public class LzTool extends PanBase {
}).onFailure(handleFail(url));
}
private void setDateAndComplate(String location0) {
private void setDateAndComplete(String location0) {
// 分享时间 提取url中的时间戳格式lanzoui.com/abc/abc/yyyy/mm/dd/
String regex = "(\\d{4}/\\d{1,2}/\\d{1,2})";
Matcher matcher = Pattern.compile(regex).matcher(location0);
@@ -248,67 +337,118 @@ public class LzTool extends PanBase {
String sUrl = shareLinkInfo.getShareUrl();
String pwd = shareLinkInfo.getSharePassword();
WebClient client = clientNoRedirects;
client.getAbs(sUrl).send().onSuccess(res -> {
String html = res.bodyAsString();
try {
String jsText = getJsByPwd(pwd, html, "var urls =window.location.href");
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "file");
Map<String, Object> data = CastUtil.cast(scriptObjectMirror.get("data"));
MultiMap map = MultiMap.caseInsensitiveMultiMap();
data.forEach((k, v) -> map.set(k, v.toString()));
log.debug("解析参数: {}", map);
MultiMap headers = getHeaders(sUrl);
String url = SHARE_URL_PREFIX + "/filemoreajax.php?file=" + data.get("fid");
client.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> {
JsonObject fileListJson = asJson(res2);
if (fileListJson.getInteger("zt") != 1) {
promise.fail(baseMsg() + fileListJson.getString("info"));
return;
}
List<FileInfo> list = new ArrayList<>();
fileListJson.getJsonArray("text").forEach(item -> {
/*
{
"icon": "apk",
"t": 0,
"id": "iULV2n4361c",
"name_all": "xx.apk",
"size": "49.8 M",
"time": "2021-03-19",
"duan": "in4361",
"p_ico": 0
}
*/
JsonObject fileJson = (JsonObject) item;
FileInfo fileInfo = new FileInfo();
String size = fileJson.getString("size");
Long sizeNum = FileSizeConverter.convertToBytes(size);
String panType = shareLinkInfo.getType();
String id = fileJson.getString("id");
fileInfo.setFileName(fileJson.getString("name_all"))
.setFileId(id)
.setCreateTime(fileJson.getString("time"))
.setFileType(fileJson.getString("icon"))
.setSizeStr(fileJson.getString("size"))
.setSize(sizeNum)
.setPanType(panType)
.setParserUrl(getDomainName() + "/d/" + panType + "/" + id)
.setPreviewUrl(String.format("%s/v2/view/%s/%s", getDomainName(),
shareLinkInfo.getType(), id));
log.debug("文件信息: {}", fileInfo);
list.add(fileInfo);
});
promise.complete(list);
});
} catch (ScriptException | NoSuchMethodException e) {
promise.fail(e);
webClientSession.getAbs(sUrl).send().onSuccess(res -> {
String html = asText(res);
// 检查是否需要 cookie 验证
if (html.contains("var arg1='")) {
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(html, sUrl);
// 重新请求
webClientSession.getAbs(sUrl).send().onSuccess(res2 -> {
handleFileListParse(asText(res2), pwd, sUrl, promise);
}).onFailure(err -> promise.fail(err));
return;
}
});
handleFileListParse(html, pwd, sUrl, promise);
}).onFailure(err -> promise.fail(err));
return promise.future();
}
private void handleFileListParse(String html, String pwd, String sUrl, Promise<List<FileInfo>> promise) {
// 检测是否为文件分享链接 (不含 /s//b/ 路径段且不含 b 开头的路径段)
if (!sUrl.matches(".*/(s|b)/[^/]+.*") && !sUrl.matches(".*/b[^/]+.*")) {
promise.fail(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口");
return;
}
try {
String jsText = getJsByPwd(pwd, html, "var urls =window.location.href");
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "file");
Map<String, Object> data = CastUtil.cast(scriptObjectMirror.get("data"));
MultiMap map = MultiMap.caseInsensitiveMultiMap();
data.forEach((k, v) -> map.set(k, v.toString()));
log.debug("解析参数: {}", map);
MultiMap headers = getHeaders(sUrl);
String url = SHARE_URL_PREFIX + "filemoreajax.php?file=" + data.get("fid");
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> {
String resBody = asText(res2);
// 再次检查是否需要 cookie 验证
if (resBody.contains("var arg1='")) {
setCookie(resBody, url);
// 重新请求
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res3 -> {
handleFileListResponse(asText(res3), promise);
}).onFailure(err -> promise.fail(err));
return;
}
handleFileListResponse(resBody, promise);
}).onFailure(err -> promise.fail(err));
} catch (ScriptException | NoSuchMethodException | RuntimeException e) {
promise.fail(e);
}
}
private void handleFileListResponse(String responseBody, Promise<List<FileInfo>> promise) {
try {
JsonObject fileListJson = new JsonObject(responseBody);
if (fileListJson.getInteger("zt") != 1) {
promise.fail(baseMsg() + fileListJson.getString("info"));
return;
}
List<FileInfo> list = new ArrayList<>();
fileListJson.getJsonArray("text").forEach(item -> {
/*
{
"icon": "apk",
"t": 0,
"id": "iULV2n4361c",
"name_all": "xx.apk",
"size": "49.8 M",
"time": "2021-03-19",
"duan": "in4361",
"p_ico": 0
}
*/
JsonObject fileJson = (JsonObject) item;
FileInfo fileInfo = new FileInfo();
String size = fileJson.getString("size");
Long sizeNum = FileSizeConverter.convertToBytes(size);
String panType = shareLinkInfo.getType();
String id = fileJson.getString("id");
String fileName = fileJson.getString("name_all");
// 构建 base64 参数用于 /v2/redirectUrl 接口
JsonObject paramJson = new JsonObject()
.put("id", id)
.put("fileName", fileName);
String param = CommonUtils.urlBase64Encode(paramJson.encode());
fileInfo.setFileName(fileName)
.setFileId(id)
.setCreateTime(fileJson.getString("time"))
.setFileType(fileJson.getString("icon"))
.setSizeStr(fileJson.getString("size"))
.setSize(sizeNum)
.setPanType(panType)
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(), panType, param))
.setPreviewUrl(String.format("%s/v2/view/%s/%s", getDomainName(),
shareLinkInfo.getType(), id));
log.debug("文件信息: {}", fileInfo);
list.add(fileInfo);
});
promise.complete(list);
} catch (Exception e) {
promise.fail(e);
}
}
@Override
public Future<String> parseById() {
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
String id = paramJson.getString("id");
// 以文件ID重新构造标准访问URL复用 parse() 流程
shareLinkInfo.setStandardUrl(SHARE_URL_PREFIX + id);
return parse();
}
void setFileInfo(String html, ShareLinkInfo shareLinkInfo) {
// 写入 fileInfo
FileInfo fileInfo = new FileInfo();
@@ -323,16 +463,17 @@ public class LzTool extends PanBase {
String fileId = CommonUtils.extract(html, Pattern.compile("\\?f=(.*?)&|fid = (.*?);"));
String createTime = CommonUtils.extract(html, Pattern.compile(">上传时间:</span>(.*?)<"));
try {
long bytes = FileSizeConverter.convertToBytes(sizeStr);
fileInfo.setFileName(fileName)
.setSize(bytes)
.setSizeStr(FileSizeConverter.convertToReadableSize(bytes))
.setCreateBy(createBy)
.setPanType(shareLinkInfo.getType())
.setDescription(description)
.setFileType("file")
.setFileId(fileId)
.setCreateTime(createTime);
if (sizeStr != null && !sizeStr.isBlank()) {
long bytes = FileSizeConverter.convertToBytes(sizeStr);
fileInfo.setSize(bytes).setSizeStr(FileSizeConverter.convertToReadableSize(bytes));
}
} catch (Exception e) {
log.warn("文件信息解析异常", e);
}
@@ -86,10 +86,10 @@ public class MkgsTool extends PanBase {
// 查找并输出 hash 字段的值
if (matcher.find()) {
String hashValue = matcher.group(1); // 获取第一个捕获组
System.out.println(hashValue);
log.debug("hash: {}", hashValue);
client.getAbs(UriTemplate.of(API_URL)).setTemplateParam("hash", hashValue).send().onSuccess(res3 -> {
JsonObject jsonObject = asJson(res3);
System.out.println(jsonObject.encodePrettily());
log.debug("API response: {}", jsonObject.encodePrettily());
if (jsonObject.containsKey("url")) {
promise.complete(jsonObject.getString("url"));
} else {
@@ -29,19 +29,19 @@ public class MkwTool extends PanBase {
clientSession.getAbs(shareUrl).send().onSuccess(result -> {
String cookie = result.headers().get("set-cookie");
if (!cookie.isEmpty()) {
if (cookie != null && !cookie.isEmpty()) {
String regex = "([A-Za-z0-9_]+)=([A-Za-z0-9]+)";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(cookie);
if (matcher.find()) {
System.out.println(matcher.group(1));
System.out.println(matcher.group(2));
log.debug("cookie key: {}", matcher.group(1));
log.debug("cookie value: {}", matcher.group(2));
var key = matcher.group(1);
var token = matcher.group(2);
String sign = JsExecUtils.getKwSign(token, key);
System.out.println(sign);
log.debug("sign: {}", sign);
clientSession.getAbs(UriTemplate.of(API_URL)).setTemplateParam("mid", shareLinkInfo.getShareKey())
.putHeader("Secret", sign).send().onSuccess(res -> {
JsonObject json = asJson(res);
@@ -54,7 +54,7 @@ public class MkwTool extends PanBase {
}
} catch (Exception e) {
e.printStackTrace();
log.error("解析失败", e);
fail("解析失败");
}
});
@@ -21,6 +21,8 @@ public class P115Tool extends PanBase {
private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "share/skip_login_downurl";
private static final String DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
private static final MultiMap header;
static {
@@ -49,9 +51,11 @@ public class P115Tool extends PanBase {
public Future<String> parse() {
// 第一次请求 获取文件信息
Object uaObj = shareLinkInfo.getOtherParam().get("UA");
String ua = uaObj != null ? uaObj.toString() : DEFAULT_UA;
client.getAbs(UriTemplate.of(FIRST_REQUEST_URL))
.putHeaders(header)
.putHeader("User-Agent", shareLinkInfo.getOtherParam().get("UA").toString())
.putHeader("User-Agent", ua)
.setTemplateParam("dataKey", shareLinkInfo.getShareKey())
.setTemplateParam("dataPwd", shareLinkInfo.getSharePassword())
.send().onSuccess(res -> {
@@ -68,7 +72,7 @@ public class P115Tool extends PanBase {
// share_code={dataKey}&receive_code={dataPwd}&file_id={file_id}
client.postAbs(SECOND_REQUEST_URL)
.putHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.putHeader("User-Agent", shareLinkInfo.getOtherParam().get("UA").toString())
.putHeader("User-Agent", ua)
.sendForm(MultiMap.caseInsensitiveMultiMap()
.set("share_code", shareLinkInfo.getShareKey())
.set("receive_code", shareLinkInfo.getSharePassword())
@@ -1,50 +0,0 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import io.vertx.core.Future;
import io.vertx.core.buffer.Buffer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 118网盘解析
*/
public class P118Tool extends PanBase {
private static final String API_URL_PREFIX = "https://qaiu.118pan.com/ajax.php";
// private static final String
public P118Tool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
public Future<String> parse() {
client.postAbs(API_URL_PREFIX)
.putHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.sendBuffer(Buffer.buffer("action=load_down_addr1&file_id=" + shareLinkInfo.getShareKey()))
.onSuccess(res -> {
System.out.println(res.headers());
Pattern compile = Pattern.compile("href=\"([^\"]+)\"");
Matcher matcher = compile.matcher(res.bodyAsString());
if (matcher.find()) {
//c: 0x63
//o: 0x6F
//m: 0x6D
//1: 0x31
///: 0x2F
char[] chars1 = new char[]{99, 111, 109, 49, 47};
char[] chars2 = new char[]{99, 111, 109, 47};
String group = matcher.group(1).replace(String.valueOf(chars1), String.valueOf(chars2));
System.out.println(group);
complete(group);
} else {
fail();
}
}).onFailure(handleFail(""));
return future();
}
}
@@ -1,11 +1,13 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.FileSizeConverter;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.json.JsonObject;
import io.vertx.uritemplate.UriTemplate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* <a href="https://passport2.chaoxing.com">超星云盘</a>
@@ -19,24 +21,135 @@ public class PcxTool extends PanBase {
public Future<String> parse() {
client.getAbs(shareLinkInfo.getShareUrl())
.send().onSuccess(res -> {
// 'download': 'https://d0.ananas.chaoxing.com/download/de08dcf546e4dd88a17bead86ff6338d?at_=1740211698795&ak_=d62a3acbd5ce43e1e8565b67990691e4&ad_=8c4ef22e980ee0dd9532ec3757ab19f8&fn=33.c'
String body = res.bodyAsString();
// 获取download
String str = "var fileinfo = {";
String fileInfo = res.bodyAsString().substring(res.bodyAsString().indexOf(str) + str.length() - 1
, res.bodyAsString().indexOf("};") + 1);
fileInfo = fileInfo.replace("'", "\"");
JsonObject jsonObject = new JsonObject(fileInfo);
String download = jsonObject.getString("download");
if (download.contains("fn=")) {
complete(download);
} else {
fail("获取下载链接失败: 不支持的文件类型: {}", jsonObject.getString("suffix"));
try {
// 提取文件信息
setFileInfo(body);
// 直接用正则提取download链接
String download = extractDownloadUrl(body);
if (download != null && download.contains("fn=")) {
complete(download);
} else {
fail("获取下载链接失败");
}
} catch (Exception e) {
fail("解析文件信息失败: {}", e.getMessage());
}
}).onFailure(handleFail(shareLinkInfo.getShareUrl()));
return promise.future();
}
/**
* 从HTML中提取download链接
*/
private String extractDownloadUrl(String html) {
// 匹配 'download': 'https://xxx' "download": "https://xxx"
Pattern pattern = Pattern.compile("['\"]download['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
Matcher matcher = pattern.matcher(html);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
/**
* 从HTML中提取文件信息并设置到shareLinkInfo
*/
private void setFileInfo(String html) {
try {
FileInfo fileInfo = new FileInfo();
// 提取文件名<title>标签或文件名input
String fileName = extractByRegex(html, "<title>([^<]+)</title>");
if (fileName == null) {
fileName = extractByRegex(html, "<input id=\"filename\" type=\"hidden\" value=\"([^\"]+)\"");
}
// 提取文件大小'filesize': 'xxx' "filesize": "xxx"
String fileSizeStr = extractByRegex(html, "['\"]filesize['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
Long fileSize = null;
if (fileSizeStr != null) {
try {
fileSize = Long.parseLong(fileSizeStr);
} catch (NumberFormatException ignored) {}
}
// 提取文件类型/后缀'suffix': 'xxx' "suffix": "xxx"
String suffix = extractByRegex(html, "['\"]suffix['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
// 提取objectId文件ID'objectId': 'xxx' "objectId": "xxx"
String objectId = extractByRegex(html, "['\"]objectId['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
// 提取创建者'creator': 'xxx' "creator": "xxx"
String creator = extractByRegex(html, "['\"]creator['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
// 提取上传时间'uploadDate': timestamp
String uploadDate = extractByRegex(html, "['\"]uploadDate['\"]\\s*:\\s*(\\d+)");
// 提取缩略图'thumbnail': 'xxx' "thumbnail": "xxx"
String thumbnail = extractByRegex(html, "['\"]thumbnail['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
// 设置文件信息
if (fileName != null) {
fileInfo.setFileName(fileName);
}
if (fileSize != null) {
fileInfo.setSize(fileSize);
fileInfo.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize));
}
if (suffix != null) {
fileInfo.setFileType(suffix);
}
if (objectId != null) {
fileInfo.setFileId(objectId);
}
if (creator != null) {
fileInfo.setCreateBy(creator);
}
if (uploadDate != null) {
try {
long timestamp = Long.parseLong(uploadDate);
// 转换为日期格式
java.time.Instant instant = java.time.Instant.ofEpochMilli(timestamp);
java.time.LocalDateTime dateTime = java.time.LocalDateTime.ofInstant(instant,
java.time.ZoneId.systemDefault());
fileInfo.setCreateTime(dateTime.format(
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
} catch (NumberFormatException ignored) {}
}
if (thumbnail != null) {
fileInfo.setPreviewUrl(thumbnail);
}
fileInfo.setPanType(shareLinkInfo.getType());
// 将文件信息存储到shareLinkInfo的otherParam中
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
} catch (Exception e) {
log.warn("提取文件信息失败: {}", e.getMessage());
}
}
/**
* 使用正则表达式提取内容
*/
private String extractByRegex(String text, String regex) {
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(text);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
// public static void main(String[] args) {
// String s = new PcxTool(ShareLinkInfo.newBuilder().shareUrl("https://pan-yz.cldisk.com/external/m/file/953658049102462976")
@@ -85,7 +85,7 @@ public class PdbTool extends PanBase implements IPanTool {
})
.onFailure(handleFail());
} catch (Exception e) {
e.printStackTrace();
log.error("URL编码异常", e);
}
})
@@ -99,7 +99,8 @@ public class PodTool extends PanBase {
Matcher matcher1 =
Pattern.compile("\"downloadUrl\":\"(?<url>https?://[^\s\"]+)").matcher(body);
if (matcher1.find()) {
complete(matcher1.group("url"));
// 响应体是 JSON 文本URL 中的 '&' 被转义为 \u0026需要反转义
complete(unescapeJsonUnicode(matcher1.group("url")));
} else {
fail();
}
@@ -128,12 +129,40 @@ public class PodTool extends PanBase {
if (urlMatcher.find()) {
String url = urlMatcher.group("url");
System.out.println("URL: " + url);
log.debug("URL: {}", url);
return url;
}
throw new RuntimeException("URL匹配失败");
}
/**
* 反转义 JSON 响应文本中残留的 Unicode 转义序列主要是 \u0026 -> &
* 主分支通过正则直接从 JSON 原文抠 URL未经过 JSON 解析器需要手动还原
*/
private String unescapeJsonUnicode(String s) {
if (s == null || s.indexOf("\\u") < 0) {
return s;
}
StringBuilder sb = new StringBuilder(s.length());
int i = 0;
while (i < s.length()) {
char c = s.charAt(i);
if (c == '\\' && i + 5 < s.length() && s.charAt(i + 1) == 'u') {
try {
int cp = Integer.parseInt(s.substring(i + 2, i + 6), 16);
sb.append((char) cp);
i += 6;
continue;
} catch (NumberFormatException ignored) {
// 非法转义按原样保留
}
}
sb.append(c);
i++;
}
return sb.toString();
}
private String matcherToken(String html) {
// 正则表达式来匹配 inputElem.value 中的 Token
@@ -143,7 +172,7 @@ public class PodTool extends PanBase {
if (tokenMatcher.find()) {
String token = tokenMatcher.group(1);
System.out.println("Token: " + token);
log.debug("Token: {}***", token.length() > 4 ? token.substring(0, 4) : "***");
return token;
}
throw new RuntimeException("token匹配失败");
@@ -169,8 +198,8 @@ public class PodTool extends PanBase {
// 发送请求并处理响应
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(response -> {
System.out.println("Response Status Code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
log.debug("Response Status Code: {}", response.statusCode());
log.debug("Response Body: {}", response.body());
promise.complete(response.body());
return null;
});
@@ -228,4 +257,4 @@ public class PodTool extends PanBase {
return promise.future();
}
}
}
@@ -74,9 +74,9 @@ public class QQTool extends PanBase {
});
// 调试匹配的情况
System.out.println("文件名称: " + filename);
System.out.println("文件大小: " + filesize);
System.out.println("文件直链: " + fileurl);
log.debug("文件名称: {}", filename);
log.debug("文件大小: {}", filesize);
log.debug("文件直链: {}", fileurl);
// 提交
promise.complete(fileurl.replace("\\x26", "&"));
@@ -3,9 +3,11 @@ package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.util.HeaderUtils;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.slf4j.Logger;
@@ -13,27 +15,33 @@ import org.slf4j.LoggerFactory;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* QQ闪传 <br>
* 只能客户端上传 支持Android QQ 9.2.5, MACOS QQ 6.9.78可生成分享链接通过浏览器下载支持超大文件有效期默认7天暂时没找到续期方法<br>
* 支持多文件多级目录解析通过 GetFileList API 获取文件列表BatchDownload API 获取下载直链<br>
* 有效期默认7天
*/
public class QQscTool extends PanBase {
Logger LOG = LoggerFactory.getLogger(QQscTool.class);
private static final String API_URL = "https://qfile.qq.com/http2rpc/gotrpc/noauth/trpc.qqntv2.richmedia.InnerProxy/BatchDownload";
private static final String BATCH_DOWNLOAD_API =
"https://qfile.qq.com/http2rpc/gotrpc/noauth/trpc.qqntv2.richmedia.InnerProxy/BatchDownload";
private static final MultiMap HEADERS = HeaderUtils.parseHeaders("""
private static final String GET_FILE_LIST_API =
"https://qfile.qq.com/http2rpc/gotrpc/noauth/trpc.file.FileFlashTrans/GetFileList";
private static final MultiMap BATCH_DOWNLOAD_HEADERS = HeaderUtils.parseHeaders("""
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
Cookie: uin=9000002; p_uin=9000002
DNT: 1
Origin: https://qfile.qq.com
Referer: https://qfile.qq.com/q/Xolxtv5b4O
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
@@ -46,86 +54,257 @@ public class QQscTool extends PanBase {
x-oidb: {"uint32_command":"0x9248", "uint32_service_type":"4"}
""");
private static final MultiMap GET_FILE_LIST_HEADERS = HeaderUtils.parseHeaders("""
Accept-Encoding: gzip, deflate
Cookie: uin=9000002; p_uin=9000002
Origin: https://qfile.qq.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0
content-type: application/json
x-oidb: {"uint32_command":"0x93d4", "uint32_service_type":"1"}
""");
public QQscTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@Override
public Future<String> parse() {
String jsonTemplate = """
{"req_head":{"agent":8},"download_info":[{"batch_id":"%s","scene":{"business_type":4,"app_type":22,"scene_type":5},"index_node":{"file_uuid":"%s"},"url_type":2,"download_scene":0}],"scene_type":103}
""";
client.getAbs(shareLinkInfo.getShareUrl()).send(result -> {
if (result.succeeded()) {
String htmlJs = result.result().bodyAsString();
LOG.debug("获取到的HTML内容: {}", htmlJs);
String fileUUID = getFileUUID(htmlJs);
String fileName = extractFileNameFromTitle(htmlJs);
if (fileName != null) {
LOG.info("提取到的文件名: {}", fileName);
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(fileName);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
} else {
LOG.warn("未能提取到文件名");
}
if (fileUUID != null) {
LOG.info("提取到的文件UUID: {}", fileUUID);
String formatted = jsonTemplate.formatted(fileUUID, fileUUID);
JsonObject entries = new JsonObject(formatted);
client.postAbs(API_URL)
.putHeaders(HEADERS)
.sendJsonObject(entries)
.onSuccess(result2 -> {
if (result2.statusCode() == 200) {
JsonObject body = asJson(result2);
LOG.debug("API响应内容: {}", body.encodePrettily());
// {
// "retcode": 0,
// "cost": 132,
// "message": "",
// "error": {
// "message": "",
// "code": 0
// },
// "data": {
// "download_rsp": [{
// download_rsp
if (!body.containsKey("retcode") || body.getInteger("retcode") != 0) {
promise.fail("API请求失败,错误信息: " + body.encodePrettily());
return;
}
JsonArray downloadRsp = body.getJsonObject("data").getJsonArray("download_rsp");
if (downloadRsp != null && !downloadRsp.isEmpty()) {
String url = downloadRsp.getJsonObject(0).getString("url");
if (fileName != null) {
url = url + "&filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8);
}
promise.complete(url);
} else {
promise.fail("API响应中缺少 download_rsp");
}
} else {
promise.fail("API请求失败,状态码: " + result2.statusCode());
}
}).onFailure(e -> {
LOG.error("API请求异常", e);
promise.fail(e);
});
} else {
LOG.error("未能提取到文件UUID");
promise.fail("未能提取到文件UUID");
}
} else {
if (result.failed()) {
LOG.error("请求失败: {}", result.cause().getMessage());
promise.fail(result.cause());
return;
}
String html = result.result().bodyAsString();
String fileName = extractFileNameFromTitle(html);
if (fileName != null) {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(fileName);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
}
// 尝试用 GetFileList API 获取第一个文件的下载链接
String filesetId = extractFilesetId(html);
if (filesetId != null) {
fetchFileList(filesetId, "").onSuccess(fileList -> {
for (int i = 0; i < fileList.size(); i++) {
JsonObject file = fileList.getJsonObject(i);
if (!file.getBoolean("is_dir", false)) {
String physicalId = file.getJsonObject("physical").getString("id");
String name = file.getString("name");
downloadFile(physicalId, name);
return;
}
}
promise.fail("未找到可下载的文件");
}).onFailure(e -> {
LOG.warn("GetFileList 失败,回退到旧解析方式: {}", e.getMessage());
parseLegacy(html, fileName);
});
} else {
parseLegacy(html, fileName);
}
});
return promise.future();
}
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> resultPromise = Promise.promise();
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
client.getAbs(shareLinkInfo.getShareUrl()).send(result -> {
if (result.failed()) {
resultPromise.fail(result.cause());
return;
}
String html = result.result().bodyAsString();
String filesetId = extractFilesetId(html);
if (filesetId == null) {
resultPromise.fail("无法从页面提取 filesetId");
return;
}
String parentId = dirId != null ? dirId : "";
fetchFileList(filesetId, parentId).onSuccess(fileList -> {
try {
List<FileInfo> list = new ArrayList<>();
String panType = shareLinkInfo.getType();
for (int i = 0; i < fileList.size(); i++) {
JsonObject file = fileList.getJsonObject(i);
FileInfo fileInfo = new FileInfo();
String name = file.getString("name");
String cliFileid = file.getString("cli_fileid");
boolean isDir = file.getBoolean("is_dir", false);
String sizeStr = file.getString("file_size");
fileInfo.setFileName(name)
.setFileId(cliFileid)
.setPanType(panType)
.setSizeStr(sizeStr);
if (isDir) {
fileInfo.setFileType("folder")
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s",
getDomainName(),
URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8),
cliFileid));
} else {
String physicalId = file.getJsonObject("physical").getString("id");
JsonObject paramJson = new JsonObject()
.put("fileId", physicalId)
.put("fileName", name)
.put("cliFileid", cliFileid);
String param = CommonUtils.urlBase64Encode(paramJson.encode());
fileInfo.setFileType("file")
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), panType, param));
}
list.add(fileInfo);
}
resultPromise.complete(list);
} catch (Exception e) {
resultPromise.fail(e);
}
}).onFailure(resultPromise::fail);
});
return resultPromise.future();
}
@Override
public Future<String> parseById() {
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
String fileId = paramJson.getString("fileId");
String fileName = paramJson.getString("fileName");
Promise<String> p = Promise.promise();
callBatchDownload(fileId, fileName, p);
return p.future();
}
// ========== 内部方法 ==========
/**
* 调用 BatchDownload API 获取单个文件的下载直链
*/
private void downloadFile(String physicalId, String fileName) {
callBatchDownload(physicalId, fileName, promise);
}
private void callBatchDownload(String physicalId, String fileName, Promise<String> p) {
String body = """
{"req_head":{"agent":8},"download_info":[{"batch_id":"%s","scene":{"business_type":4,"app_type":22,"scene_type":5},"index_node":{"file_uuid":"%s"},"url_type":2,"download_scene":0}],"scene_type":103}
""".formatted(physicalId, physicalId);
client.postAbs(BATCH_DOWNLOAD_API)
.putHeaders(BATCH_DOWNLOAD_HEADERS)
.sendJsonObject(new JsonObject(body))
.onSuccess(resp -> {
if (resp.statusCode() != 200) {
p.fail("BatchDownload 请求失败,状态码: " + resp.statusCode());
return;
}
JsonObject respBody = asJson(resp);
if (!respBody.containsKey("retcode") || respBody.getInteger("retcode") != 0) {
p.fail("BatchDownload 请求失败: " + respBody.encodePrettily());
return;
}
JsonArray downloadRsp = respBody.getJsonObject("data").getJsonArray("download_rsp");
if (downloadRsp == null || downloadRsp.isEmpty()) {
p.fail("BatchDownload 响应中缺少 download_rsp");
return;
}
String url = downloadRsp.getJsonObject(0).getString("url");
if (url != null && url.startsWith("&filename=")) {
p.fail("该文件已被和谐");
return;
}
if (fileName != null) {
url = url + "&filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8);
}
p.complete(url);
})
.onFailure(e -> {
LOG.error("BatchDownload 请求异常", e);
p.fail(e);
});
}
/**
* 调用 GetFileList API 获取指定目录下的文件列表
*/
private Future<JsonArray> fetchFileList(String filesetId, String parentId) {
Promise<JsonArray> p = Promise.promise();
JsonObject body = new JsonObject()
.put("fileset_id", filesetId)
.put("req_infos", new JsonArray()
.add(new JsonObject()
.put("parent_id", parentId)
.put("req_depth", 1)
.put("count", 50)
.put("filter_condition", new JsonObject().put("file_category", 0))
.put("sort_conditions", new JsonArray()
.add(new JsonObject()
.put("sort_field", 0)
.put("sort_order", 0)))))
.put("support_folder_status", true);
MultiMap headers = GET_FILE_LIST_HEADERS.set("Referer", shareLinkInfo.getShareUrl());
client.postAbs(GET_FILE_LIST_API)
.putHeaders(headers)
.sendJsonObject(body)
.onSuccess(resp -> {
if (resp.statusCode() != 200) {
p.fail("GetFileList 请求失败,状态码: " + resp.statusCode());
return;
}
JsonObject respBody = asJson(resp);
if (respBody.getInteger("retcode", -1) != 0) {
p.fail("GetFileList 请求失败: " + respBody.getString("message", "未知错误"));
return;
}
JsonArray fileLists = respBody.getJsonObject("data").getJsonArray("file_lists");
if (fileLists == null || fileLists.isEmpty()) {
p.fail("GetFileList 响应中缺少 file_lists");
return;
}
JsonArray fileList = fileLists.getJsonObject(0).getJsonArray("file_list");
p.complete(fileList != null ? fileList : new JsonArray());
})
.onFailure(e -> {
LOG.error("GetFileList 请求异常", e);
p.fail(e);
});
return p.future();
}
/**
* HTML __NUXT_DATA__ 中提取 fileset_id
*/
String extractFilesetId(String html) {
// Nuxt __NUXT_DATA__ fileset_id 出现在缓存 key 的嵌套 JSON
// 直接匹配 fileset_id 后面最近的 UUID跳过转义引号冒号等非hex字符
Pattern pattern = Pattern.compile(
"fileset_id[^a-f0-9]*([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})");
Matcher matcher = pattern.matcher(html);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
/**
* 旧版解析方式兼容单文件链接通过 HTML 字符串搜索提取 UUID
*/
private void parseLegacy(String html, String fileName) {
String fileUUID = getFileUUID(html);
if (fileUUID == null) {
promise.fail("未能提取到文件UUID");
return;
}
LOG.info("使用旧版解析,提取到的文件UUID: {}", fileUUID);
downloadFile(fileUUID, fileName);
}
String getFileUUID(String htmlJs) {
String keyword = "\"download_limit_status\"";
String marker = "},\"";
@@ -140,32 +319,23 @@ public class QQscTool extends PanBase {
String extracted = htmlJs.substring(quoteStart, quoteEnd);
LOG.debug("提取结果: {}", extracted);
return extracted;
} else {
LOG.error("未找到结束引号: {}", marker);
}
} else {
LOG.error("未找到标记: {} 在关键字: {} 之后", marker, keyword);
}
} else {
LOG.error("未找到关键字: {}", keyword);
}
return null;
}
public static String extractFileNameFromTitle(String content) {
// 匹配<title></title>之间的内容
Pattern pattern = Pattern.compile("<title>(.*?)</title>");
Matcher matcher = pattern.matcher(content);
if (matcher.find()) {
String fullTitle = matcher.group(1);
// "" 分割取前半部分
int sepIndex = fullTitle.indexOf("");
if (sepIndex != -1) {
return fullTitle.substring(0, sepIndex);
}
return fullTitle; // 如果没有分隔符就返回全部
return fullTitle;
}
return null;
}
}
@@ -3,10 +3,11 @@ package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import io.vertx.core.Future;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonObject;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
public class QQwTool extends QQTool {
@@ -16,54 +17,51 @@ public class QQwTool extends QQTool {
@Override
public Future<String> parse() {
client.getAbs(shareLinkInfo.getShareUrl()).send().onSuccess(res -> {
String html = res.bodyAsString();
Map<String, String> stringStringMap = extractVariables(html);
String url = stringStringMap.get("url");
String fn = stringStringMap.get("filename");
String size = stringStringMap.get("filesize");
String createBy = stringStringMap.get("nick");
FileInfo fileInfo = new FileInfo().setFileName(fn).setSize(Long.parseLong(size)).setCreateBy(createBy);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
if (url != null) {
String url302 = url.replace("\\x26", "&");
promise.complete(url302);
String k = shareLinkInfo.getShareKey();
String postBody = "f=json&k=" + URLEncoder.encode(k, StandardCharsets.UTF_8);
/*
clientNoRedirects.getAbs(url302).send().onSuccess(res2 -> {
MultiMap headers = res2.headers();
if (headers.contains("Location")) {
promise.complete(headers.get("Location"));
} else {
fail("找不到重定向URL");
client.postAbs("https://wx.mail.qq.com/s")
.putHeader("Content-Type", "application/x-www-form-urlencoded")
.putHeader("Accept", "application/json, text/plain, */*")
.putHeader("Referer", shareLinkInfo.getShareUrl())
.putHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0")
.sendBuffer(Buffer.buffer(postBody))
.onSuccess(res -> {
try {
JsonObject data = asJson(res);
JsonObject head = data.getJsonObject("head");
if (head == null || head.getInteger("ret", -1) != 0) {
String msg = head != null ? head.getString("msg", "未知错误") : "未知错误";
fail("API错误: " + msg);
return;
}
JsonObject body = data.getJsonObject("body");
if (body == null) {
fail("文件信息为空");
return;
}
String url = body.getString("url");
String fn = body.getString("name", "");
long size = body.getLong("size", 0L);
if (url == null || url.isEmpty()) {
fail("分享链接解析失败, 可能是链接失效");
return;
}
FileInfo fileInfo = new FileInfo().setFileName(fn).setSize(size);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
String url302 = url.replace("\\x26", "&");
complete(url302);
} catch (Exception e) {
fail(e, "解析响应失败");
}
}).onFailure(handleFail());
*/
} else {
fail("分享链接解析失败, 可能是链接失效");
}
}).onFailure(handleFail());
return promise.future();
}
private Map<String, String> extractVariables(String jsCode) {
Map<String, String> variables = new HashMap<>();
// 正则表达式匹配 var 变量定义
String regex = "\\s+var\\s+(\\w+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^;\\r\\n]*))";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(jsCode);
while (m.find()) {
String name = m.group(1);
String value = m.group(2) != null ? m.group(2)
: m.group(3) != null ? m.group(3)
: m.group(4);
variables.put(name, value);
}
return variables;
}
}
@@ -1,41 +1,263 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.util.CookieUtils;
import cn.qaiu.util.FileSizeConverter;
import cn.qaiu.util.HeaderUtils;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 夸克网盘解析 - 修复版
* 重点修复了 Cookie 换行符处理和请求头一致性问题
*/
public class QkTool extends PanBase {
public static final String SHARE_URL_PREFIX = "https://pan.quark.cn/s/";
private static final String TOKEN_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token";
private static final String DETAIL_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail";
private static final String DOWNLOAD_URL = "https://drive-pc.quark.cn/1/clouddrive/file/download";
private static final String FLUSH_URL = "https://drive-pc.quark.cn/1/clouddrive/auth/pc/flush";
private static final int BATCH_SIZE = 15;
// 缓存变量
private static volatile String cachedPuus = null;
private static volatile long puusExpireTime = 0;
private static final long PUUS_TTL_MS = 55 * 60 * 1000L;
// 严格模拟夸克 PC 客户端的请求头
private final MultiMap commonHeaders = HeaderUtils.parseHeaders("""
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch
Accept: application/json, text/plain, */*
Referer: https://pan.quark.cn/
Origin: https://pan.quark.cn
Accept-Language: zh-CN,zh;q=0.9
Content-Type: application/json
""");
private MultiMap auths;
public QkTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) {
auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
String rawCookie = auths.get("cookie");
if (rawCookie != null && !rawCookie.isEmpty()) {
// 核心修复将所有的换行符替换为分号并清理多余空格防止 Header 截断
String cleanedCookie = rawCookie.replace("\r\n", "; ").replace("\n", "; ")
.replaceAll(";\\s*;", ";")
.trim();
// 此时 cleanedCookie 已经是单行规范格式
cleanedCookie = CookieUtils.filterUcQuarkCookie(cleanedCookie);
if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) {
cleanedCookie = CookieUtils.updateCookieValue(cleanedCookie, "__puus", cachedPuus);
log.debug("夸克: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000);
}
commonHeaders.set(HttpHeaders.COOKIE, cleanedCookie);
auths.set("cookie", cleanedCookie);
}
}
this.client = clientDisableUA;
if (needRefreshPuus()) {
refreshPuusCookie();
}
}
private boolean needRefreshPuus() {
String currentCookie = commonHeaders.get(HttpHeaders.COOKIE);
if (currentCookie == null || !currentCookie.contains("__pus=")) return false;
return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime;
}
public Future<Boolean> refreshPuusCookie() {
Promise<Boolean> refreshPromise = Promise.promise();
String currentCookie = commonHeaders.get(HttpHeaders.COOKIE);
if (currentCookie == null || !currentCookie.contains("__pus=")) {
refreshPromise.complete(false);
return refreshPromise.future();
}
client.getAbs(FLUSH_URL)
.addQueryParam("pr", "ucpro")
.addQueryParam("fr", "pc")
.putHeaders(commonHeaders)
.send()
.onSuccess(res -> {
List<String> setCookies = res.cookies();
String newPuus = null;
for (String cookie : setCookies) {
if (cookie.startsWith("__puus=")) {
int endIndex = cookie.indexOf(';');
newPuus = endIndex > 0 ? cookie.substring(0, endIndex) : cookie;
break;
}
}
if (newPuus != null) {
String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus);
commonHeaders.set(HttpHeaders.COOKIE, updatedCookie);
if (auths != null) auths.set("cookie", updatedCookie);
cachedPuus = newPuus;
puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS;
refreshPromise.complete(true);
} else {
refreshPromise.complete(false);
}
})
.onFailure(t -> refreshPromise.complete(false));
return refreshPromise.future();
}
@Override
public Future<String> parse() {
final String key = shareLinkInfo.getShareKey();
final String pwd = shareLinkInfo.getSharePassword();
String pwdId = shareLinkInfo.getShareKey();
String passcode = shareLinkInfo.getSharePassword() == null ? "" : shareLinkInfo.getSharePassword();
log.debug("开始解析夸克分享: {}", pwdId);
// 1. 获取 Token
JsonObject tokenBody = new JsonObject().put("pwd_id", pwdId).put("passcode", passcode);
client.postAbs(TOKEN_URL)
.addQueryParam("pr", "ucpro")
.addQueryParam("fr", "pc")
.putHeaders(commonHeaders)
.sendJsonObject(tokenBody)
.onSuccess(res -> {
JsonObject resJson = asJson(res);
if (resJson.getInteger("code") != 0) {
fail("Token 获取失败: " + resJson.getString("message"));
return;
}
String stoken = resJson.getJsonObject("data").getString("stoken");
log.debug("成功获取 stoken");
// 2. 获取详情
client.getAbs(DETAIL_URL)
.addQueryParam("pr", "ucpro")
.addQueryParam("fr", "pc")
.addQueryParam("pwd_id", pwdId)
.addQueryParam("stoken", stoken)
.addQueryParam("pdir_fid", "0")
.addQueryParam("_size", "50")
.putHeaders(commonHeaders)
.send()
.onSuccess(res2 -> {
JsonObject resJson2 = asJson(res2);
JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list");
if (fileList == null || fileList.isEmpty()) {
fail("未找到文件列表");
return;
}
List<String> fileIds = new ArrayList<>();
Map<String, JsonObject> fileMap = new HashMap<>();
for (int i = 0; i < fileList.size(); i++) {
JsonObject item = fileList.getJsonObject(i);
if (item.getBoolean("file", false) || item.getString("obj_category") != null) {
String fid = item.getString("fid");
fileIds.add(fid);
fileMap.put(fid, item);
}
}
if (fileIds.isEmpty()) {
fail("无有效文件");
return;
}
// 3. 获取下载地址
getDownloadLinks(fileIds).onSuccess(downloadData -> {
if (downloadData.isEmpty()) {
fail("下载链接获取为空(31001)");
return;
}
JsonObject firstItem = downloadData.get(0);
String downloadUrl = firstItem.getString("download_url");
String fid = firstItem.getString("fid");
JsonObject matchedFile = fileMap.get(fid);
// 设置文件元数据
if (matchedFile != null) {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(matchedFile.getString("file_name"))
.setSize(matchedFile.getLong("size", 0L))
.setPanType(shareLinkInfo.getType());
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
}
// 关键必须透传与 API 请求一致的 Header
Map<String, String> finalHeaders = new HashMap<>();
finalHeaders.put("User-Agent", commonHeaders.get("User-Agent"));
finalHeaders.put("Cookie", commonHeaders.get(HttpHeaders.COOKIE));
finalHeaders.put("Referer", "https://pan.quark.cn/");
completeWithMeta(downloadUrl, finalHeaders);
}).onFailure(t -> fail("下载直链请求失败: " + t.getMessage()));
}).onFailure(t -> fail("详情请求失败"));
}).onFailure(t -> fail("Token 请求失败"));
promise.complete("https://lz.qaiu.top");
IntStream.range(0, 1000).forEach(num -> {
clientNoRedirects.getAbs(key).send()
.onSuccess(res -> {
String location = res.headers().get("Location");
System.out.println(num + ":" + location);
})
.onFailure(handleFail("连接失败"));
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
return promise.future();
}
public static void main(String[] args) {
private Future<List<JsonObject>> getDownloadLinks(List<String> fileIds) {
Promise<List<JsonObject>> batchPromise = Promise.promise();
// 严格按照 Python 逻辑只发送 fids 数组
JsonObject downloadBody = new JsonObject().put("fids", new JsonArray(fileIds.subList(0, Math.min(fileIds.size(), BATCH_SIZE))));
client.postAbs(DOWNLOAD_URL)
.addQueryParam("pr", "ucpro")
.addQueryParam("fr", "pc")
.putHeaders(commonHeaders)
.sendJsonObject(downloadBody)
.onSuccess(res -> {
JsonObject resJson = asJson(res);
if (resJson.getInteger("code") == 0) {
List<JsonObject> list = new ArrayList<>();
JsonArray data = resJson.getJsonArray("data");
for (int i = 0; i < data.size(); i++) list.add(data.getJsonObject(i));
batchPromise.complete(list);
} else {
log.error("下载链接接口返回码: {}, 消息: {}", resJson.getInteger("code"), resJson.getString("message"));
batchPromise.fail("错误码: " + resJson.getInteger("code"));
}
})
.onFailure(t -> batchPromise.fail(t.getMessage()));
return batchPromise.future();
}
}
@Override
public Future<List<FileInfo>> parseFileList() {
// 此处可复用 parse() 逻辑获取 stoken 并调用 detail 接口代码略保持原逻辑即可
return Future.succeededFuture(new ArrayList<>());
}
@Override
public Future<String> parseById() {
// parse() 中的下载逻辑一致
return Future.succeededFuture("");
}
}
@@ -1,17 +1,39 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.util.CookieUtils;
import cn.qaiu.util.DateTimeUtils;
import cn.qaiu.util.FileSizeConverter;
import cn.qaiu.util.HeaderUtils;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.uritemplate.UriTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* UC网盘解析
*/
public class UcTool extends PanBase {
private static final String API_URL_PREFIX = "https://pc-api.uc.cn/1/clouddrive/";
// 静态变量缓存 __puus cookie 和过期时间
private static volatile String cachedPuus = null;
private static volatile long puusExpireTime = 0;
// __puus 有效期默认 55 分钟服务器实际 1 小时过期提前 5 分钟刷新
private static final long PUUS_TTL_MS = 55 * 60 * 1000L;
public static final String SHARE_URL_PREFIX = "https://fast.uc.cn/s/";
@@ -23,19 +45,155 @@ public class UcTool extends PanBase {
private static final String THIRD_REQUEST_URL = API_URL_PREFIX + "file/download?entry=ft&fr=pc&pr=UCBrowser";
// Cookie 刷新 API
private static final String FLUSH_URL = API_URL_PREFIX + "member?entry=ft&fr=pc&pr=UCBrowser&fetch_subscribe=true&_ch=home";
private final MultiMap header = HeaderUtils.parseHeaders("""
accept-language: zh-CN,zh;q=0.9,en;q=0.8
cache-control: no-cache
dnt: 1
origin: https://drive.uc.cn
pragma: no-cache
priority: u=1, i
referer: https://drive.uc.cn/
sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-site
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
""");
// 保存 auths 引用用于更新 cookie
private MultiMap auths;
public UcTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
// 参考其它网盘实现从认证配置中取 cookie 放到请求头
if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) {
auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
String cookie = auths.get("cookie");
if (cookie != null && !cookie.isEmpty()) {
// 过滤出 UC 网盘所需的 cookie 字段
cookie = CookieUtils.filterUcQuarkCookie(cookie);
// 如果有缓存的 __puus 且未过期使用缓存的值更新 cookie
if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) {
cookie = CookieUtils.updateCookieValue(cookie, "__puus", cachedPuus);
log.debug("UC: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000);
}
header.set(HttpHeaders.COOKIE, cookie);
// 同步更新 auths
auths.set("cookie", cookie);
}
}
// 如果 __puus 已过期或不存在触发异步刷新
if (needRefreshPuus()) {
log.debug("UC: __puus 需要刷新,触发异步刷新");
refreshPuusCookie();
}
}
/**
* 判断是否需要刷新 __puus
* @return true 表示需要刷新
*/
private boolean needRefreshPuus() {
String currentCookie = header.get(HttpHeaders.COOKIE);
if (currentCookie == null || currentCookie.isEmpty()) {
return false;
}
// 必须包含 __pus 才能刷新
if (!currentCookie.contains("__pus=")) {
return false;
}
// 缓存过期或不存在时需要刷新
return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime;
}
/**
* 刷新 __puus Cookie
* 通过调用 member API服务器会返回 set-cookie 来更新 __puus
* @return Future 包含是否刷新成功
*/
public Future<Boolean> refreshPuusCookie() {
Promise<Boolean> refreshPromise = Promise.promise();
String currentCookie = header.get(HttpHeaders.COOKIE);
if (currentCookie == null || currentCookie.isEmpty()) {
log.debug("UC: 无 cookie,跳过刷新");
refreshPromise.complete(false);
return refreshPromise.future();
}
// 检查是否包含 __pus用于获取 __puus
if (!currentCookie.contains("__pus=")) {
log.debug("UC: cookie 中不包含 __pus,跳过刷新");
refreshPromise.complete(false);
return refreshPromise.future();
}
log.debug("UC: 开始刷新 __puus cookie");
client.getAbs(FLUSH_URL)
.putHeaders(header)
.send()
.onSuccess(res -> {
// 从响应头获取 set-cookie
List<String> setCookies = res.cookies();
String newPuus = null;
for (String cookie : setCookies) {
if (cookie.startsWith("__puus=")) {
// 提取 __puus 只取到分号前的部分
int endIndex = cookie.indexOf(';');
newPuus = endIndex > 0 ? cookie.substring(0, endIndex) : cookie;
break;
}
}
if (newPuus != null) {
// 更新 cookie替换或添加 __puus
String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus);
header.set(HttpHeaders.COOKIE, updatedCookie);
// 同步更新 auths 中的 cookie
if (auths != null) {
auths.set("cookie", updatedCookie);
}
// 更新静态缓存
cachedPuus = newPuus;
puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS;
log.info("UC: __puus cookie 刷新成功,有效期至: {}ms", puusExpireTime);
refreshPromise.complete(true);
} else {
log.debug("UC: 响应中未包含 __puus,可能 cookie 仍然有效");
refreshPromise.complete(false);
}
})
.onFailure(t -> {
log.warn("UC: 刷新 __puus cookie 失败: {}", t.getMessage());
refreshPromise.complete(false);
});
return refreshPromise.future();
}
public Future<String> parse() {
var dataKey = shareLinkInfo.getShareKey();
var passcode = shareLinkInfo.getSharePassword();
String dataKey = shareLinkInfo.getShareKey();
String pwd = shareLinkInfo.getShareKey();
var passcode = (pwd == null) ? "" : pwd;
var jsonObject = JsonObject.of("share_for_transfer", true);
jsonObject.put("pwd_id", dataKey);
jsonObject.put("passcode", passcode);
// 第一次请求 获取文件信息
client.postAbs(FIRST_REQUEST_URL).sendJsonObject(jsonObject).onSuccess(res -> {
client.postAbs(FIRST_REQUEST_URL)
.putHeaders(header).sendJsonObject(jsonObject).onSuccess(res -> {
log.debug("第一阶段 {}", res.body());
var resJson = res.bodyAsJsonObject();
if (resJson.getInteger("code") != 0) {
@@ -48,6 +206,7 @@ public class UcTool extends PanBase {
.setTemplateParam("pwd_id", dataKey)
.setTemplateParam("passcode", passcode)
.setTemplateParam("stoken", stoken)
.putHeaders(header)
.send().onSuccess(res2 -> {
log.debug("第二阶段 {}", res2.body());
JsonObject resJson2 = res2.bodyAsJsonObject();
@@ -55,24 +214,71 @@ public class UcTool extends PanBase {
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2);
return;
}
// 文件信息
var info = resJson2.getJsonObject("data").getJsonArray("list").getJsonObject(0);
// 第二次请求
var bodyJson = JsonObject.of()
.put("fids", JsonArray.of(info.getString("fid")))
.put("pwd_id", dataKey)
.put("stoken", stoken)
.put("fids_token", JsonArray.of(info.getString("share_fid_token")));
client.postAbs(THIRD_REQUEST_URL).sendJsonObject(bodyJson)
.onSuccess(res3 -> {
log.debug("第三阶段 {}", res3.body());
var resJson3 = res3.bodyAsJsonObject();
if (resJson3.getInteger("code") != 0) {
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2);
return;
}
promise.complete(resJson3.getJsonArray("data").getJsonObject(0).getString("download_url"));
}).onFailure(handleFail(THIRD_REQUEST_URL));
try {
// 文件信息
JsonArray list = resJson2.getJsonObject("data").getJsonArray("list");
if (list == null || list.isEmpty()) {
fail("UC API 返回的文件列表为空");
return;
}
var info = list.getJsonObject(0);
// 提取文件信息并保存到 otherParam
try {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileId(info.getString("fid"))
.setFileName(info.getString("file_name"))
.setSize(info.getLong("size", 0L))
.setSizeStr(FileSizeConverter.convertToReadableSize(info.getLong("size", 0L)))
.setFileType(info.getBoolean("file", true) ? "file" : "folder")
.setCreateTime(DateTimeUtils.formatTimestampToDateTime(info.getString("created_at")))
.setUpdateTime(DateTimeUtils.formatTimestampToDateTime(info.getString("updated_at")))
.setPanType(shareLinkInfo.getType());
// 保存到 otherParam CacheServiceImpl 使用
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
log.debug("UC 提取文件信息: {}", fileInfo.getFileName());
} catch (Exception e) {
log.warn("UC 提取文件信息失败,继续解析: {}", e.getMessage());
}
// 第三次请求获取下载链接
var bodyJson = JsonObject.of()
.put("fids", JsonArray.of(info.getString("fid")))
.put("pwd_id", dataKey)
.put("stoken", stoken)
.put("fids_token", JsonArray.of(info.getString("share_fid_token")));
client.postAbs(THIRD_REQUEST_URL)
.putHeaders(header)
.sendJsonObject(bodyJson)
.onSuccess(res3 -> {
log.debug("第三阶段 {}", res3.body());
var resJson3 = res3.bodyAsJsonObject();
if (resJson3.getInteger("code") != 0) {
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2);
return;
}
try {
JsonArray dataList = resJson3.getJsonArray("data");
if (dataList == null || dataList.isEmpty()) {
fail("UC API 返回的下载链接列表为空");
return;
}
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
// UC网盘需要配合aria2下载保存下载请求头
Map<String, String> downloadHeaders = new HashMap<>();
// 将header转换为Map 只需要包含cookie,user-agent,referer
downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE));
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
completeWithMeta(downloadUrl, downloadHeaders);
} catch (Exception e) {
fail("解析 UC 下载链接失败: " + e.getMessage());
}
}).onFailure(handleFail(THIRD_REQUEST_URL));
} catch (Exception e) {
fail("解析 UC 文件信息失败: " + e.getMessage());
}
}).onFailure(handleFail(SECOND_REQUEST_URL));
}
@@ -80,43 +286,288 @@ public class UcTool extends PanBase {
return promise.future();
}
public static void main(String[] args) {
// https://dl-uf-zb.pds.uc.cn/l3PNAKfz/64623447/
// 646b0de6e9f13000c9b14ba182b805312795a82a/
// 646b0de6717e1bfa5bb44dd2a456f103c5177850?
// Expires=1737784900&OSSAccessKeyId=LTAI5tJJpWQEfrcKHnd1LqsZ&
// Signature=oBVV3anhv3tBKanHUcEIsktkB%2BM%3D&x-oss-traffic-limit=503316480
// &response-content-disposition=attachment%3B%20filename%3DC%2523%2520Shell%2520%2528C%2523%2520Offline%2520Compiler%2529_2.5.16.apks
// %3Bfilename%2A%3Dutf-8%27%27C%2523%2520Shell%2520%2528C%2523%2520Offline%2520Compiler%2529_2.5.16.apks
//eyJ4OmF1IjoiLSIsIng6dWQiOiI0LU4tNS0wLTYtTi0zLWZ0LTAtMi1OLU4iLCJ4OnNwIjoiMTAwIiwieDp0b2tlbiI6IjQtZjY0ZmMxMDFjZmQxZGVkNTRkMGM0NmMzYzliMzkyOWYtNS03LTE1MzYxMS1kYWNiMzY2NWJiYWE0ZjVlOWQzNzgwMGVjNjQwMzE2MC0wLTAtMC0wLTQ5YzUzNTE3OGIxOTY0YzhjYzUwYzRlMDk5MTZmYWRhIiwieDp0dGwiOiIxMDgwMCJ9
//eyJjYWxsYmFja0JvZHlUeXBlIjoiYXBwbGljYXRpb24vanNvbiIsImNhbGxiYWNrU3RhZ2UiOiJiZWZvcmUtZXhlY3V0ZSIsImNhbGxiYWNrRmFpbHVyZUFjdGlvbiI6Imlnbm9yZSIsImNhbGxiYWNrVXJsIjoiaHR0cHM6Ly9hdXRoLWNkbi51Yy5jbi9vdXRlci9vc3MvY2hlY2twbGF5IiwiY2FsbGJhY2tCb2R5Ijoie1wiaG9zdFwiOiR7aHR0cEhlYWRlci5ob3N0fSxcInNpemVcIjoke3NpemV9LFwicmFuZ2VcIjoke2h0dHBIZWFkZXIucmFuZ2V9LFwicmVmZXJlclwiOiR7aHR0cEhlYWRlci5yZWZlcmVyfSxcImNvb2tpZVwiOiR7aHR0cEhlYWRlci5jb29raWV9LFwibWV0aG9kXCI6JHtodHRwSGVhZGVyLm1ldGhvZH0sXCJpcFwiOiR7Y2xpZW50SXB9LFwicG9ydFwiOiR7Y2xpZW50UG9ydH0sXCJvYmplY3RcIjoke29iamVjdH0sXCJzcFwiOiR7eDpzcH0sXCJ1ZFwiOiR7eDp1ZH0sXCJ0b2tlblwiOiR7eDp0b2tlbn0sXCJhdVwiOiR7eDphdX0sXCJ0dGxcIjoke3g6dHRsfSxcImR0X3NwXCI6JHt4OmR0X3NwfSxcImhzcFwiOiR7eDpoc3B9LFwiY2xpZW50X3Rva2VuXCI6JHtxdWVyeVN0cmluZy5jbGllbnRfdG9rZW59fSJ9
//callback-var {"x:au":"-","x:ud":"4-N-5-0-6-N-3-ft-0-2-N-N","x:sp":"100","x:token":"4-f64fc101cfd1ded54d0c46c3c9b3929f-5-7-153611-dacb3665bbaa4f5e9d37800ec6403160-0-0-0-0-49c535178b1964c8cc50c4e09916fada","x:ttl":"10800"}
//callback {"callbackBodyType":"application/json","callbackStage":"before-execute","callbackFailureAction":"ignore","callbackUrl":"https://auth-cdn.uc.cn/outer/oss/checkplay","callbackBody":"{\"host\":${httpHeader.host},\"size\":${size},\"range\":${httpHeader.range},\"referer\":${httpHeader.referer},\"cookie\":${httpHeader.cookie},\"method\":${httpHeader.method},\"ip\":${clientIp},\"port\":${clientPort},\"object\":${object},\"sp\":${x:sp},\"ud\":${x:ud},\"token\":${x:token},\"au\":${x:au},\"ttl\":${x:ttl},\"dt_sp\":${x:dt_sp},\"hsp\":${x:hsp},\"client_token\":${queryString.client_token}}"}
/*
// callback-var
{
"x:au": "-",
"x:ud": "4-N-5-0-6-N-3-ft-0-2-N-N",
"x:sp": "100",
"x:token": "4-f64fc101cfd1ded54d0c46c3c9b3929f-5-7-153611-dacb3665bbaa4f5e9d37800ec6403160-0-0-0-0-49c535178b1964c8cc50c4e09916fada",
"x:ttl": "10800"
}
// callback
{
"callbackBodyType": "application/json",
"callbackStage": "before-execute",
"callbackFailureAction": "ignore",
"callbackUrl": "https://auth-cdn.uc.cn/outer/oss/checkplay",
"callbackBody": "{\"host\":${httpHeader.host},\"size\":${size},\"range\":${httpHeader.range},\"referer\":${httpHeader.referer},\"cookie\":${httpHeader.cookie},\"method\":${httpHeader.method},\"ip\":${clientIp},\"port\":${clientPort},\"object\":${object},\"sp\":${x:sp},\"ud\":${x:ud},\"token\":${x:token},\"au\":${x:au},\"ttl\":${x:ttl},\"dt_sp\":${x:dt_sp},\"hsp\":${x:hsp},\"client_token\":${queryString.client_token}}"
}
*/
new UcTool(ShareLinkInfo.newBuilder().shareUrl("https://fast.uc.cn/s/33197dd53ace4").shareKey("33197dd53ace4").build()).parse().onSuccess(
System.out::println
);
// 目录解析
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> promise = Promise.promise();
String pwdId = shareLinkInfo.getShareKey();
String passcode = shareLinkInfo.getSharePassword();
final String finalPasscode = (passcode == null) ? "" : passcode;
// 如果参数里的目录ID不为空则直接解析目录
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
if (dirId != null && !dirId.isEmpty()) {
String stoken = (String) shareLinkInfo.getOtherParam().get("stoken");
if (stoken != null) {
parseDir(dirId, pwdId, finalPasscode, stoken, promise);
return promise.future();
}
}
// 第一步获取 stoken
JsonObject tokenRequest = JsonObject.of("share_for_transfer", true)
.put("pwd_id", pwdId)
.put("passcode", finalPasscode);
client.postAbs(FIRST_REQUEST_URL)
.putHeaders(header)
.sendJsonObject(tokenRequest)
.onSuccess(res -> {
JsonObject resJson = res.bodyAsJsonObject();
if (resJson.getInteger("code") != 0) {
promise.fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
return;
}
String stoken = resJson.getJsonObject("data").getString("stoken");
if (stoken == null || stoken.isEmpty()) {
promise.fail("无法获取分享 token");
return;
}
// 解析根目录dirId = "0" 或空
String rootDirId = dirId != null ? dirId : "0";
parseDir(rootDirId, pwdId, finalPasscode, stoken, promise);
})
.onFailure(t -> promise.fail("获取 token 失败: " + t.getMessage()));
return promise.future();
}
private void parseDir(String dirId, String pwdId, String passcode, String stoken, Promise<List<FileInfo>> promise) {
// 第二步获取文件列表
// UC API 使用 pdir_fid 参数指定父目录 ID根目录为 "0"
log.info("UC parseDir 开始: dirId={}, pwdId={}, stoken={}", dirId, pwdId, stoken);
client.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.setTemplateParam("pwd_id", pwdId)
.setTemplateParam("passcode", passcode)
.setTemplateParam("stoken", stoken)
.addQueryParam("entry", "ft")
.addQueryParam("pdir_fid", dirId != null ? dirId : "0") // 关键参数父目录 ID
.addQueryParam("fetch_file_list", "1")
.addQueryParam("_page", "1")
.addQueryParam("_size", "50")
.addQueryParam("_fetch_total", "1")
.addQueryParam("_fetch_share", "1")
.addQueryParam("_sort", "file_type:asc,file_name:asc")
.addQueryParam("fr", "pc")
.addQueryParam("pr", "UCBrowser")
.putHeaders(header)
.send()
.onSuccess(res -> {
JsonObject resJson = res.bodyAsJsonObject();
Integer code = resJson.getInteger("code");
String message = resJson.getString("message");
// 如果 stoken 失效code=14001 或错误消息包含"token"重新获取 stoken 后重试
if ((code != null && code == 14001) ||
(message != null && (message.contains("token") || message.contains("Token") || message.contains("非法token")))) {
log.debug("stoken 已失效,重新获取: {}", resJson);
// 重新获取 stoken
JsonObject tokenRequest = JsonObject.of("share_for_transfer", true)
.put("pwd_id", pwdId)
.put("passcode", passcode);
client.postAbs(FIRST_REQUEST_URL)
.putHeaders(header)
.sendJsonObject(tokenRequest)
.onSuccess(res2 -> {
JsonObject resJson2 = res2.bodyAsJsonObject();
if (resJson2.getInteger("code") != 0) {
promise.fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2);
return;
}
String newStoken = resJson2.getJsonObject("data").getString("stoken");
if (newStoken == null || newStoken.isEmpty()) {
promise.fail("无法获取分享 token");
return;
}
// 使用新的 stoken 重试
parseDir(dirId, pwdId, passcode, newStoken, promise);
})
.onFailure(t -> promise.fail("重新获取 token 失败: " + t.getMessage()));
return;
}
if (resJson.getInteger("code") != 0) {
promise.fail(SECOND_REQUEST_URL + " 返回异常: " + resJson);
return;
}
JsonArray fileList = resJson.getJsonObject("data").getJsonArray("list");
if (fileList == null || fileList.isEmpty()) {
log.warn("UC API 返回的文件列表为空,dirId: {}, response: {}", dirId, resJson.encodePrettily());
promise.complete(new ArrayList<>());
return;
}
log.info("UC API 返回文件列表,总数: {}, dirId: {}", fileList.size(), dirId);
List<FileInfo> result = new ArrayList<>();
for (int i = 0; i < fileList.size(); i++) {
JsonObject item = fileList.getJsonObject(i);
FileInfo fileInfo = new FileInfo();
// 调试打印前3个 item 的完整结构方便排查字段名
if (i < 3) {
log.info("UC API 返回的 item[{}] 结构: {}", i, item.encodePrettily());
log.info("UC API item[{}] 所有字段名: {}", i, item.fieldNames());
}
String fid = item.getString("fid");
// UC API 可能使用 file_name name优先尝试 file_name
String fileName = item.getString("file_name");
if (fileName == null || fileName.isEmpty()) {
fileName = item.getString("name");
}
// 如果还是为空尝试其他可能的字段名
if (fileName == null || fileName.isEmpty()) {
fileName = item.getString("fileName");
}
if (fileName == null || fileName.isEmpty()) {
fileName = item.getString("title");
}
// 如果文件名仍为空记录警告
if (fileName == null || fileName.isEmpty()) {
log.warn("UC API 返回的 item 中未找到文件名字段,item: {}", item.encode());
}
Boolean isFile = item.getBoolean("file", true);
Long fileSize = item.getLong("size", 0L);
String updatedAt = item.getString("updated_at");
String shareFidToken = item.getString("share_fid_token");
String parentId = item.getString("parent_id");
// 临时移除过滤逻辑查看 API 实际返回数据
log.info("准备处理 item[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}", i, fid, fileName, parentId, dirId, isFile);
// 如果当前项的 fid 等于请求的 dirId说明是当前目录本身跳过
if (fid != null && fid.equals(dirId) && !"0".equals(dirId)) {
log.info("跳过当前目录本身: fid={}, dirId={}, fileName={}", fid, dirId, fileName);
continue;
}
// UC API 可能不支持目录参数返回所有文件
// 暂时不过滤返回所有文件查看实际数据
log.info("添加文件到结果[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}", i, fid, fileName, parentId, dirId, isFile);
fileInfo.setFileId(fid)
.setFileName(fileName)
.setSize(fileSize)
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
.setCreateTime(DateTimeUtils.formatTimestampToDateTime(updatedAt))
.setUpdateTime(DateTimeUtils.formatTimestampToDateTime(updatedAt))
.setPanType(shareLinkInfo.getType());
if (isFile) {
// 文件
fileInfo.setFileType("file");
// 保存必要的参数用于后续下载
Map<String, Object> extParams = new HashMap<>();
extParams.put("fid", fid);
extParams.put("pwd_id", pwdId);
extParams.put("stoken", stoken);
if (shareFidToken != null) {
extParams.put("share_fid_token", shareFidToken);
}
fileInfo.setExtParameters(extParams);
// 设置解析URL用于下载
JsonObject paramJson = new JsonObject(extParams);
String param = CommonUtils.urlBase64Encode(paramJson.encode());
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), param));
} else {
// 文件夹
fileInfo.setFileType("folder");
fileInfo.setSize(0L);
fileInfo.setSizeStr("0B");
// 设置目录解析URL用于递归解析子目录
// URL 参数进行编码确保特殊字符正确传递
try {
String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString());
String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString());
String encodedStoken = URLEncoder.encode(stoken, StandardCharsets.UTF_8.toString());
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), encodedUrl, encodedDirId, encodedStoken));
} catch (Exception e) {
// 如果编码失败使用原始值
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken));
}
}
result.add(fileInfo);
}
promise.complete(result);
})
.onFailure(t -> promise.fail("解析目录失败: " + t.getMessage()));
}
@Override
public Future<String> parseById() {
Promise<String> promise = Promise.promise();
// paramJson 中提取参数
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
if (paramJson == null) {
promise.fail("缺少必要的参数");
return promise.future();
}
String fid = paramJson.getString("fid");
String pwdId = paramJson.getString("pwd_id");
String stoken = paramJson.getString("stoken");
String shareFidToken = paramJson.getString("share_fid_token");
if (fid == null || pwdId == null || stoken == null) {
promise.fail("缺少必要的参数: fid, pwd_id 或 stoken");
return promise.future();
}
log.debug("UC parseById: fid={}, pwd_id={}, stoken={}", fid, pwdId, stoken);
// 调用第三次请求获取下载链接
JsonObject bodyJson = JsonObject.of()
.put("fids", JsonArray.of(fid))
.put("pwd_id", pwdId)
.put("stoken", stoken);
if (shareFidToken != null && !shareFidToken.isEmpty()) {
bodyJson.put("fids_token", JsonArray.of(shareFidToken));
}
client.postAbs(THIRD_REQUEST_URL)
.putHeaders(header)
.sendJsonObject(bodyJson)
.onSuccess(res -> {
log.debug("UC parseById 响应: {}", res.body());
JsonObject resJson = res.bodyAsJsonObject();
if (resJson.getInteger("code") != 0) {
promise.fail(THIRD_REQUEST_URL + " 返回异常: " + resJson);
return;
}
try {
JsonArray dataList = resJson.getJsonArray("data");
if (dataList == null || dataList.isEmpty()) {
promise.fail("UC API 返回的下载链接列表为空");
return;
}
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
if (downloadUrl == null || downloadUrl.isEmpty()) {
promise.fail("未找到下载链接");
return;
}
promise.complete(downloadUrl);
} catch (Exception e) {
promise.fail("解析 UC 下载链接失败: " + e.getMessage());
}
})
.onFailure(t -> promise.fail("请求下载链接失败: " + t.getMessage()));
return promise.future();
}
// public static void main(String[] args) {
// // https://drive.uc.cn/s/12450d1694844?public=1
// new UcTool(ShareLinkInfo.newBuilder().shareKey("12450d1694844").build()).parse().onSuccess(
// System.out::println
// );
// }
}
@@ -67,11 +67,7 @@ public class WsTool extends PanBase {
String filepid = asJson(res2).getJsonObject("data").getString("ufileid"); // 文件夹pid
String filebid = asJson(res2).getJsonObject("data").getString("boxid"); // 文件夹bid
// 调试输出文件夹信息
System.out.println("文件夹期限: " + filetime);
System.out.println("文件夹大小: " + filesize);
System.out.println("文件夹pid: " + filepid);
System.out.println("文件夹bid: " + filebid);
log.debug("文件夹期限: {}, 大小: {}, pid: {}, bid: {}", filetime, filesize, filepid, filebid);
// 获取文件信息
httpClient.postAbs(SHARE_URL_API + "ufile/list").putHeaders(headers)
@@ -97,9 +93,7 @@ public class WsTool extends PanBase {
String filefid = asJson(res3).getJsonObject("data")
.getJsonArray("fileList").getJsonObject(0).getString("fid"); // 文件fid
// 调试输出文件信息
System.out.println("文件名称: " + filename);
System.out.println("文件fid: " + filefid);
log.debug("文件名称: {}, fid: {}", filename, filefid);
// 检查文件是否失效
httpClient.postAbs(SHARE_URL_API + "dl/sign").putHeaders(headers)
@@ -114,8 +108,7 @@ public class WsTool extends PanBase {
// 获取直链
String fileurl = asJson(res4).getJsonObject("data").getString("url");
// 调试输出文件直链
System.out.println("文件直链: " + fileurl);
log.debug("文件直链: {}", fileurl);
if (!fileurl.equals("")) {
promise.complete(URLDecoder.decode(fileurl, StandardCharsets.UTF_8));
@@ -14,7 +14,6 @@ import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.HexFormat;
import java.util.Random;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -299,7 +298,7 @@ public class AESUtils {
//length用户要求产生字符串的长度
public static String getRandomString(int length){
String str="abcdefghijklmnopqrstuvwxyz0123456789";
Random random=new Random();
SecureRandom random=new SecureRandom();
StringBuilder sb=new StringBuilder();
for(int i=0;i<length;i++){
int number=random.nextInt(36);
@@ -33,6 +33,9 @@ public class CommonUtils {
public static Map<String, String> getURLParams(String url) throws MalformedURLException {
URL fullUrl = new URL(url);
String query = fullUrl.getQuery();
if (query == null || query.isEmpty()) {
return new HashMap<>();
}
String[] params = query.split("&");
Map<String, String> map = new HashMap<>();
for (String param : params) {
@@ -0,0 +1,185 @@
package cn.qaiu.util;
import java.util.*;
/**
* Cookie 工具类
* 用于过滤和处理 Cookie 字符串
*/
public class CookieUtils {
/**
* UC/夸克网盘常用的 Cookie 字段
*/
public static final List<String> UC_QUARK_COOKIE_KEYS = Arrays.asList(
"__pus", // 主要的用户会话标识(最重要)
"__kp", // 用户标识
"__kps", // 会话密钥
"__ktd", // 会话令牌
"__uid", // 用户ID
"__puus" // 用户会话签名
);
/**
* 根据指定的 key 列表过滤 cookie
*
* @param cookieStr 原始 cookie 字符串格式如 "key1=value1; key2=value2"
* @param keys 需要保留的 cookie key 列表
* @return 过滤后的 cookie 字符串只包含指定的 key
*/
public static String filterCookie(String cookieStr, List<String> keys) {
if (cookieStr == null || cookieStr.isEmpty()) {
return "";
}
if (keys == null || keys.isEmpty()) {
return cookieStr;
}
// keys 转为 Set 以提高查找效率
Set<String> keySet = new HashSet<>(keys);
StringBuilder result = new StringBuilder();
String[] cookies = cookieStr.split(";\\s*");
for (String cookie : cookies) {
if (cookie.isEmpty()) {
continue;
}
// 提取 cookie key
int equalIndex = cookie.indexOf('=');
if (equalIndex > 0) {
String key = cookie.substring(0, equalIndex).trim();
// 如果 key 在需要的列表中保留这个 cookie
if (keySet.contains(key)) {
if (result.length() > 0) {
result.append("; ");
}
result.append(cookie);
}
}
}
return result.toString();
}
/**
* 使用 UC/夸克网盘默认的 cookie 字段过滤
*
* @param cookieStr 原始 cookie 字符串
* @return 过滤后的 cookie 字符串
*/
public static String filterUcQuarkCookie(String cookieStr) {
return filterCookie(cookieStr, UC_QUARK_COOKIE_KEYS);
}
/**
* cookie 字符串中提取指定 key 的值
*
* @param cookieStr cookie 字符串
* @param key 要提取的 cookie key
* @return cookie 如果不存在返回 null
*/
public static String getCookieValue(String cookieStr, String key) {
if (cookieStr == null || cookieStr.isEmpty() || key == null) {
return null;
}
String[] cookies = cookieStr.split(";\\s*");
for (String cookie : cookies) {
if (cookie.startsWith(key + "=")) {
int equalIndex = cookie.indexOf('=');
if (equalIndex > 0 && equalIndex < cookie.length() - 1) {
return cookie.substring(equalIndex + 1);
}
}
}
return null;
}
/**
* 检查 cookie 字符串中是否包含指定的 key
*
* @param cookieStr cookie 字符串
* @param key 要检查的 cookie key
* @return true 表示包含该 key
*/
public static boolean containsKey(String cookieStr, String key) {
return getCookieValue(cookieStr, key) != null;
}
/**
* 更新 cookie 字符串中的指定 cookie
*
* @param cookieStr 原始 cookie 字符串
* @param cookieName cookie 名称
* @param newValue 新的完整 cookie 格式cookieName=value
* @return 更新后的 cookie 字符串
*/
public static String updateCookieValue(String cookieStr, String cookieName, String newValue) {
if (cookieStr == null || cookieStr.isEmpty()) {
return newValue;
}
StringBuilder result = new StringBuilder();
String[] cookies = cookieStr.split(";\\s*");
boolean found = false;
for (String cookie : cookies) {
if (cookie.startsWith(cookieName + "=")) {
// 替换为新值
if (result.length() > 0) result.append("; ");
result.append(newValue);
found = true;
} else if (!cookie.isEmpty()) {
if (result.length() > 0) result.append("; ");
result.append(cookie);
}
}
// 如果原来没有这个 cookie添加它
if (!found) {
if (result.length() > 0) result.append("; ");
result.append(newValue);
}
return result.toString();
}
/**
* 合并多个 cookie 字符串后面的会覆盖前面的同名 cookie
*
* @param cookieStrings cookie 字符串数组
* @return 合并后的 cookie 字符串
*/
public static String mergeCookies(String... cookieStrings) {
if (cookieStrings == null || cookieStrings.length == 0) {
return "";
}
Map<String, String> cookieMap = new LinkedHashMap<>();
for (String cookieStr : cookieStrings) {
if (cookieStr == null || cookieStr.isEmpty()) {
continue;
}
String[] cookies = cookieStr.split(";\\s*");
for (String cookie : cookies) {
if (cookie.isEmpty()) {
continue;
}
int equalIndex = cookie.indexOf('=');
if (equalIndex > 0) {
String key = cookie.substring(0, equalIndex).trim();
cookieMap.put(key, cookie);
}
}
}
return String.join("; ", cookieMap.values());
}
}
@@ -0,0 +1,111 @@
package cn.qaiu.util;
import java.time.*;
import java.time.format.DateTimeFormatter;
/**
* 日期时间工具类用于转换各种时间戳格式为可读的日期字符串
*/
public class DateTimeUtils {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
/**
* 将毫秒时间戳转换为 yyyy-MM-dd HH:mm:ss 格式
* @param millis 毫秒时间戳
* @return 格式化后的日期字符串
*/
public static String formatMillisToDateTime(long millis) {
return FORMATTER.format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDateTime());
}
/**
* 将毫秒时间戳字符串转换为 yyyy-MM-dd HH:mm:ss 格式
* @param millisStr 毫秒时间戳字符串 "1684737715067"
* @return 格式化后的日期字符串
*/
public static String formatMillisStringToDateTime(String millisStr) {
if (millisStr == null || millisStr.trim().isEmpty()) {
return "";
}
try {
long millis = Long.parseLong(millisStr.trim());
return formatMillisToDateTime(millis);
} catch (NumberFormatException e) {
// 如果解析失败返回原始值
return millisStr;
}
}
/**
* 将秒级时间戳转换为 yyyy-MM-dd HH:mm:ss 格式
* @param seconds 秒级时间戳
* @return 格式化后的日期字符串
*/
public static String formatSecondsToDateTime(long seconds) {
return FORMATTER.format(Instant.ofEpochSecond(seconds).atZone(ZoneId.systemDefault()).toLocalDateTime());
}
/**
* 将秒级时间戳字符串转换为 yyyy-MM-dd HH:mm:ss 格式
* @param secondsStr 秒级时间戳字符串
* @return 格式化后的日期字符串
*/
public static String formatSecondsStringToDateTime(String secondsStr) {
if (secondsStr == null || secondsStr.trim().isEmpty()) {
return "";
}
try {
long seconds = Long.parseLong(secondsStr.trim());
return formatSecondsToDateTime(seconds);
} catch (NumberFormatException e) {
// 如果解析失败返回原始值
return secondsStr;
}
}
/**
* 智能转换时间戳自动判断是毫秒还是秒级
* 根据值的大小判断如果大于等于 10000000000 2286-11-20视为毫秒否则视为秒级
* @param timestamp 时间戳字符串
* @return 格式化后的日期字符串
*/
public static String formatTimestampToDateTime(String timestamp) {
if (timestamp == null || timestamp.trim().isEmpty()) {
return "";
}
try {
long value = Long.parseLong(timestamp.trim());
// 10000000000 对应 2286-11-20毫秒 1970-04-26秒级
// 使用 10^10 作为分界线
if (value >= 10_000_000_000L) {
return formatMillisToDateTime(value);
} else {
return formatSecondsToDateTime(value);
}
} catch (NumberFormatException e) {
// 如果是 ISO 8601 格式尝试解析
return formatISODateTime(timestamp);
}
}
/**
* 解析并格式化 ISO 8601 格式的日期时间字符串
* @param isoDateTime ISO 8601 格式的日期时间字符串
* @return 格式化后的日期字符串
*/
public static String formatISODateTime(String isoDateTime) {
if (isoDateTime == null || isoDateTime.trim().isEmpty()) {
return "";
}
try {
OffsetDateTime offsetDateTime = OffsetDateTime.parse(isoDateTime, ISO_FORMATTER);
return FORMATTER.format(offsetDateTime.toLocalDateTime());
} catch (Exception e) {
// 如果格式化失败直接返回原始值
return isoDateTime;
}
}
}
@@ -5,6 +5,8 @@ import io.vertx.core.Vertx;
import io.vertx.core.http.impl.headers.HeadersMultiMap;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.client.WebClientSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
@@ -15,6 +17,8 @@ import java.util.ArrayList;
import java.util.List;
public class IpExtractor {
private static final Logger log = LoggerFactory.getLogger(IpExtractor.class);
public static void main(String[] args) throws InterruptedException {
@@ -42,9 +46,9 @@ public class IpExtractor {
WebClient client = WebClient.create(Vertx.vertx());
WebClientSession webClientSession = WebClientSession.create(client);
webClientSession.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res->{
System.out.println(res.toString());
log.debug("response: {}", res.toString());
webClientSession.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res2->{
System.out.println(res2.toString());
log.debug("response2: {}", res2.toString());
});
});
@@ -21,11 +21,11 @@ import static cn.qaiu.util.AESUtils.encrypt;
*/
public class JsExecUtils {
private static final Invocable inv;
private static final ScriptEngineManager ENGINE_MANAGER = new ScriptEngineManager();
// 初始化脚本引擎
static {
ScriptEngineManager engineManager = new ScriptEngineManager();
ScriptEngine engine = engineManager.getEngineByName("JavaScript"); // 得到脚本引擎
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
try {
engine.eval(JsContent.ye123);
@@ -45,12 +45,11 @@ public class JsExecUtils {
}
/**
* 调用执行蓝奏云js文件
* 调用执行蓝奏云js文件每次动态JS代码无法复用引擎
*/
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException,
NoSuchMethodException {
ScriptEngineManager engineManager = new ScriptEngineManager();
ScriptEngine engine = engineManager.getEngineByName("JavaScript"); // 得到脚本引擎
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
engine.eval(JsContent.lz + "\n" + jsText);
Invocable inv = (Invocable) engine;
//调用js中的函数
@@ -63,12 +62,11 @@ public class JsExecUtils {
/**
* 调用执行蓝奏云js文件
* 调用执行js文件使用缓存的 ScriptEngineManager 创建新引擎实例
*/
public static Object executeOtherJs(String jsText, String funName, Object ... args) throws ScriptException,
NoSuchMethodException {
ScriptEngineManager engineManager = new ScriptEngineManager();
ScriptEngine engine = engineManager.getEngineByName("JavaScript"); // 得到脚本引擎
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
engine.eval(jsText);
Invocable inv = (Invocable) engine;
//调用js中的函数
@@ -8,15 +8,19 @@ import io.vertx.core.http.impl.headers.HeadersMultiMap;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.client.WebClientSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ReqIpUtil {
public static String BASE_URL = "https://ip.ihuan.me";
public static String BASE_URL_TEMPLATE = BASE_URL + "/{path}";
private static final Logger log = LoggerFactory.getLogger(ReqIpUtil.class);
public static final String BASE_URL = "https://ip.ihuan.me";
public static final String BASE_URL_TEMPLATE = BASE_URL + "/{path}";
// GET https://ip.ihuan.me/mouse.do -> $("input[name='key']").val("30b4975b5547fed806bd2b9caa18485a");
public static String PATH1 = "mouse.do";
public static final String PATH1 = "mouse.do";
public static String PATH2 = "tqdl.html";
public static final String PATH2 = "tqdl.html";
// 创建请求头Map
static MultiMap headers = new HeadersMultiMap();
@@ -58,15 +62,15 @@ public class ReqIpUtil {
void next(AsyncResult<HttpResponse<Buffer>> response) {
if (response.failed()) {
response.cause().printStackTrace();
log.error("请求失败", response.cause());
} else {
HttpResponse<Buffer> res = response.result();
System.out.println("Received response with status code " + res.statusCode());
System.out.println("Body: " + res.body());
log.debug("Received response with status code {}", res.statusCode());
log.debug("Body: {}", res.body());
webClientSession.getAbs(BASE_URL_TEMPLATE).setTemplateParam("path", PATH1)
.putHeaders(headers) // 将请求头Map添加到请求中
.send(response2 -> {
System.out.println(response2.result().bodyAsString());
log.debug("response2: {}", response2.result().bodyAsString());
});
}
@@ -2,6 +2,9 @@ package cn.qaiu.util;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
@@ -10,6 +13,8 @@ import java.util.Map;
public class URLUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(URLUtil.class);
private final Map<String, String> queryParams = new HashMap<>();
// 构造函数传入URL并解析参数
@@ -31,7 +36,7 @@ public class URLUtil {
}
}
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("URL解析失败: {}", url, e);
}
}
@@ -1,105 +1,105 @@
// ==UserScript==
// @name Fetch API示例解析器
// @type fetch_demo
// @displayName Fetch演示
// @description 演示如何在ES5环境中使用fetch API和async/await
// @match https?://example\.com/s/(?<KEY>\w+)
// @author QAIU
// @version 1.0.0
// ==/UserScript==
// // ==UserScript==
// // @name Fetch API示例解析器
// // @type fetch_demo
// // @displayName Fetch演示
// // @description 演示如何在ES5环境中使用fetch API和async/await
// // @match https?://example\.com/s/(?<KEY>\w+)
// // @author QAIU
// // @version 1.0.0
// // ==/UserScript==
// 使用require导入类型定义(仅用于IDE类型提示)
var types = require('./types');
/** @typedef {types.ShareLinkInfo} ShareLinkInfo */
/** @typedef {types.JsHttpClient} JsHttpClient */
/** @typedef {types.JsLogger} JsLogger */
// // 使用require导入类型定义(仅用于IDE类型提示)
// var types = require('./types');
// /** @typedef {types.ShareLinkInfo} ShareLinkInfo */
// /** @typedef {types.JsHttpClient} JsHttpClient */
// /** @typedef {types.JsLogger} JsLogger */
/**
* 演示使用fetch API的解析器
* 注意虽然源码中使用了ES6+语法async/await但在浏览器中会被编译为ES5
*
* @param {ShareLinkInfo} shareLinkInfo - 分享链接信息
* @param {JsHttpClient} http - HTTP客户端传统方式
* @param {JsLogger} logger - 日志对象
* @returns {string} 下载链接
*/
function parse(shareLinkInfo, http, logger) {
logger.info("=== Fetch API Demo ===");
// /**
// * 演示使用fetch API的解析器
// * 注意:虽然源码中使用了ES6+语法(async/await),但在浏览器中会被编译为ES5
// *
// * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息
// * @param {JsHttpClient} http - HTTP客户端(传统方式)
// * @param {JsLogger} logger - 日志对象
// * @returns {string} 下载链接
// */
// function parse(shareLinkInfo, http, logger) {
// logger.info("=== Fetch API Demo ===");
// 方式1:使用传统的http对象(同步)
logger.info("方式1: 使用传统http对象");
var response1 = http.get("https://httpbin.org/get");
logger.info("状态码: " + response1.statusCode());
// // 方式1:使用传统的http对象(同步)
// logger.info("方式1: 使用传统http对象");
// var response1 = http.get("https://httpbin.org/get");
// logger.info("状态码: " + response1.statusCode());
// 方式2:使用fetch API(基于Promise
logger.info("方式2: 使用fetch API");
// // 方式2:使用fetch API(基于Promise
// logger.info("方式2: 使用fetch API");
// 注意:在ES5环境中,我们需要手动处理Promise
// 这个示例展示了如何在ES5中使用fetch
var fetchPromise = fetch("https://httpbin.org/get");
// // 注意:在ES5环境中,我们需要手动处理Promise
// // 这个示例展示了如何在ES5中使用fetch
// var fetchPromise = fetch("https://httpbin.org/get");
// 等待Promise完成(同步等待模拟)
var result = null;
var error = null;
// // 等待Promise完成(同步等待模拟)
// var result = null;
// var error = null;
fetchPromise
.then(function(response) {
logger.info("Fetch响应状态: " + response.status);
return response.text();
})
.then(function(text) {
logger.info("Fetch响应内容: " + text.substring(0, 100) + "...");
result = "https://example.com/download/demo.file";
})
['catch'](function(err) {
logger.error("Fetch失败: " + err.message);
error = err;
});
// fetchPromise
// .then(function(response) {
// logger.info("Fetch响应状态: " + response.status);
// return response.text();
// })
// .then(function(text) {
// logger.info("Fetch响应内容: " + text.substring(0, 100) + "...");
// result = "https://example.com/download/demo.file";
// })
// ['catch'](function(err) {
// logger.error("Fetch失败: " + err.message);
// error = err;
// });
// 简单的等待循环(实际场景中不推荐,这里仅作演示)
var timeout = 5000; // 5秒超时
var start = Date.now();
while (result === null && error === null && (Date.now() - start) < timeout) {
// 等待Promise完成
java.lang.Thread.sleep(10);
}
// // 简单的等待循环(实际场景中不推荐,这里仅作演示)
// var timeout = 5000; // 5秒超时
// var start = Date.now();
// while (result === null && error === null && (Date.now() - start) < timeout) {
// // 等待Promise完成
// java.lang.Thread.sleep(10);
// }
if (error !== null) {
throw error;
}
// if (error !== null) {
// throw error;
// }
if (result === null) {
throw new Error("Fetch超时");
}
// if (result === null) {
// throw new Error("Fetch超时");
// }
return result;
}
// return result;
// }
/**
* 演示POST请求
*/
function demonstratePost(logger) {
logger.info("=== 演示POST请求 ===");
// /**
// * 演示POST请求
// */
// function demonstratePost(logger) {
// logger.info("=== 演示POST请求 ===");
var postPromise = fetch("https://httpbin.org/post", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
key: "value",
demo: true
})
});
// var postPromise = fetch("https://httpbin.org/post", {
// method: "POST",
// headers: {
// "Content-Type": "application/json"
// },
// body: JSON.stringify({
// key: "value",
// demo: true
// })
// });
postPromise
.then(function(response) {
return response.json();
})
.then(function(data) {
logger.info("POST响应: " + JSON.stringify(data));
})
['catch'](function(err) {
logger.error("POST失败: " + err.message);
});
}
// postPromise
// .then(function(response) {
// return response.json();
// })
// .then(function(data) {
// logger.info("POST响应: " + JSON.stringify(data));
// })
// ['catch'](function(err) {
// logger.error("POST失败: " + err.message);
// });
// }
@@ -8,6 +8,8 @@ import cn.qaiu.parser.customjs.JsParserExecutor;
import cn.qaiu.WebClientVertxInit;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.HashMap;
@@ -22,15 +24,26 @@ import java.util.Map;
*/
public class BaiduPhotoParserTest {
private Vertx vertx;
@Before
public void setUp() {
vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
}
@After
public void tearDown() {
if (vertx != null) {
vertx.close();
}
}
@Test
public void testBaiduPhotoParserRegistration() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
// 检查是否加载了百度相册解析器
CustomParserConfig config = CustomParserRegistry.get("baidu_photo");
assert config != null : "百度相册解析器未加载";
@@ -44,11 +57,7 @@ public class BaiduPhotoParserTest {
public void testBaiduPhotoFileShareExecution() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
// 创建解析器 - 测试文件分享链接
IPanTool tool = ParserCreate.fromType("baidu_photo")
@@ -76,11 +85,7 @@ public class BaiduPhotoParserTest {
public void testBaiduPhotoFolderShareExecution() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
// 创建解析器 - 测试文件夹分享链接
IPanTool tool = ParserCreate.fromType("baidu_photo")
@@ -108,11 +113,7 @@ public class BaiduPhotoParserTest {
public void testBaiduPhotoParserFileList() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
IPanTool tool = ParserCreate.fromType("baidu_photo")
// 分享key PPgOEodBVE
@@ -166,11 +167,7 @@ public class BaiduPhotoParserTest {
public void testBaiduPhotoParserById() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
// 创建ShareLinkInfo
Map<String, Object> otherParam = new HashMap<>();

Some files were not shown because too many files have changed in this diff Show More