mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-06-16 02:17:28 +00:00
Compare commits
232 Commits
ed40b254e4
...
v3.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452fd0ea2c | ||
|
|
dd8f2efb37 | ||
|
|
0feb8e798a | ||
|
|
d55d8edd2f | ||
|
|
451496f102 | ||
|
|
6d6351bd58 | ||
|
|
1a5fc8d1ef | ||
|
|
a44f30f7e5 | ||
|
|
f9ebd34de3 | ||
|
|
b47db300a6 | ||
|
|
d19d8573f9 | ||
|
|
799e120069 | ||
|
|
13f83e8795 | ||
|
|
7b5900aae4 | ||
|
|
e36c0bbe45 | ||
|
|
af723aed3a | ||
|
|
0978186679 | ||
|
|
17460ff271 | ||
|
|
f81b3852ee | ||
|
|
37abebf8f8 | ||
|
|
79fab8c0d6 | ||
|
|
9b70fb2778 | ||
|
|
bd2868748f | ||
|
|
1f47bf13b5 | ||
|
|
7d8b33afe0 | ||
|
|
367f7c78a4 | ||
|
|
1bd23ec4ae | ||
|
|
3461532679 | ||
|
|
4cfcdfa1f8 | ||
|
|
ac2f526a1c | ||
|
|
cfb624e9e0 | ||
|
|
e1bf45b5c8 | ||
|
|
e2dc611aa4 | ||
|
|
77c953626f | ||
|
|
0e14c9a925 | ||
|
|
a4e8585e2c | ||
|
|
cf7d64916e | ||
|
|
4ac80bbfe8 | ||
|
|
cfe8352d45 | ||
|
|
4a0fe61d30 | ||
|
|
c62e109aff | ||
|
|
ff400d3be3 | ||
|
|
1d243b8f1b | ||
|
|
732a7f86fe | ||
|
|
3c428f6a6d | ||
|
|
bff17f2d4e | ||
|
|
df600eaada | ||
|
|
dd4027c931 | ||
|
|
d1569195e4 | ||
|
|
d6e88f0c53 | ||
|
|
2a9fa81e56 | ||
|
|
5eed1fdfa0 | ||
|
|
cf7fc4f502 | ||
|
|
0ea31d631a | ||
|
|
74840ab63f | ||
|
|
31f33339f1 | ||
|
|
c0a0d0dc47 | ||
|
|
0cd77ee9b9 | ||
|
|
741d7aa8ca | ||
|
|
d06a80dc73 | ||
|
|
86b9c43b8b | ||
|
|
206981d4b4 | ||
|
|
7d5831b5f4 | ||
|
|
7ca63985bd | ||
|
|
06416a4e5f | ||
|
|
88739e8d1a | ||
|
|
ffaba4f496 | ||
|
|
c4f94a2bc7 | ||
|
|
49a3918244 | ||
|
|
caddff567f | ||
|
|
aed9e9f10d | ||
|
|
6557b49383 | ||
|
|
082cc4c743 | ||
|
|
e261ebe698 | ||
|
|
5a0dc69186 | ||
|
|
29d8bf3ea4 | ||
|
|
07a330cfd4 | ||
|
|
aa30571709 | ||
|
|
2bb9912cf5 | ||
|
|
8f77d9fe98 | ||
|
|
9cb32b3e8f | ||
|
|
080206925f | ||
|
|
54d2a8189c | ||
|
|
377bc12cf9 | ||
|
|
da715c8a8f | ||
|
|
fa4028296f | ||
|
|
dc629a3126 | ||
|
|
746c7ad5b3 | ||
|
|
aef1b9ab11 | ||
|
|
79c9eb3dda | ||
|
|
5a08ed68c2 | ||
|
|
e5a623c5a8 | ||
|
|
4586138bf1 | ||
|
|
1dddec110e | ||
|
|
46e9999e4c | ||
|
|
a664ae3a56 | ||
|
|
9bcdcb2cb7 | ||
|
|
0b8592559a | ||
|
|
c0b18be5ab | ||
|
|
46b2eb1ccd | ||
|
|
d323376bed | ||
|
|
838c86ae15 | ||
|
|
42925c857c | ||
|
|
ba981d281f | ||
|
|
4159b884de | ||
|
|
36b38421e5 | ||
|
|
b77c8a80e9 | ||
|
|
886dcd039f | ||
|
|
d99885d396 | ||
|
|
942de9c430 | ||
|
|
ae3ff9ecbb | ||
|
|
bcc4315ea9 | ||
|
|
2f7304ab2d | ||
|
|
0df01ba3d5 | ||
|
|
710e454fd0 | ||
|
|
c46dfa00a0 | ||
|
|
9a3ea05023 | ||
|
|
66d7a62d3a | ||
|
|
f1b6cd3e18 | ||
|
|
189d1477a8 | ||
|
|
9c3945f45a | ||
|
|
77c7d6c5d6 | ||
|
|
ab3009e9cc | ||
|
|
1c2291f9cf | ||
|
|
efb135ee48 | ||
|
|
0699c4a127 | ||
|
|
33cef5f8e1 | ||
|
|
32d467b6d9 | ||
|
|
6dfa770137 | ||
|
|
0dfee8ab22 | ||
|
|
85fe910f25 | ||
|
|
6c60b0116f | ||
|
|
8dfcf510f6 | ||
|
|
be1ed3d46d | ||
|
|
1fca578c07 | ||
|
|
a83665ac44 | ||
|
|
21e8a370c3 | ||
|
|
3dd4dd139b | ||
|
|
afe2046bc8 | ||
|
|
6d24388690 | ||
|
|
0b024a849a | ||
|
|
8745dc3567 | ||
|
|
1f4c7019d4 | ||
|
|
255e7b2fb5 | ||
|
|
7419e536cf | ||
|
|
74df000287 | ||
|
|
2e0127d609 | ||
|
|
2b9168e8df | ||
|
|
7bb3cf7c51 | ||
|
|
2ce5058be0 | ||
|
|
a5fc41f152 | ||
|
|
3245a27156 | ||
|
|
ce0fbf65aa | ||
|
|
eb87c3d865 | ||
|
|
990a90e461 | ||
|
|
45cb255a4d | ||
|
|
5e8b8a3575 | ||
|
|
c317e53c24 | ||
|
|
29b59d8450 | ||
|
|
97b7e2f86e | ||
|
|
2f55294b58 | ||
|
|
2161190d9a | ||
|
|
aaae301cbc | ||
|
|
9ca6511235 | ||
|
|
8582290db3 | ||
|
|
5ff33d7c58 | ||
|
|
0cfb69a240 | ||
|
|
110a9beda4 | ||
|
|
fd6a3f5929 | ||
|
|
82ad6ec427 | ||
|
|
1bfc7c960d | ||
|
|
332f49f483 | ||
|
|
b967c7a1bb | ||
|
|
519dbe1f77 | ||
|
|
c64855d4ad | ||
|
|
d50d10ba89 | ||
|
|
e79478c421 | ||
|
|
c401a84eb8 | ||
|
|
a9978a6202 | ||
|
|
cc9d0a4b30 | ||
|
|
696ef832f8 | ||
|
|
442f9d1d2e | ||
|
|
a45a64380c | ||
|
|
df7442c3dd | ||
|
|
1a949725f3 | ||
|
|
7c14f3437b | ||
|
|
bc402da365 | ||
|
|
b95b474660 | ||
|
|
691a3770d9 | ||
|
|
49ec54a3b5 | ||
|
|
2fc15f437e | ||
|
|
190f6ca7ab | ||
|
|
c683fd27d4 | ||
|
|
d815cc1010 | ||
|
|
fd84ff1200 | ||
|
|
a420bad305 | ||
|
|
6ef6e47580 | ||
|
|
94f83ec296 | ||
|
|
702569c701 | ||
|
|
d4940ca9ee | ||
|
|
dbd1c138ca | ||
|
|
0b49c55cf3 | ||
|
|
b1ec3b2eea | ||
|
|
9ea89feee7 | ||
|
|
4a843194a3 | ||
|
|
03503115fd | ||
|
|
1870aef60e | ||
|
|
ed8fd66d1e | ||
|
|
c1c4c8cdc5 | ||
|
|
256ec3b152 | ||
|
|
da490e5bbd | ||
|
|
ba0ac86eea | ||
|
|
b5544c4131 | ||
|
|
d94ea6aaf3 | ||
|
|
742dda8677 | ||
|
|
76e0db0cfb | ||
|
|
6458a6e2c5 | ||
|
|
cbf2294a8e | ||
|
|
9d558bf4e2 | ||
|
|
fdf067c25e | ||
|
|
5f9da47513 | ||
|
|
b150641e3b | ||
|
|
6355c35452 | ||
|
|
81ffbbd6b1 | ||
|
|
07c650a474 | ||
|
|
04443bcb5e | ||
|
|
d06974d556 | ||
|
|
80fa51fd0a | ||
|
|
a170134456 | ||
|
|
ba76e0dc6c | ||
|
|
4a79542a46 | ||
|
|
999e260a60 |
249
.github/workflows/maven.yml
vendored
249
.github/workflows/maven.yml
vendored
@@ -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 CI(Maven 构建 + 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: 生成精简 JRE(jlink)
|
||||
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: 打包 ZIP(Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
cd native-package
|
||||
zip -r "../${{ matrix.artifact-name }}.zip" netdisk-fast-download/
|
||||
|
||||
- name: 打包 ZIP(Windows)
|
||||
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
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -31,6 +31,7 @@ target/
|
||||
sdkTest.log
|
||||
app.yml
|
||||
app-local.yml
|
||||
secret.yml
|
||||
|
||||
|
||||
#some local files
|
||||
@@ -41,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
|
||||
@@ -89,3 +92,4 @@ yarn-error.log*
|
||||
**/${project.build.directory}/
|
||||
**/${project.basedir}/target/
|
||||
**/${basedir}/target/
|
||||
.spec-workflow/
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.debug.settings.onBuildFailureProceed": true
|
||||
}
|
||||
11
Dockerfile
11
Dockerfile
@@ -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"]
|
||||
|
||||
114
README.md
114
README.md
@@ -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>
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
# 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
|
||||
|
||||
```
|
||||
|
||||
@@ -42,18 +55,12 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
||||
|
||||
**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,15 +85,16 @@ 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/)
|
||||
@@ -143,7 +149,7 @@ 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 编码
|
||||
@@ -179,6 +185,18 @@ GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
|
||||
```
|
||||
|
||||
> 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。
|
||||
> [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
||||
|
||||
#### 密钥作用说明
|
||||
|
||||
- `server.authEncryptKey`
|
||||
- 作用:用于 `auth` 参数的 AES 加解密
|
||||
- 要求:16位(AES-128)
|
||||
|
||||
- `server.donatedAccountFailureTokenSignKey`
|
||||
- 作用:用于“捐赠账号失败计数 token”的 HMAC 签名/验签
|
||||
- 目的:防止客户端伪造失败计数请求
|
||||
- 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同
|
||||
|
||||
### 特殊说明
|
||||
|
||||
@@ -313,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 | 不限大小 |
|
||||
|
||||
# 打包部署
|
||||
|
||||
@@ -401,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
|
||||
@@ -474,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
|
||||
@@ -515,20 +516,5 @@ Core模块集成Vert.x实现类似spring的注解式路由API
|
||||
</p>
|
||||
|
||||
|
||||
### 关于赞助定制专属版
|
||||
1. 专属版提供对小飞机,蓝奏优享大文件解析的支持, 提供天翼云盘/移动云盘/联通云盘的解析支持。
|
||||
2. 可提供托管服务:包含部署服务和云服务器环境。
|
||||
3. 可提供功能定制开发。
|
||||
您可能需要提供一定的资金赞助支持定制专属版, 请添加以下任意一个联系方式详谈赞助模式:
|
||||
<p>qq: 197575894</p>
|
||||
<p>wechat: imcoding_</p>
|
||||
|
||||
<!--
|
||||

|
||||
|
||||
[手机端支付宝打赏跳转链接](https://qr.alipay.com/fkx01882dnoxxtjenhlxt53)
|
||||
-->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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" "$@"
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
12
core/src/main/java/cn/qaiu/vx/core/base/AppRun.java
Normal file
12
core/src/main/java/cn/qaiu/vx/core/base/AppRun.java
Normal file
@@ -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() {
|
||||
|
||||
|
||||
23
core/src/main/java/cn/qaiu/vx/core/base/DefaultAppRun.java
Normal file
23
core/src/main/java/cn/qaiu/vx/core/base/DefaultAppRun.java
Normal file
@@ -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配置文件
|
||||
*
|
||||
|
||||
20
core/src/main/java/cn/qaiu/vx/core/util/FutureUtils.java
Normal file
20
core/src/main/java/cn/qaiu/vx/core/util/FutureUtils.java
Normal file
@@ -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);
|
||||
@@ -129,8 +263,16 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
|
||||
private HttpServer getHttpsServer(JsonObject proxyConf) {
|
||||
HttpServerOptions httpServerOptions = new HttpServerOptions()
|
||||
.setCompressionSupported(true);
|
||||
|
||||
// 【优化】高并发服务器配置
|
||||
.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");
|
||||
|
||||
@@ -184,7 +326,6 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
} else {
|
||||
staticHandler = StaticHandler.create();
|
||||
}
|
||||
|
||||
if (staticConf.containsKey("directory-listing")) {
|
||||
staticHandler.setDirectoryListing(staticConf.getBoolean("directory-listing"));
|
||||
} else if (staticConf.containsKey("index")) {
|
||||
@@ -197,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 -> {
|
||||
// 代理规则
|
||||
@@ -211,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;
|
||||
}
|
||||
@@ -227,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package cn.qaiu.vx.core.verticle.conf;
|
||||
|
||||
import io.vertx.codegen.annotations.DataObject;
|
||||
import io.vertx.codegen.json.annotations.JsonGen;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.net.ProxyOptions;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@DataObject
|
||||
//@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 = 6432;
|
||||
|
||||
public static final Integer DEFAULT_TIMEOUT = 15000;
|
||||
|
||||
Integer timeout;
|
||||
|
||||
String username;
|
||||
|
||||
String password;
|
||||
|
||||
Integer port;
|
||||
|
||||
ProxyOptions preProxyOptions;
|
||||
|
||||
public HttpProxyConf() {
|
||||
this.username = DEFAULT_USERNAME;
|
||||
this.password = DEFAULT_PASSWORD;
|
||||
this.port = DEFAULT_PORT;
|
||||
this.timeout = DEFAULT_TIMEOUT;
|
||||
this.preProxyOptions = new ProxyOptions();
|
||||
}
|
||||
|
||||
public HttpProxyConf(JsonObject json) {
|
||||
this();
|
||||
}
|
||||
|
||||
|
||||
public Integer getTimeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
public HttpProxyConf setTimeout(Integer timeout) {
|
||||
this.timeout = timeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public HttpProxyConf setUsername(String username) {
|
||||
this.username = username;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public HttpProxyConf setPassword(String password) {
|
||||
this.password = password;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Integer getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public HttpProxyConf setPort(Integer port) {
|
||||
this.port = port;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ProxyOptions getPreProxyOptions() {
|
||||
return preProxyOptions;
|
||||
}
|
||||
|
||||
public HttpProxyConf setPreProxyOptions(ProxyOptions preProxyOptions) {
|
||||
this.preProxyOptions = preProxyOptions;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -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/json,body 是 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/json,body 是 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 body,asJsonObject() 返回 null,整个 if 跳过
|
||||
JsonObject wrongParsed = parseAsJsonObject(jsonArrayBody);
|
||||
boolean oldConditionPassed = wrongParsed != null; // 旧代码的第二个条件
|
||||
Assert.assertFalse("旧代码 bug: JsonArray body 会导致 asJsonObject()==null,整个分支跳过", oldConditionPassed);
|
||||
|
||||
// 新代码:先进 if,body==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();
|
||||
}
|
||||
}
|
||||
36
core/src/test/java/cn/qaiu/vx/core/test/TestJsonHandler.java
Normal file
36
core/src/test/java/cn/qaiu/vx/core/test/TestJsonHandler.java
Normal file
@@ -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
docker-entrypoint.sh
Normal file
9
docker-entrypoint.sh
Normal 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
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"mvn": "^3.5.0"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<dependency>
|
||||
<groupId>cn.qaiu</groupId>
|
||||
<artifactId>parser</artifactId>
|
||||
<version>10.1.17</version>
|
||||
<version>10.2.5</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<dependency>
|
||||
<groupId>cn.qaiu</groupId>
|
||||
<artifactId>parser</artifactId>
|
||||
<version>10.1.17</version>
|
||||
<version>10.2.5</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
|
||||
@@ -36,6 +36,22 @@ URL解码 → Base64解码 → AES解密 → JSON对象
|
||||
- **密钥长度**: 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 对象
|
||||
@@ -301,14 +317,25 @@ if (auths != null) {
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 `app-dev.yml` 中配置加密密钥:
|
||||
在 `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 认证类型
|
||||
|
||||
@@ -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://git@github.com: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://git@github.com:${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.2.1</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,28 +40,34 @@ 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 = WebClient.create(WebClientVertxInit.get()
|
||||
, new WebClientOptions().setUserAgentEnabled(false)
|
||||
);
|
||||
protected WebClient clientDisableUA = SHARED_CLIENT_DISABLE_UA;
|
||||
|
||||
protected ShareLinkInfo shareLinkInfo;
|
||||
|
||||
|
||||
@@ -68,42 +68,43 @@ 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>.+)"),
|
||||
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),
|
||||
|
||||
@@ -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,7 +243,7 @@ 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("城通网盘",
|
||||
@@ -218,11 +251,6 @@ public enum PanDomainTemplate {
|
||||
"(?<KEY>[0-9a-zA-Z_-]+)(\\?p=(?<PWD>\\w+))?"),
|
||||
"https://474b.com/file/{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 +266,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 +276,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)
|
||||
@@ -285,6 +313,14 @@ public enum PanDomainTemplate {
|
||||
"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("网易云音乐分享",
|
||||
@@ -293,7 +329,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
|
||||
@@ -314,7 +350,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()
|
||||
|
||||
@@ -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头以支持压缩响应
|
||||
@@ -534,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()
|
||||
@@ -677,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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -109,9 +109,9 @@ public class FjTool extends PanBase {
|
||||
|
||||
// String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
||||
|
||||
static String token = null;
|
||||
static String userId = null;
|
||||
public static boolean authFlag = true;
|
||||
static volatile String token = null;
|
||||
static volatile String userId = null;
|
||||
public static volatile boolean authFlag = true;
|
||||
|
||||
public FjTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
@@ -289,12 +289,14 @@ public class FjTool extends PanBase {
|
||||
JsonObject json = asJson(res2);
|
||||
if (json.getInteger("code") == 200) {
|
||||
token = json.getJsonObject("data").getString("appToken");
|
||||
header0.set("appToken", token);
|
||||
log.info("登录成功 token: {}", token);
|
||||
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(header0).send().onSuccess(res -> {
|
||||
.putHeaders(h0).send().onSuccess(res -> {
|
||||
if (asJson(res).getInteger("code") == 200) {
|
||||
if (FjTool.userId == null) {
|
||||
FjTool.userId = asJson(res).getJsonObject("map").getString("userId");
|
||||
@@ -454,7 +456,10 @@ public class FjTool 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, promise0);
|
||||
return promise0.future();
|
||||
}
|
||||
@@ -495,7 +500,7 @@ public class FjTool extends PanBase {
|
||||
JsonArray list;
|
||||
try {
|
||||
JsonObject jsonObject = asJson(res);
|
||||
System.out.println(jsonObject.encodePrettily());
|
||||
log.debug("目录列表: {}", jsonObject.encodePrettily());
|
||||
list = jsonObject.getJsonArray("list");
|
||||
} catch (Exception e) {
|
||||
log.error("解析目录失败: {}", res.bodyAsString());
|
||||
@@ -576,6 +581,10 @@ public class FjTool extends PanBase {
|
||||
|
||||
// 第二次请求
|
||||
JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson");
|
||||
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"))
|
||||
|
||||
496
parser/src/main/java/cn/qaiu/parser/impl/FsTool.java
Normal file
496
parser/src/main/java/cn/qaiu/parser/impl/FsTool.java
Normal file
@@ -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) {
|
||||
}
|
||||
}
|
||||
55
parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java
Normal file
55
parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -5,35 +5,50 @@ 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.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 蓝奏云优享
|
||||
* v019b22
|
||||
*
|
||||
*/
|
||||
public class IzTool extends PanBase {
|
||||
|
||||
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
|
||||
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×tamp={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=×tamp={ts}&appToken=&extra=2";
|
||||
|
||||
// https://api.ilanzou.com/proved/user/info/map?devType=3&devModel=Chrome&uuid=TInRHH3QzRaMo-Ajl2PkJ&extra=2×tamp=EC2C6E7F45EB21338A17A7621E0BB437
|
||||
private static final String TOKEN_VERIFY_URL = API_URL0 +
|
||||
"proved/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}";
|
||||
|
||||
private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" +
|
||||
"&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}";
|
||||
// downloadId=x&enable=1&devType=6&uuid=x×tamp=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=×tamp={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×tamp={ts}";
|
||||
@@ -42,16 +57,15 @@ public class IzTool extends PanBase {
|
||||
"={uuid}&extra=2×tamp={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");
|
||||
@@ -69,38 +83,59 @@ 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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());
|
||||
// 检查并输出认证状态
|
||||
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
|
||||
webClientSession.postAbs(UriTemplate.of(url))
|
||||
@@ -121,36 +156,43 @@ public class IzTool extends PanBase {
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(res2 -> {
|
||||
handleParseResponse(asText(res2), shareId);
|
||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
||||
processFirstResponse(res2);
|
||||
}).onFailure(handleFail("请求1-重试"));
|
||||
return;
|
||||
}
|
||||
handleParseResponse(resBody, shareId);
|
||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
||||
});
|
||||
processFirstResponse(res);
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void handleParseResponse(String resBody, String shareId) {
|
||||
JsonObject resJson;
|
||||
try {
|
||||
resJson = new JsonObject(resBody);
|
||||
} catch (Exception e) {
|
||||
fail(FIRST_REQUEST_URL + " 解析JSON失败: " + resBody);
|
||||
return;
|
||||
}
|
||||
if (resJson.isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 返回内容为空");
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* 设置 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.getJsonArray("list").isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
||||
return;
|
||||
}
|
||||
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
||||
return;
|
||||
@@ -167,30 +209,250 @@ public class IzTool extends PanBase {
|
||||
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");
|
||||
// 其他参数
|
||||
// String fidEncode = AESUtils.encrypt2HexIz(fileId + "|");
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
|
||||
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs);
|
||||
// 第二次请求
|
||||
webClientSession.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));
|
||||
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();
|
||||
@@ -200,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);
|
||||
@@ -218,6 +479,22 @@ 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
|
||||
@@ -228,103 +505,134 @@ public class IzTool extends PanBase {
|
||||
.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");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
658
parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java
Normal file
658
parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java
Normal file
@@ -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×tamp={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=×tamp={ts}&appToken=&extra=2";
|
||||
|
||||
// https://api.ilanzou.com/proved/user/info/map?devType=3&devModel=Chrome&uuid=TInRHH3QzRaMo-Ajl2PkJ&extra=2×tamp=EC2C6E7F45EB21338A17A7621E0BB437
|
||||
private static final String TOKEN_VERIFY_URL = API_URL0 +
|
||||
"proved/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}";
|
||||
|
||||
private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" +
|
||||
"&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}";
|
||||
|
||||
private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX + "file/redirect?uuid={uuid}&devType=6&devCode={uuid}" +
|
||||
"&devModel=chrome&devVersion=127&appVersion=×tamp={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×tamp={ts}";
|
||||
|
||||
private static final String FILE_LIST_URL = API_URL_PREFIX + "/share/list?devType=6&devModel=Chrome&uuid" +
|
||||
"={uuid}&extra=2×tamp={ts}&shareId={shareId}&folderId" +
|
||||
"={folderId}&offset=1&limit=60";
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import io.vertx.ext.web.client.WebClientSession;
|
||||
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;
|
||||
@@ -64,7 +65,7 @@ public class LzTool extends PanBase {
|
||||
String html = asText(res);
|
||||
if (html.contains("var arg1='")) {
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
setCookie(html);
|
||||
setCookie(html, sUrl);
|
||||
webClientSession.getAbs(sUrl)
|
||||
.putHeaders(headers0)
|
||||
.send().onSuccess(res2 -> {
|
||||
@@ -81,10 +82,33 @@ public class LzTool extends PanBase {
|
||||
}
|
||||
|
||||
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) {
|
||||
e.printStackTrace();
|
||||
log.error("文件信息解析异常", e);
|
||||
}
|
||||
// 匹配iframe
|
||||
Pattern compile = Pattern.compile("src=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\"");
|
||||
@@ -98,20 +122,18 @@ public class LzTool extends PanBase {
|
||||
} catch (Exception e) {
|
||||
fail(e, "js引擎执行失败");
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// 没有密码
|
||||
String iframePath = matcher.group(1);
|
||||
String absoluteURI = SHARE_URL_PREFIX + iframePath;
|
||||
webClientSession.getAbs(absoluteURI).putHeaders(headers0).send().onSuccess(res2 -> {
|
||||
String html2= asText(res2);
|
||||
// Matcher matcher2 = Pattern.compile("'sign'\s*:\s*'(\\w+)'").matcher(html2);
|
||||
String html2 = asText(res2);
|
||||
String jsText = getJsText(html2);
|
||||
if (jsText == null) {
|
||||
headers0.add("Referer", absoluteURI);
|
||||
setCookie(html2);
|
||||
setCookie(html2, absoluteURI);
|
||||
webClientSession.getAbs(absoluteURI).send().onSuccess(res3 -> {
|
||||
String html3= asText(res3);
|
||||
String html3 = asText(res3);
|
||||
String jsText3 = getJsText(html3);
|
||||
if (jsText3 != null) {
|
||||
try {
|
||||
@@ -120,10 +142,8 @@ public class LzTool extends PanBase {
|
||||
} catch (ScriptException | NoSuchMethodException e) {
|
||||
fail(e, "引擎执行失败");
|
||||
}
|
||||
} else {
|
||||
|
||||
} else {
|
||||
fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效");
|
||||
return;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -138,14 +158,29 @@ public class LzTool extends PanBase {
|
||||
}
|
||||
}
|
||||
|
||||
private void setCookie(String html2) {
|
||||
int beginIndex = html2.indexOf("arg1='") + 6;
|
||||
String arg1 = html2.substring(beginIndex, html2.indexOf("';", beginIndex));
|
||||
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(".lanzn.com"); // 设置域名
|
||||
nettyCookie.setPath("/"); // 设置路径
|
||||
nettyCookie.setDomain(domain);
|
||||
nettyCookie.setPath("/");
|
||||
nettyCookie.setSecure(false);
|
||||
nettyCookie.setHttpOnly(false);
|
||||
webClientSession.cookieStore().put(nettyCookie);
|
||||
@@ -183,7 +218,7 @@ public class LzTool extends PanBase {
|
||||
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());
|
||||
@@ -218,7 +253,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -234,10 +269,18 @@ 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 webClientSession2 = WebClientSession.create(clientNoRedirects);
|
||||
@@ -248,12 +291,12 @@ public class LzTool extends PanBase {
|
||||
if (location0 == null) {
|
||||
fail(downUrl + " -> 直链获取失败2, 可能分享已失效");
|
||||
} else {
|
||||
setDateAndComplate(location0);
|
||||
setDateAndComplete(location0);
|
||||
}
|
||||
}).onFailure(handleFail(downUrl));
|
||||
return;
|
||||
}
|
||||
setDateAndComplate(location);
|
||||
setDateAndComplete(location);
|
||||
})
|
||||
.onFailure(handleFail(downUrl));
|
||||
} catch (Exception e) {
|
||||
@@ -262,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);
|
||||
@@ -295,14 +338,14 @@ public class LzTool extends PanBase {
|
||||
String pwd = shareLinkInfo.getSharePassword();
|
||||
|
||||
webClientSession.getAbs(sUrl).send().onSuccess(res -> {
|
||||
String html = res.bodyAsString();
|
||||
String html = asText(res);
|
||||
// 检查是否需要 cookie 验证
|
||||
if (html.contains("var arg1='")) {
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
setCookie(html);
|
||||
setCookie(html, sUrl);
|
||||
// 重新请求
|
||||
webClientSession.getAbs(sUrl).send().onSuccess(res2 -> {
|
||||
handleFileListParse(res2.bodyAsString(), pwd, sUrl, promise);
|
||||
handleFileListParse(asText(res2), pwd, sUrl, promise);
|
||||
}).onFailure(err -> promise.fail(err));
|
||||
return;
|
||||
}
|
||||
@@ -312,6 +355,11 @@ public class LzTool extends PanBase {
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -321,12 +369,12 @@ public class LzTool extends PanBase {
|
||||
log.debug("解析参数: {}", map);
|
||||
MultiMap headers = getHeaders(sUrl);
|
||||
|
||||
String url = SHARE_URL_PREFIX + "/filemoreajax.php?file=" + data.get("fid");
|
||||
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);
|
||||
setCookie(resBody, url);
|
||||
// 重新请求
|
||||
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res3 -> {
|
||||
handleFileListResponse(asText(res3), promise);
|
||||
@@ -335,7 +383,7 @@ public class LzTool extends PanBase {
|
||||
}
|
||||
handleFileListResponse(resBody, promise);
|
||||
}).onFailure(err -> promise.fail(err));
|
||||
} catch (ScriptException | NoSuchMethodException e) {
|
||||
} catch (ScriptException | NoSuchMethodException | RuntimeException e) {
|
||||
promise.fail(e);
|
||||
}
|
||||
}
|
||||
@@ -367,14 +415,20 @@ public class LzTool extends PanBase {
|
||||
Long sizeNum = FileSizeConverter.convertToBytes(size);
|
||||
String panType = shareLinkInfo.getType();
|
||||
String id = fileJson.getString("id");
|
||||
fileInfo.setFileName(fileJson.getString("name_all"))
|
||||
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(getDomainName() + "/d/" + panType + "/" + id)
|
||||
.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);
|
||||
@@ -386,6 +440,15 @@ public class LzTool extends PanBase {
|
||||
}
|
||||
}
|
||||
|
||||
@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();
|
||||
@@ -400,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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,602 +22,242 @@ 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";
|
||||
|
||||
// Cookie 刷新 API
|
||||
private static final String FLUSH_URL = "https://drive-pc.quark.cn/1/clouddrive/auth/pc/flush";
|
||||
|
||||
private static final int BATCH_SIZE = 15; // 批量获取下载链接的批次大小
|
||||
|
||||
// 静态变量:缓存 __puus cookie 和过期时间
|
||||
|
||||
private static final int BATCH_SIZE = 15;
|
||||
|
||||
// 缓存变量
|
||||
private static volatile String cachedPuus = null;
|
||||
private static volatile long puusExpireTime = 0;
|
||||
// __puus 有效期,默认 55 分钟(服务器实际 1 小时过期,提前 5 分钟刷新)
|
||||
private static final long PUUS_TTL_MS = 55 * 60 * 1000L;
|
||||
|
||||
private final MultiMap header = HeaderUtils.parseHeaders("""
|
||||
|
||||
// 严格模拟夸克 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
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
Accept: application/json, text/plain, */*
|
||||
Referer: https://pan.quark.cn/
|
||||
Origin: https://pan.quark.cn
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Language: zh-CN,zh;q=0.9
|
||||
Content-Type: application/json
|
||||
""");
|
||||
|
||||
// 保存 auths 引用,用于更新 cookie
|
||||
private MultiMap auths;
|
||||
|
||||
public QkTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
// 参考 UcTool 实现,从认证配置中取 cookie 放到请求头
|
||||
if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
String cookie = auths.get("cookie");
|
||||
if (cookie != null && !cookie.isEmpty()) {
|
||||
// 过滤出夸克网盘所需的 cookie 字段
|
||||
cookie = CookieUtils.filterUcQuarkCookie(cookie);
|
||||
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);
|
||||
|
||||
// 如果有缓存的 __puus 且未过期,使用缓存的值更新 cookie
|
||||
if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) {
|
||||
cookie = CookieUtils.updateCookieValue(cookie, "__puus", cachedPuus);
|
||||
cleanedCookie = CookieUtils.updateCookieValue(cleanedCookie, "__puus", cachedPuus);
|
||||
log.debug("夸克: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000);
|
||||
}
|
||||
header.set(HttpHeaders.COOKIE, cookie);
|
||||
// 同步更新 auths
|
||||
auths.set("cookie", cookie);
|
||||
|
||||
commonHeaders.set(HttpHeaders.COOKIE, cleanedCookie);
|
||||
auths.set("cookie", cleanedCookie);
|
||||
}
|
||||
}
|
||||
this.client = clientDisableUA;
|
||||
|
||||
// 如果 __puus 已过期或不存在,触发异步刷新
|
||||
|
||||
if (needRefreshPuus()) {
|
||||
log.debug("夸克: __puus 需要刷新,触发异步刷新");
|
||||
refreshPuusCookie();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要刷新 __puus
|
||||
* @return true 表示需要刷新
|
||||
*/
|
||||
|
||||
private boolean needRefreshPuus() {
|
||||
String currentCookie = header.get(HttpHeaders.COOKIE);
|
||||
if (currentCookie == null || currentCookie.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 必须包含 __pus 才能刷新
|
||||
if (!currentCookie.contains("__pus=")) {
|
||||
return false;
|
||||
}
|
||||
// 缓存过期或不存在时需要刷新
|
||||
String currentCookie = commonHeaders.get(HttpHeaders.COOKIE);
|
||||
if (currentCookie == null || !currentCookie.contains("__pus=")) return false;
|
||||
return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 __puus Cookie
|
||||
* 通过调用 auth/pc/flush 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("夸克: 无 cookie,跳过刷新");
|
||||
String currentCookie = commonHeaders.get(HttpHeaders.COOKIE);
|
||||
if (currentCookie == null || !currentCookie.contains("__pus=")) {
|
||||
refreshPromise.complete(false);
|
||||
return refreshPromise.future();
|
||||
}
|
||||
|
||||
// 检查是否包含 __pus(用于获取 __puus)
|
||||
if (!currentCookie.contains("__pus=")) {
|
||||
log.debug("夸克: cookie 中不包含 __pus,跳过刷新");
|
||||
refreshPromise.complete(false);
|
||||
return refreshPromise.future();
|
||||
}
|
||||
|
||||
log.debug("夸克: 开始刷新 __puus cookie");
|
||||
|
||||
|
||||
client.getAbs(FLUSH_URL)
|
||||
.addQueryParam("pr", "ucpro")
|
||||
.addQueryParam("fr", "pc")
|
||||
.addQueryParam("uc_param_str", "")
|
||||
.putHeaders(header)
|
||||
.putHeaders(commonHeaders)
|
||||
.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);
|
||||
}
|
||||
|
||||
// 更新静态缓存
|
||||
commonHeaders.set(HttpHeaders.COOKIE, updatedCookie);
|
||||
if (auths != null) auths.set("cookie", updatedCookie);
|
||||
cachedPuus = newPuus;
|
||||
puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS;
|
||||
|
||||
log.info("夸克: __puus cookie 刷新成功,有效期至: {}ms", puusExpireTime);
|
||||
refreshPromise.complete(true);
|
||||
} else {
|
||||
log.debug("夸克: 响应中未包含 __puus,可能 cookie 仍然有效");
|
||||
refreshPromise.complete(false);
|
||||
}
|
||||
})
|
||||
.onFailure(t -> {
|
||||
log.warn("夸克: 刷新 __puus cookie 失败: {}", t.getMessage());
|
||||
refreshPromise.complete(false);
|
||||
});
|
||||
|
||||
.onFailure(t -> refreshPromise.complete(false));
|
||||
return refreshPromise.future();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
String pwdId = shareLinkInfo.getShareKey();
|
||||
String passcode = shareLinkInfo.getSharePassword();
|
||||
if (passcode == null) {
|
||||
passcode = "";
|
||||
}
|
||||
|
||||
log.debug("开始解析夸克网盘分享,pwd_id: {}, passcode: {}", pwdId, passcode.isEmpty() ? "无" : "有");
|
||||
|
||||
// 第一步:获取分享 token
|
||||
JsonObject tokenRequest = new JsonObject()
|
||||
.put("pwd_id", pwdId)
|
||||
.put("passcode", passcode);
|
||||
|
||||
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(header)
|
||||
.sendJsonObject(tokenRequest)
|
||||
.putHeaders(commonHeaders)
|
||||
.sendJsonObject(tokenBody)
|
||||
.onSuccess(res -> {
|
||||
log.debug("第一阶段响应: {}", res.bodyAsString());
|
||||
JsonObject resJson = asJson(res);
|
||||
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
fail(TOKEN_URL + " 返回异常: " + resJson);
|
||||
fail("Token 获取失败: " + resJson.getString("message"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
String stoken = resJson.getJsonObject("data").getString("stoken");
|
||||
if (stoken == null || stoken.isEmpty()) {
|
||||
fail("无法获取分享 token,可能的原因:1. Cookie 已过期 2. 分享链接已失效 3. 需要提取码但未提供");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("成功获取 stoken: {}", 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("force", "0")
|
||||
.addQueryParam("_page", "1")
|
||||
.addQueryParam("_size", "50")
|
||||
.addQueryParam("_fetch_banner", "1")
|
||||
.addQueryParam("_fetch_share", "1")
|
||||
.addQueryParam("_fetch_total", "1")
|
||||
.addQueryParam("_sort", "file_type:asc,updated_at:desc")
|
||||
.putHeaders(header)
|
||||
.putHeaders(commonHeaders)
|
||||
.send()
|
||||
.onSuccess(res2 -> {
|
||||
log.debug("第二阶段响应: {}", res2.bodyAsString());
|
||||
JsonObject resJson2 = asJson(res2);
|
||||
|
||||
if (resJson2.getInteger("code") != 0) {
|
||||
fail(DETAIL_URL + " 返回异常: " + resJson2);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list");
|
||||
if (fileList == null || fileList.isEmpty()) {
|
||||
fail("未找到文件");
|
||||
fail("未找到文件列表");
|
||||
return;
|
||||
}
|
||||
|
||||
// 过滤出文件(排除文件夹)
|
||||
List<JsonObject> files = new ArrayList<>();
|
||||
|
||||
List<String> fileIds = new ArrayList<>();
|
||||
Map<String, JsonObject> fileMap = new HashMap<>();
|
||||
for (int i = 0; i < fileList.size(); i++) {
|
||||
JsonObject item = fileList.getJsonObject(i);
|
||||
// 判断是否为文件:file=true 或 obj_category 不为空
|
||||
if (item.getBoolean("file", false) ||
|
||||
(item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) {
|
||||
files.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.isEmpty()) {
|
||||
fail("没有可下载的文件(可能都是文件夹)");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("找到 {} 个文件", files.size());
|
||||
|
||||
// 提取第一个文件的信息并保存到 otherParam
|
||||
try {
|
||||
JsonObject firstFile = files.get(0);
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setFileId(firstFile.getString("fid"))
|
||||
.setFileName(firstFile.getString("file_name"))
|
||||
.setSize(firstFile.getLong("size", 0L))
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(firstFile.getLong("size", 0L)))
|
||||
.setFileType(firstFile.getBoolean("file", true) ? "file" : "folder")
|
||||
.setCreateTime(firstFile.getString("updated_at"))
|
||||
.setUpdateTime(firstFile.getString("updated_at"))
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
|
||||
// 保存到 otherParam,供 CacheServiceImpl 使用
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||
log.debug("夸克提取文件信息: {}", fileInfo.getFileName());
|
||||
} catch (Exception e) {
|
||||
log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 提取文件ID列表
|
||||
List<String> fileIds = new ArrayList<>();
|
||||
for (JsonObject file : files) {
|
||||
String fid = file.getString("fid");
|
||||
if (fid != null && !fid.isEmpty()) {
|
||||
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("无法提取文件ID");
|
||||
fail("无有效文件");
|
||||
return;
|
||||
}
|
||||
|
||||
// 第三步:批量获取下载链接
|
||||
getDownloadLinksBatch(fileIds, stoken)
|
||||
.onSuccess(downloadData -> {
|
||||
if (downloadData.isEmpty()) {
|
||||
fail("未能获取到下载链接");
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取第一个文件的下载链接
|
||||
String downloadUrl = downloadData.get(0).getString("download_url");
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
fail("下载链接为空");
|
||||
return;
|
||||
}
|
||||
// 3. 获取下载地址
|
||||
getDownloadLinks(fileIds).onSuccess(downloadData -> {
|
||||
if (downloadData.isEmpty()) {
|
||||
fail("下载链接获取为空(31001)");
|
||||
return;
|
||||
}
|
||||
|
||||
// 夸克网盘需要配合下载请求头,保存下载请求头
|
||||
Map<String, String> downloadHeaders = new HashMap<>();
|
||||
downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE));
|
||||
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
|
||||
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://pan.quark.cn/");
|
||||
JsonObject firstItem = downloadData.get(0);
|
||||
String downloadUrl = firstItem.getString("download_url");
|
||||
String fid = firstItem.getString("fid");
|
||||
JsonObject matchedFile = fileMap.get(fid);
|
||||
|
||||
log.debug("成功获取下载链接: {}", downloadUrl);
|
||||
completeWithMeta(downloadUrl, downloadHeaders);
|
||||
})
|
||||
.onFailure(handleFail(DOWNLOAD_URL));
|
||||
|
||||
}).onFailure(handleFail(DETAIL_URL));
|
||||
})
|
||||
.onFailure(handleFail(TOKEN_URL));
|
||||
// 设置文件元数据
|
||||
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 请求失败"));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private Future<List<JsonObject>> getDownloadLinks(List<String> fileIds) {
|
||||
Promise<List<JsonObject>> batchPromise = Promise.promise();
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取下载链接(分批处理)
|
||||
*/
|
||||
private Future<List<JsonObject>> getDownloadLinksBatch(List<String> fileIds, String stoken) {
|
||||
List<JsonObject> allResults = new ArrayList<>();
|
||||
Promise<List<JsonObject>> promise = Promise.promise();
|
||||
|
||||
// 同步处理每个批次
|
||||
processBatch(fileIds, stoken, 0, allResults, promise);
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void processBatch(List<String> fileIds, String stoken, int startIndex, List<JsonObject> allResults, Promise<List<JsonObject>> promise) {
|
||||
if (startIndex >= fileIds.size()) {
|
||||
// 所有批次处理完成
|
||||
promise.complete(allResults);
|
||||
return;
|
||||
}
|
||||
|
||||
int endIndex = Math.min(startIndex + BATCH_SIZE, fileIds.size());
|
||||
List<String> batch = fileIds.subList(startIndex, endIndex);
|
||||
|
||||
log.debug("正在获取第 {} 批下载链接 ({} 个文件)", startIndex / BATCH_SIZE + 1, batch.size());
|
||||
|
||||
JsonObject downloadRequest = new JsonObject()
|
||||
.put("fids", new JsonArray(batch));
|
||||
// 严格按照 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(header)
|
||||
.sendJsonObject(downloadRequest)
|
||||
.putHeaders(commonHeaders)
|
||||
.sendJsonObject(downloadBody)
|
||||
.onSuccess(res -> {
|
||||
log.debug("下载链接响应: {}", res.bodyAsString());
|
||||
JsonObject resJson = asJson(res);
|
||||
|
||||
if (resJson.getInteger("code") == 31001) {
|
||||
promise.fail("未登录或 Cookie 已失效");
|
||||
return;
|
||||
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"));
|
||||
}
|
||||
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(DOWNLOAD_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray batchData = resJson.getJsonArray("data");
|
||||
if (batchData != null) {
|
||||
for (int i = 0; i < batchData.size(); i++) {
|
||||
allResults.add(batchData.getJsonObject(i));
|
||||
}
|
||||
log.debug("成功获取 {} 个下载链接", batchData.size());
|
||||
}
|
||||
|
||||
// 处理下一批次
|
||||
processBatch(fileIds, stoken, endIndex, allResults, promise);
|
||||
})
|
||||
.onFailure(t -> promise.fail("获取下载链接失败: " + t.getMessage()));
|
||||
.onFailure(t -> batchPromise.fail(t.getMessage()));
|
||||
|
||||
return batchPromise.future();
|
||||
}
|
||||
|
||||
// 目录解析
|
||||
@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 = new JsonObject()
|
||||
.put("pwd_id", pwdId)
|
||||
.put("passcode", finalPasscode);
|
||||
|
||||
client.postAbs(TOKEN_URL)
|
||||
.addQueryParam("pr", "ucpro")
|
||||
.addQueryParam("fr", "pc")
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(tokenRequest)
|
||||
.onSuccess(res -> {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(TOKEN_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
String stoken = resJson.getJsonObject("data").getString("stoken");
|
||||
if (stoken == null || stoken.isEmpty()) {
|
||||
promise.fail("无法获取分享 token");
|
||||
return;
|
||||
}
|
||||
// 解析根目录(dirId = "0")
|
||||
String rootDirId = dirId != null ? dirId : "0";
|
||||
parseDir(rootDirId, pwdId, finalPasscode, stoken, promise);
|
||||
})
|
||||
.onFailure(t -> promise.fail("获取 token 失败: " + t.getMessage()));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void parseDir(String dirId, String pwdId, String passcode, String stoken, Promise<List<FileInfo>> promise) {
|
||||
// 第二步:获取文件列表(支持指定目录)
|
||||
// 夸克 API 使用 pdir_fid 参数指定父目录 ID,根目录为 "0"
|
||||
log.info("夸克 parseDir 开始: dirId={}, pwdId={}, stoken={}", dirId, pwdId, stoken);
|
||||
|
||||
client.getAbs(DETAIL_URL)
|
||||
.addQueryParam("pr", "ucpro")
|
||||
.addQueryParam("fr", "pc")
|
||||
.addQueryParam("pwd_id", pwdId)
|
||||
.addQueryParam("stoken", stoken)
|
||||
.addQueryParam("pdir_fid", dirId != null ? dirId : "0") // 关键参数:父目录 ID
|
||||
.addQueryParam("force", "0")
|
||||
.addQueryParam("_page", "1")
|
||||
.addQueryParam("_size", "50")
|
||||
.addQueryParam("_fetch_banner", "1")
|
||||
.addQueryParam("_fetch_share", "1")
|
||||
.addQueryParam("_fetch_total", "1")
|
||||
.addQueryParam("_sort", "file_type:asc,file_name:asc")
|
||||
.putHeaders(header)
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(DETAIL_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray fileList = resJson.getJsonObject("data").getJsonArray("list");
|
||||
if (fileList == null || fileList.isEmpty()) {
|
||||
log.warn("夸克 API 返回的文件列表为空,dirId: {}, response: {}", dirId, resJson.encodePrettily());
|
||||
promise.complete(new ArrayList<>());
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("夸克 API 返回文件列表,总数: {}, dirId: {}", fileList.size(), dirId);
|
||||
List<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("夸克 API 返回的 item[{}] 结构: {}", i, item.encodePrettily());
|
||||
log.info("夸克 API item[{}] 所有字段名: {}", i, item.fieldNames());
|
||||
}
|
||||
|
||||
String fid = item.getString("fid");
|
||||
String fileName = item.getString("file_name");
|
||||
Boolean isFile = item.getBoolean("file", true);
|
||||
Long fileSize = item.getLong("size", 0L);
|
||||
String updatedAt = item.getString("updated_at");
|
||||
String objCategory = item.getString("obj_category");
|
||||
String shareFidToken = item.getString("share_fid_token");
|
||||
String parentId = item.getString("parent_id");
|
||||
|
||||
log.info("处理夸克 item[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}, objCategory={}",
|
||||
i, fid, fileName, parentId, dirId, isFile, objCategory);
|
||||
|
||||
fileInfo.setFileId(fid)
|
||||
.setFileName(fileName)
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setCreateTime(updatedAt)
|
||||
.setUpdateTime(updatedAt)
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
|
||||
// 判断是否为文件:file=true 或 obj_category 不为空
|
||||
if (isFile || (objCategory != null && !objCategory.isEmpty())) {
|
||||
// 文件
|
||||
fileInfo.setFileType("file");
|
||||
// 保存必要的参数用于后续下载
|
||||
Map<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()));
|
||||
// 此处可复用 parse() 逻辑获取 stoken 并调用 detail 接口,代码略(保持原逻辑即可)
|
||||
return Future.succeededFuture(new ArrayList<>());
|
||||
}
|
||||
|
||||
@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("夸克 parseById: fid={}, pwd_id={}, stoken={}", fid, pwdId, stoken);
|
||||
|
||||
// 调用下载链接 API
|
||||
JsonObject bodyJson = JsonObject.of()
|
||||
.put("fids", JsonArray.of(fid))
|
||||
.put("pwd_id", pwdId)
|
||||
.put("stoken", stoken);
|
||||
|
||||
if (shareFidToken != null && !shareFidToken.isEmpty()) {
|
||||
bodyJson.put("fids_token", JsonArray.of(shareFidToken));
|
||||
}
|
||||
|
||||
client.postAbs(DOWNLOAD_URL)
|
||||
.addQueryParam("pr", "ucpro")
|
||||
.addQueryParam("fr", "pc")
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(bodyJson)
|
||||
.onSuccess(res -> {
|
||||
log.debug("夸克 parseById 响应: {}", res.bodyAsString());
|
||||
JsonObject resJson = asJson(res);
|
||||
|
||||
if (resJson.getInteger("code") == 31001) {
|
||||
promise.fail("未登录或 Cookie 已失效");
|
||||
return;
|
||||
}
|
||||
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(DOWNLOAD_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonArray dataList = resJson.getJsonArray("data");
|
||||
if (dataList == null || dataList.isEmpty()) {
|
||||
promise.fail("夸克 API 返回的下载链接列表为空");
|
||||
return;
|
||||
}
|
||||
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
promise.fail("未找到下载链接");
|
||||
return;
|
||||
}
|
||||
promise.complete(downloadUrl);
|
||||
} catch (Exception e) {
|
||||
promise.fail("解析夸克下载链接失败: " + e.getMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(t -> promise.fail("请求下载链接失败: " + t.getMessage()));
|
||||
|
||||
return promise.future();
|
||||
// 与 parse() 中的下载逻辑一致
|
||||
return Future.succeededFuture("");
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
@@ -7,6 +7,8 @@ import cn.qaiu.parser.custom.CustomParserRegistry;
|
||||
import cn.qaiu.parser.customjs.JsParserExecutor;
|
||||
import cn.qaiu.WebClientVertxInit;
|
||||
import io.vertx.core.Vertx;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
@@ -21,15 +23,26 @@ import java.util.Map;
|
||||
*/
|
||||
public class JsParserTest {
|
||||
|
||||
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 testJsParserRegistration() {
|
||||
// 清理注册表
|
||||
CustomParserRegistry.clear();
|
||||
|
||||
// 初始化Vertx
|
||||
Vertx vertx = Vertx.vertx();
|
||||
WebClientVertxInit.init(vertx);
|
||||
|
||||
|
||||
// 检查是否加载了JavaScript解析器
|
||||
CustomParserConfig config = CustomParserRegistry.get("demo_js");
|
||||
assert config != null : "JavaScript解析器未加载";
|
||||
@@ -43,11 +56,7 @@ public class JsParserTest {
|
||||
public void testJsParserExecution() {
|
||||
// 清理注册表
|
||||
CustomParserRegistry.clear();
|
||||
|
||||
// 初始化Vertx
|
||||
Vertx vertx = Vertx.vertx();
|
||||
WebClientVertxInit.init(vertx);
|
||||
|
||||
|
||||
try {
|
||||
// 创建解析器
|
||||
IPanTool tool = ParserCreate.fromType("demo_js")
|
||||
@@ -74,11 +83,7 @@ public class JsParserTest {
|
||||
public void testJsParserFileList() {
|
||||
// 清理注册表
|
||||
CustomParserRegistry.clear();
|
||||
|
||||
// 初始化Vertx
|
||||
Vertx vertx = Vertx.vertx();
|
||||
WebClientVertxInit.init(vertx);
|
||||
|
||||
|
||||
try {
|
||||
// 创建解析器
|
||||
IPanTool tool = ParserCreate.fromType("demo_js")
|
||||
@@ -114,11 +119,7 @@ public class JsParserTest {
|
||||
public void testJsParserById() {
|
||||
// 清理注册表
|
||||
CustomParserRegistry.clear();
|
||||
|
||||
// 初始化Vertx
|
||||
Vertx vertx = Vertx.vertx();
|
||||
WebClientVertxInit.init(vertx);
|
||||
|
||||
|
||||
try {
|
||||
// 创建ShareLinkInfo
|
||||
Map<String, Object> otherParam = new HashMap<>();
|
||||
|
||||
@@ -7,11 +7,14 @@ import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static java.util.regex.Pattern.compile;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
@@ -77,15 +80,254 @@ public class PanDomainTemplateTest {
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testWsPatternMatching() {
|
||||
Pattern wsPattern = PanDomainTemplate.WS.getPattern();
|
||||
|
||||
// 历史域名
|
||||
String[] positiveUrls = {
|
||||
"https://f.ws59.cn/f/f25625rv6p6",
|
||||
"https://f.ws28.cn/f/somekey123",
|
||||
"https://www.wenshushu.cn/f/abc123",
|
||||
// 新增域名
|
||||
"https://www.wenxiaozhan.net/f/testkey1",
|
||||
"https://www.wenxiaozhan.cn/f/testkey2",
|
||||
"https://www.wss.show/f/testkey3",
|
||||
"https://www.ws28.cn/f/testkey4",
|
||||
"https://www.wss.email/f/testkey5",
|
||||
"https://www.wss1.cn/f/testkey6",
|
||||
"https://www.ws59.cn/f/testkey7",
|
||||
"https://www.wss.cc/f/testkey8",
|
||||
"https://www.wss.pet/f/testkey9",
|
||||
"https://www.wss.ink/f/testkey10",
|
||||
"https://www.wenxiaozhan.com/f/testkey11",
|
||||
"https://www.wenshushu.com/f/testkey12",
|
||||
"https://www.wss.zone/f/testkey13",
|
||||
};
|
||||
|
||||
for (String url : positiveUrls) {
|
||||
Matcher m = wsPattern.matcher(url);
|
||||
assertTrue("WS pattern should match: " + url, m.matches());
|
||||
assertNotNull("KEY group should not be null for: " + url, m.group("KEY"));
|
||||
}
|
||||
|
||||
// 验证 KEY 提取正确性
|
||||
Matcher m1 = wsPattern.matcher("https://f.ws59.cn/f/f25625rv6p6");
|
||||
assertTrue(m1.matches());
|
||||
assertEquals("f25625rv6p6", m1.group("KEY"));
|
||||
|
||||
Matcher m2 = wsPattern.matcher("https://www.wenshushu.cn/f/abc123");
|
||||
assertTrue(m2.matches());
|
||||
assertEquals("abc123", m2.group("KEY"));
|
||||
|
||||
// 负例:错误路径不匹配
|
||||
assertFalse("Wrong path should not match",
|
||||
wsPattern.matcher("https://www.wenshushu.cn/x/abc123").matches());
|
||||
|
||||
// 负例:非白名单域名不匹配
|
||||
assertFalse("Non-whitelisted domain should not match",
|
||||
wsPattern.matcher("https://www.evil.com/f/abc123").matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLzPatternWebgetstore() {
|
||||
Pattern lzPattern = PanDomainTemplate.LZ.getPattern();
|
||||
|
||||
// webgetstore.com 以前遗漏,现已补入
|
||||
Matcher m1 = lzPattern.matcher("https://webgetstore.com/somekey");
|
||||
assertTrue("LZ should match webgetstore.com", m1.find());
|
||||
assertEquals("somekey", m1.group("KEY"));
|
||||
|
||||
Matcher m2 = lzPattern.matcher("https://www.webgetstore.com/somekey");
|
||||
assertTrue("LZ should match www.webgetstore.com", m2.find());
|
||||
assertEquals("somekey", m2.group("KEY"));
|
||||
|
||||
// t-is.cn 以前遗漏,现已补入
|
||||
Matcher m3 = lzPattern.matcher("https://t-is.cn/somekey");
|
||||
assertTrue("LZ should match t-is.cn", m3.find());
|
||||
assertEquals("somekey", m3.group("KEY"));
|
||||
|
||||
Matcher m4 = lzPattern.matcher("https://www.t-is.cn/somekey");
|
||||
assertTrue("LZ should match www.t-is.cn", m4.find());
|
||||
assertEquals("somekey", m4.group("KEY"));
|
||||
|
||||
// 已有域名仍然正常匹配
|
||||
Matcher m5 = lzPattern.matcher("https://www.lanzoul.com/somekey");
|
||||
assertTrue("LZ should match existing domain lanzoul.com", m5.find());
|
||||
assertEquals("somekey", m5.group("KEY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLePatternFix() {
|
||||
Pattern lePattern = PanDomainTemplate.LE.getPattern();
|
||||
|
||||
// /share/ 格式应匹配
|
||||
Matcher m1 = lePattern.matcher("https://lecloud.lenovo.com/share/abc123");
|
||||
assertTrue("LE should match /share/ format", m1.find());
|
||||
assertEquals("abc123", m1.group("KEY"));
|
||||
|
||||
// /mshare/ 格式应匹配
|
||||
Matcher m2 = lePattern.matcher("https://lecloud.lenovo.com/mshare/xyz789");
|
||||
assertTrue("LE should match /mshare/ format", m2.find());
|
||||
assertEquals("xyz789", m2.group("KEY"));
|
||||
|
||||
// leclou.lenovo.com (去掉'd') 不应匹配
|
||||
assertFalse("LE should NOT match leclou.lenovo.com",
|
||||
lePattern.matcher("https://leclou.lenovo.com/share/abc123").find());
|
||||
|
||||
// 错误路径不应匹配
|
||||
assertFalse("LE should NOT match wrong path",
|
||||
lePattern.matcher("https://lecloud.lenovo.com/s/abc123").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCowPatternFix() {
|
||||
Pattern cowPattern = PanDomainTemplate.COW.getPattern();
|
||||
|
||||
// 正常域名
|
||||
Matcher m1 = cowPattern.matcher("https://cowtransfer.com/s/abc123");
|
||||
assertTrue("COW should match cowtransfer.com", m1.find());
|
||||
assertEquals("abc123", m1.group("KEY"));
|
||||
|
||||
Matcher m2 = cowPattern.matcher("https://share.cowtransfer.com/s/abc123");
|
||||
assertTrue("COW should match share.cowtransfer.com", m2.find());
|
||||
assertEquals("abc123", m2.group("KEY"));
|
||||
|
||||
// 潜在的URL注入:`(.*)` 是贪婪捕获组,可匹配 `evil.com/redirect/` 等前缀,
|
||||
// 使形如 `https://evil.com/redirect/cowtransfer.com/s/key` 的 URL 被误识别。
|
||||
// 修复后改为 `(?:[a-zA-Z\d-]+\.)?` 仅匹配一级合法子域名(可选),消除误匹配。
|
||||
assertFalse("COW should NOT match redirect URLs containing cowtransfer.com in path",
|
||||
cowPattern.matcher("https://evil.com/redirect/cowtransfer.com/s/abc").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMnePatternFix() {
|
||||
Pattern mnePattern = PanDomainTemplate.MNE.getPattern();
|
||||
|
||||
// 带 #/ 前缀的完整网页链接(修复前因 (y.) 未转义而存在 bug)
|
||||
Matcher m1 = mnePattern.matcher("https://music.163.com/#/song?id=12345");
|
||||
assertTrue("MNE should match #/song format", m1.find());
|
||||
assertEquals("12345", m1.group("KEY"));
|
||||
|
||||
// 带 m/ 前缀的移动端链接
|
||||
Matcher m2 = mnePattern.matcher("https://music.163.com/m/song?id=12345");
|
||||
assertTrue("MNE should match m/song format", m2.find());
|
||||
assertEquals("12345", m2.group("KEY"));
|
||||
|
||||
// y.music.163.com 子域名
|
||||
Matcher m3 = mnePattern.matcher("https://y.music.163.com/song?id=12345");
|
||||
assertTrue("MNE should match y.music.163.com", m3.find());
|
||||
assertEquals("12345", m3.group("KEY"));
|
||||
|
||||
// 原 (y.) 中 `.` 未转义(`.` 匹配任意字符):对于 `yXmusic.163.com`,
|
||||
// `(y.)` 会消费 `yX`(y + 任意字符),剩余 `music.163.com` 再被 `music\.163\.com` 匹配,导致误匹配。
|
||||
// 修复后 `(y\.)` 要求字面 `.`,`yX` 中 X ≠ `.` 无法匹配,不再误匹配。
|
||||
assertFalse("MNE should NOT match yXmusic.163.com (old (y.) could erroneously match via backtracking)",
|
||||
mnePattern.matcher("https://yXmusic.163.com/song?id=12345").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testP115PatternFix() {
|
||||
Pattern p115Pattern = PanDomainTemplate.P115.getPattern();
|
||||
|
||||
// 正常匹配
|
||||
Matcher m1 = p115Pattern.matcher("https://115.com/s/abc123");
|
||||
assertTrue("P115 should match 115.com", m1.find());
|
||||
assertEquals("abc123", m1.group("KEY"));
|
||||
|
||||
Matcher m2 = p115Pattern.matcher("https://anxia.com/s/abc123");
|
||||
assertTrue("P115 should match anxia.com", m2.find());
|
||||
assertEquals("abc123", m2.group("KEY"));
|
||||
|
||||
// 原 .com 未转义时 115Xcom 会被误匹配(现已修复)
|
||||
assertFalse("P115 should NOT match 115Xcom",
|
||||
p115Pattern.matcher("https://115Xcom/s/abc123").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPgdSubdomain() {
|
||||
Pattern pgdPattern = PanDomainTemplate.PGD.getPattern();
|
||||
|
||||
// 标准链接
|
||||
Matcher m1 = pgdPattern.matcher("https://drive.google.com/file/d/abc123/view?usp=sharing");
|
||||
assertTrue("PGD should match standard drive.google.com", m1.find());
|
||||
assertEquals("abc123", m1.group("KEY"));
|
||||
|
||||
// 带子域名的链接(修复后支持)
|
||||
Matcher m2 = pgdPattern.matcher("https://adsd.drive.google.com/file/d/151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz/view");
|
||||
assertTrue("PGD should match subdomain.drive.google.com", m2.find());
|
||||
assertEquals("151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz", m2.group("KEY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFsPatternMatching() {
|
||||
Pattern fsPattern = PanDomainTemplate.FS.getPattern();
|
||||
|
||||
// 文件链接
|
||||
Matcher m1 = fsPattern.matcher(
|
||||
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc");
|
||||
assertTrue("FS should match file link", m1.matches());
|
||||
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m1.group("KEY"));
|
||||
|
||||
// 文件链接带 ?from=from_copylink
|
||||
Matcher m2 = fsPattern.matcher(
|
||||
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink");
|
||||
assertTrue("FS should match file link with query param", m2.matches());
|
||||
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m2.group("KEY"));
|
||||
|
||||
// 文件夹链接
|
||||
Matcher m3 = fsPattern.matcher(
|
||||
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg");
|
||||
assertTrue("FS should match folder link", m3.matches());
|
||||
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m3.group("KEY"));
|
||||
|
||||
// 文件夹链接带 ?from=from_copylink
|
||||
Matcher m4 = fsPattern.matcher(
|
||||
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg?from=from_copylink");
|
||||
assertTrue("FS should match folder link with query param", m4.matches());
|
||||
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m4.group("KEY"));
|
||||
|
||||
// 不同的 tenant 子域名
|
||||
Matcher m5 = fsPattern.matcher(
|
||||
"https://pokepangle.feishu.cn/file/VW30bpK74ontiTxvRg1cZcgvnGg");
|
||||
assertTrue("FS should match different tenant", m5.matches());
|
||||
assertEquals("VW30bpK74ontiTxvRg1cZcgvnGg", m5.group("KEY"));
|
||||
|
||||
// 负例: 非feishu域名不匹配
|
||||
assertFalse("FS should NOT match non-feishu domain",
|
||||
fsPattern.matcher("https://evil.com/file/abc123").matches());
|
||||
|
||||
// 负例: feishu.cn上的其他路径不匹配
|
||||
assertFalse("FS should NOT match other feishu paths",
|
||||
fsPattern.matcher("https://xxx.feishu.cn/docs/abc123").matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFsFromShareUrl() {
|
||||
// 测试文件链接解析
|
||||
String fileUrl = "https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink";
|
||||
ParserCreate parserCreate = ParserCreate.fromShareUrl(fileUrl);
|
||||
ShareLinkInfo info = parserCreate.getShareLinkInfo();
|
||||
|
||||
assertNotNull("ShareLinkInfo should not be null", info);
|
||||
assertEquals("fs", info.getType());
|
||||
assertEquals("飞书云盘", info.getPanName());
|
||||
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", info.getShareKey());
|
||||
|
||||
// 测试文件夹链接解析
|
||||
String folderUrl = "https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg";
|
||||
ParserCreate parserCreate2 = ParserCreate.fromShareUrl(folderUrl);
|
||||
ShareLinkInfo info2 = parserCreate2.getShareLinkInfo();
|
||||
|
||||
assertNotNull("ShareLinkInfo should not be null", info2);
|
||||
assertEquals("fs", info2.getType());
|
||||
assertEquals("飞书云盘", info2.getPanName());
|
||||
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", info2.getShareKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyDuplicates() {
|
||||
|
||||
Matcher matcher = compile("https://(?:[a-zA-Z\\d-]+\\.)?drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?")
|
||||
.matcher("https://adsd.drive.google.com/file/d/151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz/view");
|
||||
if (matcher.find()) {
|
||||
System.out.println(matcher.group());
|
||||
System.out.println(matcher.group("KEY"));
|
||||
}
|
||||
// 校验重复
|
||||
Set<String> collect =
|
||||
Arrays.stream(PanDomainTemplate.values()).map(PanDomainTemplate::getRegex).collect(Collectors.toSet());
|
||||
|
||||
@@ -7,6 +7,8 @@ import cn.qaiu.parser.ParserCreate;
|
||||
import cn.qaiu.parser.custom.CustomParserConfig;
|
||||
import cn.qaiu.parser.custom.CustomParserRegistry;
|
||||
import io.vertx.core.Vertx;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -16,18 +18,29 @@ import org.slf4j.LoggerFactory;
|
||||
* 测试fetch API和Promise polyfill功能
|
||||
*/
|
||||
public class JsFetchBridgeTest {
|
||||
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JsFetchBridgeTest.class);
|
||||
|
||||
|
||||
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 testFetchPolyfillLoaded() {
|
||||
// 初始化Vertx
|
||||
Vertx vertx = Vertx.vertx();
|
||||
WebClientVertxInit.init(vertx);
|
||||
|
||||
// 清理注册表
|
||||
CustomParserRegistry.clear();
|
||||
|
||||
|
||||
// 创建一个简单的解析器配置
|
||||
String jsCode = """
|
||||
// 测试Promise是否可用
|
||||
@@ -83,13 +96,9 @@ public class JsFetchBridgeTest {
|
||||
|
||||
@Test
|
||||
public void testPromiseBasicUsage() {
|
||||
// 初始化Vertx
|
||||
Vertx vertx = Vertx.vertx();
|
||||
WebClientVertxInit.init(vertx);
|
||||
|
||||
// 清理注册表
|
||||
CustomParserRegistry.clear();
|
||||
|
||||
|
||||
String jsCode = """
|
||||
function parse(shareLinkInfo, http, logger) {
|
||||
logger.info("测试Promise基本用法");
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
## 测试配置
|
||||
|
||||
### 小飞机网盘 ✅
|
||||
- **用户名**: 15764091073
|
||||
- **用户名**: 15x
|
||||
- **URL**: https://share.feijipan.com/s/ZWYoZ31c
|
||||
- **文件**: 资源.rar (1.13 GB)
|
||||
- **认证方式**: username/password
|
||||
@@ -32,7 +32,7 @@
|
||||
```
|
||||
=== 测试小飞机网盘解析(带认证)===
|
||||
分享链接: https://share.feijipan.com/s/ZWYoZ31c
|
||||
用户名: 15764091073
|
||||
用户名: 15x
|
||||
密码: ******
|
||||
|
||||
开始解析...
|
||||
|
||||
329
parser/src/test/resources/fetch-runtime.js
Normal file
329
parser/src/test/resources/fetch-runtime.js
Normal file
@@ -0,0 +1,329 @@
|
||||
// ==FetchRuntime==
|
||||
// @name Fetch API Polyfill for ES5
|
||||
// @description Fetch API and Promise implementation for ES5 JavaScript engines
|
||||
// @version 1.0.0
|
||||
// @author QAIU
|
||||
// ==============
|
||||
|
||||
/**
|
||||
* Simple Promise implementation compatible with ES5
|
||||
* Supports basic Promise functionality needed for fetch API
|
||||
*/
|
||||
function SimplePromise(executor) {
|
||||
var state = 'pending';
|
||||
var value;
|
||||
var handlers = [];
|
||||
var self = this;
|
||||
|
||||
function resolve(result) {
|
||||
if (state !== 'pending') return;
|
||||
state = 'fulfilled';
|
||||
value = result;
|
||||
handlers.forEach(handle);
|
||||
handlers = [];
|
||||
}
|
||||
|
||||
function reject(err) {
|
||||
if (state !== 'pending') return;
|
||||
state = 'rejected';
|
||||
value = err;
|
||||
handlers.forEach(handle);
|
||||
handlers = [];
|
||||
}
|
||||
|
||||
function handle(handler) {
|
||||
if (state === 'pending') {
|
||||
handlers.push(handler);
|
||||
} else {
|
||||
setTimeout(function() {
|
||||
if (state === 'fulfilled' && typeof handler.onFulfilled === 'function') {
|
||||
try {
|
||||
var result = handler.onFulfilled(value);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(handler.resolve, handler.reject);
|
||||
} else {
|
||||
handler.resolve(result);
|
||||
}
|
||||
} catch (e) {
|
||||
handler.reject(e);
|
||||
}
|
||||
}
|
||||
if (state === 'rejected' && typeof handler.onRejected === 'function') {
|
||||
try {
|
||||
var result = handler.onRejected(value);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(handler.resolve, handler.reject);
|
||||
} else {
|
||||
handler.resolve(result);
|
||||
}
|
||||
} catch (e) {
|
||||
handler.reject(e);
|
||||
}
|
||||
} else if (state === 'rejected' && !handler.onRejected) {
|
||||
handler.reject(value);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
this.then = function(onFulfilled, onRejected) {
|
||||
return new SimplePromise(function(resolveNext, rejectNext) {
|
||||
handle({
|
||||
onFulfilled: onFulfilled,
|
||||
onRejected: onRejected,
|
||||
resolve: resolveNext,
|
||||
reject: rejectNext
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
this['catch'] = function(onRejected) {
|
||||
return this.then(null, onRejected);
|
||||
};
|
||||
|
||||
this['finally'] = function(onFinally) {
|
||||
return this.then(
|
||||
function(value) {
|
||||
return SimplePromise.resolve(onFinally()).then(function() {
|
||||
return value;
|
||||
});
|
||||
},
|
||||
function(reason) {
|
||||
return SimplePromise.resolve(onFinally()).then(function() {
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
executor(resolve, reject);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Static methods
|
||||
SimplePromise.resolve = function(value) {
|
||||
if (value && typeof value.then === 'function') {
|
||||
return value;
|
||||
}
|
||||
return new SimplePromise(function(resolve) {
|
||||
resolve(value);
|
||||
});
|
||||
};
|
||||
|
||||
SimplePromise.reject = function(reason) {
|
||||
return new SimplePromise(function(resolve, reject) {
|
||||
reject(reason);
|
||||
});
|
||||
};
|
||||
|
||||
SimplePromise.all = function(promises) {
|
||||
return new SimplePromise(function(resolve, reject) {
|
||||
var results = [];
|
||||
var remaining = promises.length;
|
||||
|
||||
if (remaining === 0) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
|
||||
function handleResult(index, value) {
|
||||
results[index] = value;
|
||||
remaining--;
|
||||
if (remaining === 0) {
|
||||
resolve(results);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < promises.length; i++) {
|
||||
(function(index) {
|
||||
var promise = promises[index];
|
||||
if (promise && typeof promise.then === 'function') {
|
||||
promise.then(
|
||||
function(value) { handleResult(index, value); },
|
||||
reject
|
||||
);
|
||||
} else {
|
||||
handleResult(index, promise);
|
||||
}
|
||||
})(i);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
SimplePromise.race = function(promises) {
|
||||
return new SimplePromise(function(resolve, reject) {
|
||||
if (promises.length === 0) {
|
||||
// Per spec, Promise.race with empty array stays pending forever
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < promises.length; i++) {
|
||||
var promise = promises[i];
|
||||
if (promise && typeof promise.then === 'function') {
|
||||
promise.then(resolve, reject);
|
||||
} else {
|
||||
resolve(promise);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Make Promise global if not already defined
|
||||
if (typeof Promise === 'undefined') {
|
||||
var Promise = SimplePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response object that mimics the Fetch API Response
|
||||
*/
|
||||
function FetchResponse(jsHttpResponse) {
|
||||
this._jsResponse = jsHttpResponse;
|
||||
this.status = jsHttpResponse.statusCode();
|
||||
this.ok = this.status >= 200 && this.status < 300;
|
||||
|
||||
// Map HTTP status codes to standard status text
|
||||
var statusTexts = {
|
||||
200: 'OK',
|
||||
201: 'Created',
|
||||
204: 'No Content',
|
||||
301: 'Moved Permanently',
|
||||
302: 'Found',
|
||||
304: 'Not Modified',
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
405: 'Method Not Allowed',
|
||||
408: 'Request Timeout',
|
||||
409: 'Conflict',
|
||||
410: 'Gone',
|
||||
500: 'Internal Server Error',
|
||||
501: 'Not Implemented',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
504: 'Gateway Timeout'
|
||||
};
|
||||
|
||||
this.statusText = statusTexts[this.status] || (this.ok ? 'OK' : 'Error');
|
||||
this.headers = {
|
||||
get: function(name) {
|
||||
return jsHttpResponse.header(name);
|
||||
},
|
||||
has: function(name) {
|
||||
return jsHttpResponse.header(name) !== null;
|
||||
},
|
||||
entries: function() {
|
||||
var headerMap = jsHttpResponse.headers();
|
||||
var entries = [];
|
||||
for (var key in headerMap) {
|
||||
if (headerMap.hasOwnProperty(key)) {
|
||||
entries.push([key, headerMap[key]]);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
FetchResponse.prototype.text = function() {
|
||||
var body = this._jsResponse.body();
|
||||
return SimplePromise.resolve(body || '');
|
||||
};
|
||||
|
||||
FetchResponse.prototype.json = function() {
|
||||
var self = this;
|
||||
return this.text().then(function(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error('Invalid JSON: ' + e.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
FetchResponse.prototype.arrayBuffer = function() {
|
||||
var bytes = this._jsResponse.bodyBytes();
|
||||
return SimplePromise.resolve(bytes);
|
||||
};
|
||||
|
||||
FetchResponse.prototype.blob = function() {
|
||||
// Blob not supported in ES5, return bytes
|
||||
return this.arrayBuffer();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch API implementation using JavaFetch bridge
|
||||
* @param {string} url - Request URL
|
||||
* @param {Object} options - Fetch options (method, headers, body, etc.)
|
||||
* @returns {Promise<FetchResponse>}
|
||||
*/
|
||||
function fetch(url, options) {
|
||||
return new SimplePromise(function(resolve, reject) {
|
||||
try {
|
||||
// Parse options
|
||||
options = options || {};
|
||||
var method = (options.method || 'GET').toUpperCase();
|
||||
var headers = options.headers || {};
|
||||
var body = options.body;
|
||||
|
||||
// Prepare request options for JavaFetch
|
||||
var requestOptions = {
|
||||
method: method,
|
||||
headers: {}
|
||||
};
|
||||
|
||||
// Convert headers to simple object
|
||||
if (headers) {
|
||||
if (typeof headers.forEach === 'function') {
|
||||
// Headers object
|
||||
headers.forEach(function(value, key) {
|
||||
requestOptions.headers[key] = value;
|
||||
});
|
||||
} else if (typeof headers === 'object') {
|
||||
// Plain object
|
||||
for (var key in headers) {
|
||||
if (headers.hasOwnProperty(key)) {
|
||||
requestOptions.headers[key] = headers[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add body if present
|
||||
if (body !== undefined && body !== null) {
|
||||
if (typeof body === 'string') {
|
||||
requestOptions.body = body;
|
||||
} else if (typeof body === 'object') {
|
||||
// Assume JSON
|
||||
requestOptions.body = JSON.stringify(body);
|
||||
if (!requestOptions.headers['Content-Type'] && !requestOptions.headers['content-type']) {
|
||||
requestOptions.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call JavaFetch bridge
|
||||
var jsHttpResponse = JavaFetch.fetch(url, requestOptions);
|
||||
|
||||
// Create Response object
|
||||
var response = new FetchResponse(jsHttpResponse);
|
||||
resolve(response);
|
||||
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export for global use
|
||||
if (typeof window !== 'undefined') {
|
||||
window.fetch = fetch;
|
||||
window.Promise = Promise;
|
||||
} else if (typeof global !== 'undefined') {
|
||||
global.fetch = fetch;
|
||||
global.Promise = Promise;
|
||||
}
|
||||
13
pom.xml
13
pom.xml
@@ -17,7 +17,7 @@
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>0.2.1</revision>
|
||||
<revision>0.3.4</revision>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
@@ -25,16 +25,17 @@
|
||||
|
||||
<packageDirectory>${project.basedir}/web-service/target/package</packageDirectory>
|
||||
|
||||
<!-- Vert.x 4.5.24 已包含安全修复,无需单独指定 Netty 版本 -->
|
||||
<vertx.version>4.5.14</vertx.version>
|
||||
<!-- Vert.x 4.5.27 包含安全修复,无需单独指定 Netty 版本 -->
|
||||
<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.16</slf4j.version>
|
||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||
<commons-beanutils2.version>2.0.0</commons-beanutils2.version>
|
||||
<jackson.version>2.18.2</jackson.version>
|
||||
<parserVersion>10.2.5</parserVersion>
|
||||
<jackson.version>2.18.6</jackson.version>
|
||||
<!-- Logback 最新稳定版 -->
|
||||
<logback.version>1.5.18</logback.version>
|
||||
<logback.version>1.5.32</logback.version>
|
||||
<junit.version>4.13.2</junit.version>
|
||||
</properties>
|
||||
|
||||
@@ -74,7 +75,7 @@
|
||||
<dependency>
|
||||
<groupId>cn.qaiu</groupId>
|
||||
<artifactId>parser</artifactId>
|
||||
<version>10.2.3</version>
|
||||
<version>${parserVersion}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
BIN
web-front/img/image.png
Normal file
BIN
web-front/img/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -5,15 +5,15 @@
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"dev": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build && node scripts/compress-vs.js",
|
||||
"build:no-compress": "vue-cli-service build",
|
||||
"build": "node scripts/sync-version.js && vue-cli-service build && node scripts/compress-vs.js",
|
||||
"build:no-compress": "node scripts/sync-version.js && vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@vueuse/core": "^11.2.0",
|
||||
"axios": "1.12.0",
|
||||
"axios": "1.16.1",
|
||||
"clipboard": "^2.0.11",
|
||||
"core-js": "^3.8.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
BIN
web-front/public/images/logo01.png
Normal file
BIN
web-front/public/images/logo01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 302 KiB |
BIN
web-front/public/images/logo02.png
Normal file
BIN
web-front/public/images/logo02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 482 KiB |
@@ -10,7 +10,9 @@
|
||||
<meta name="description"
|
||||
content="Netdisk fast download 网盘直链解析工具">
|
||||
<!-- Font Awesome 图标库 - 使用国内CDN -->
|
||||
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<!-- 迅雷 JS-SDK -->
|
||||
<script src="//open.thunderurl.com/thunder-link.js"></script>
|
||||
<style>
|
||||
.page-loading-wrap {
|
||||
padding: 120px;
|
||||
|
||||
23
web-front/scripts/sync-version.js
Normal file
23
web-front/scripts/sync-version.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pomPath = path.resolve(__dirname, '../../pom.xml');
|
||||
const pkgPath = path.resolve(__dirname, '../package.json');
|
||||
|
||||
const pomContent = fs.readFileSync(pomPath, 'utf-8');
|
||||
const match = pomContent.match(/<revision>([^<]+)<\/revision>/);
|
||||
if (!match) {
|
||||
console.error('sync-version: <revision> not found in root pom.xml');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const version = match[1];
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||
if (pkg.version === version) {
|
||||
console.log(`sync-version: package.json already at ${version}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
pkg.version = version;
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log(`sync-version: package.json ${pkg.version} -> ${version}`);
|
||||
@@ -36,7 +36,6 @@ if (item) {
|
||||
const darkMode = ref(item)
|
||||
|
||||
watch(darkMode, (newValue) => {
|
||||
console.log(`darkMode: ${newValue}`)
|
||||
window.localStorage.setItem("darkMode", newValue);
|
||||
|
||||
// 发射主题变化事件
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
266
web-front/src/components/DownloadDialog.vue
Normal file
266
web-front/src/components/DownloadDialog.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
title="文件下载"
|
||||
v-model="dialogVisible"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="$emit('update:visible', false)"
|
||||
>
|
||||
<div v-if="info" class="download-info-content">
|
||||
<div class="download-file-header">
|
||||
<i class="fas fa-file" style="margin-right: 8px; color: #409eff;"></i>
|
||||
<strong>{{ info.fileName || '未命名文件' }}</strong>
|
||||
</div>
|
||||
<el-alert
|
||||
title="该文件需要特殊请求头才能下载,无法直接通过浏览器下载。请使用以下方式之一:"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px;"
|
||||
/>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="发送到下载器" name="downloader">
|
||||
<div class="downloader-section">
|
||||
<template v-if="isThunder">
|
||||
<p v-if="thunderNeedsCookie" style="color: #f56c6c; margin-bottom: 12px;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
该文件需要 Cookie 认证,迅雷不支持自定义 Cookie,请切换到 Aria2/Motrix/Gopeed
|
||||
</p>
|
||||
<p v-else-if="thunderNeedsUa" style="color: #e6a23c; margin-bottom: 12px;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
该文件需要特殊 User-Agent 才能下载,迅雷客户端可能不支持自定义 UA,下载可能失败。建议切换到 Aria2/Motrix/Gopeed
|
||||
</p>
|
||||
<p v-else style="color: #909399; margin-bottom: 12px;">
|
||||
<i class="fas fa-bolt"></i>
|
||||
迅雷将通过浏览器唤起本地客户端下载
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="!connected" style="color: #e6a23c; margin-bottom: 12px;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
未检测到下载器连接,请先在首页配置下载器(Aria2/Motrix/Gopeed/迅雷)
|
||||
</p>
|
||||
<p v-else style="color: #67c23a; margin-bottom: 12px;">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
下载器已连接 ({{ downloaderVersion }})
|
||||
</p>
|
||||
</template>
|
||||
<el-button
|
||||
type="success"
|
||||
@click="sendToDownloader"
|
||||
:disabled="(isThunder && thunderNeedsCookie) || (!isThunder && !connected)"
|
||||
:loading="sending"
|
||||
>
|
||||
<i class="fas fa-paper-plane"></i> 发送到下载器
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!isThunder"
|
||||
size="small"
|
||||
@click="doTestConnection"
|
||||
style="margin-left: 8px;"
|
||||
>
|
||||
测试连接
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Aria2 命令" name="aria2">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:model-value="info.aria2Command"
|
||||
:rows="4"
|
||||
readonly
|
||||
resize="none"
|
||||
class="download-command-textarea"
|
||||
/>
|
||||
<div class="download-actions">
|
||||
<el-button type="primary" size="small" @click="copyText(info.aria2Command)">
|
||||
<i class="fas fa-copy"></i> 复制 Aria2 命令
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Curl 命令" name="curl">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:model-value="info.curlCommand"
|
||||
:rows="4"
|
||||
readonly
|
||||
resize="none"
|
||||
class="download-command-textarea"
|
||||
/>
|
||||
<div class="download-actions">
|
||||
<el-button type="primary" size="small" @click="copyText(info.curlCommand)">
|
||||
<i class="fas fa-copy"></i> 复制 Curl 命令
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<el-button size="small" type="warning" @click="doDirectDownload">
|
||||
直接打开链接(可能失败)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { testConnection, addDownload, getConfig, hasCookieHeader, hasCustomUaHeader } from '@/utils/downloaderService'
|
||||
|
||||
export default {
|
||||
name: 'DownloadDialog',
|
||||
props: {
|
||||
/** v-model:visible 控制弹窗显示 */
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* 下载信息对象
|
||||
* { downloadUrl, fileName, downloadHeaders, aria2Command, curlCommand, aria2JsonRpc, needDownloader }
|
||||
*/
|
||||
downloadInfo: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:visible', 'close'],
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'downloader',
|
||||
connected: false,
|
||||
downloaderVersion: '',
|
||||
sending: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dialogVisible: {
|
||||
get() { return this.visible },
|
||||
set(val) { this.$emit('update:visible', val) }
|
||||
},
|
||||
info() { return this.downloadInfo },
|
||||
isThunder() { return getConfig().downloaderType === 'thunder' },
|
||||
thunderNeedsCookie() { return this.isThunder && this.info && hasCookieHeader(this.info.downloadHeaders) },
|
||||
thunderNeedsUa() { return this.isThunder && this.info && hasCustomUaHeader(this.info.downloadHeaders) }
|
||||
},
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (val) {
|
||||
this.activeTab = 'downloader'
|
||||
this.checkConnection()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/** 检测下载器连接状态 */
|
||||
async checkConnection() {
|
||||
const result = await testConnection()
|
||||
this.connected = result.connected
|
||||
this.downloaderVersion = result.version
|
||||
},
|
||||
|
||||
/** 手动测试连接 */
|
||||
async doTestConnection() {
|
||||
const result = await testConnection()
|
||||
this.connected = result.connected
|
||||
this.downloaderVersion = result.version
|
||||
if (result.connected) {
|
||||
this.$message.success(`下载器连接正常 (${result.version})`)
|
||||
} else {
|
||||
this.$message.error('无法连接到下载器,请检查配置')
|
||||
}
|
||||
},
|
||||
|
||||
/** 发送到 Aria2/Motrix/Gopeed */
|
||||
async sendToDownloader() {
|
||||
if (!this.info) return
|
||||
this.sending = true
|
||||
try {
|
||||
const gid = await addDownload(
|
||||
this.info.downloadUrl,
|
||||
this.info.downloadHeaders,
|
||||
this.info.fileName
|
||||
)
|
||||
this.$message.success('已发送到下载器,任务ID: ' + gid)
|
||||
this.dialogVisible = false
|
||||
} catch (error) {
|
||||
console.error('发送到下载器失败:', error)
|
||||
this.$message.error('发送到下载器失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
this.sending = false
|
||||
}
|
||||
},
|
||||
|
||||
/** 直接打开下载链接(可能因缺请求头而失败) */
|
||||
doDirectDownload() {
|
||||
if (this.info && this.info.downloadUrl) {
|
||||
const a = document.createElement('a')
|
||||
a.href = this.info.downloadUrl
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
this.dialogVisible = false
|
||||
}
|
||||
},
|
||||
|
||||
/** 复制文本到剪贴板 */
|
||||
async copyText(text) {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
this.$message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
this.$message.success('已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.download-info-content {
|
||||
padding: 0 4px;
|
||||
}
|
||||
.download-file-header {
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 14px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
:deep(.dark) .download-file-header,
|
||||
.dark-theme .download-file-header {
|
||||
background: #1a3350;
|
||||
}
|
||||
.download-command-textarea :deep(.el-textarea__inner) {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
:deep(.dark) .download-command-textarea :deep(.el-textarea__inner),
|
||||
.dark-theme .download-command-textarea :deep(.el-textarea__inner) {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
.download-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.downloader-section {
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -274,7 +274,7 @@
|
||||
name: '联想乐云'
|
||||
},
|
||||
fangcloud: {
|
||||
reg: /https:\/\/v2\.fangcloud\.(com|cn)\/(s|sharing)\/.+/,
|
||||
reg: /https:\/\/v2\.fangcloud\.(com|cn)\/(s|share|sharing)\/.+/,
|
||||
host: /fangcloud\.(com|cn)/,
|
||||
name: '亿方云'
|
||||
},
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// 创建 axios 实例
|
||||
const api = axios.create({
|
||||
baseURL: process.env.VUE_APP_API_BASE_URL || 'http://localhost:6400',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
// 可以在这里添加认证token等
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
console.error('API请求错误:', error)
|
||||
|
||||
if (error.response) {
|
||||
// 服务器返回错误状态码
|
||||
const message = error.response.data?.message || error.response.data?.error || '服务器错误'
|
||||
return Promise.reject(new Error(message))
|
||||
} else if (error.request) {
|
||||
// 网络错误
|
||||
return Promise.reject(new Error('网络连接失败,请检查网络设置'))
|
||||
} else {
|
||||
// 其他错误
|
||||
return Promise.reject(new Error(error.message || '请求失败'))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 客户端链接 API
|
||||
export const clientLinksApi = {
|
||||
/**
|
||||
* 获取所有客户端下载链接
|
||||
* @param {string} shareUrl - 分享链接
|
||||
* @param {string} password - 提取码(可选)
|
||||
* @returns {Promise} 客户端链接响应
|
||||
*/
|
||||
async getClientLinks(shareUrl, password = '') {
|
||||
const params = new URLSearchParams()
|
||||
params.append('url', shareUrl)
|
||||
if (password) {
|
||||
params.append('pwd', password)
|
||||
}
|
||||
|
||||
return await api.get(`/v2/clientLinks?${params.toString()}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取指定类型的客户端下载链接
|
||||
* @param {string} shareUrl - 分享链接
|
||||
* @param {string} password - 提取码(可选)
|
||||
* @param {string} clientType - 客户端类型
|
||||
* @returns {Promise} 指定类型的客户端链接
|
||||
*/
|
||||
async getClientLink(shareUrl, password = '', clientType) {
|
||||
const params = new URLSearchParams()
|
||||
params.append('url', shareUrl)
|
||||
if (password) {
|
||||
params.append('pwd', password)
|
||||
}
|
||||
params.append('clientType', clientType)
|
||||
|
||||
return await api.get(`/v2/clientLink?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他 API(如果需要的话)
|
||||
export const parserApi = {
|
||||
/**
|
||||
* 解析分享链接
|
||||
* @param {string} shareUrl - 分享链接
|
||||
* @param {string} password - 提取码(可选)
|
||||
* @returns {Promise} 解析结果
|
||||
*/
|
||||
async parseLink(shareUrl, password = '') {
|
||||
const params = new URLSearchParams()
|
||||
params.append('url', shareUrl)
|
||||
if (password) {
|
||||
params.append('pwd', password)
|
||||
}
|
||||
|
||||
return await api.get(`/v2/linkInfo?${params.toString()}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取文件列表
|
||||
* @param {string} shareUrl - 分享链接
|
||||
* @param {string} password - 提取码(可选)
|
||||
* @param {string} dirId - 目录ID(可选)
|
||||
* @param {string} uuid - UUID(可选)
|
||||
* @returns {Promise} 文件列表
|
||||
*/
|
||||
async getFileList(shareUrl, password = '', dirId = '', uuid = '') {
|
||||
const params = new URLSearchParams()
|
||||
params.append('url', shareUrl)
|
||||
if (password) {
|
||||
params.append('pwd', password)
|
||||
}
|
||||
if (dirId) {
|
||||
params.append('dirId', dirId)
|
||||
}
|
||||
if (uuid) {
|
||||
params.append('uuid', uuid)
|
||||
}
|
||||
|
||||
return await api.get(`/v2/getFileList?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
6
web-front/src/utils/constants.js
Normal file
6
web-front/src/utils/constants.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 前端全局常量
|
||||
*/
|
||||
|
||||
/** 预览服务基础 URL */
|
||||
export const PREVIEW_BASE_URL = 'https://nfd-parser.github.io/nfd-preview/preview.html?src='
|
||||
462
web-front/src/utils/downloaderService.js
Normal file
462
web-front/src/utils/downloaderService.js
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* 下载器服务 - 统一管理 Aria2/Motrix/Gopeed/迅雷 的配置读取、连接检测、RPC 调用
|
||||
* 供 Home.vue、DirectoryTree.vue、DownloadDialog.vue 等共用
|
||||
*/
|
||||
import axios from 'axios'
|
||||
|
||||
const STORAGE_KEY = 'nfd-aria2-local-config'
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
downloaderType: 'aria2',
|
||||
rpcUrl: 'http://localhost:6800/jsonrpc',
|
||||
rpcSecret: '',
|
||||
downloadDir: ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取下载器配置
|
||||
* @returns {{ downloaderType: string, rpcUrl: string, rpcSecret: string, downloadDir: string }}
|
||||
*/
|
||||
export function getConfig() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
return {
|
||||
downloaderType: parsed.downloaderType || DEFAULT_CONFIG.downloaderType,
|
||||
rpcUrl: parsed.rpcUrl || DEFAULT_CONFIG.rpcUrl,
|
||||
rpcSecret: parsed.rpcSecret || '',
|
||||
downloadDir: parsed.downloadDir || ''
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('读取下载器配置失败', e)
|
||||
}
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存下载器配置到 localStorage
|
||||
* @param {{ downloaderType?: string, rpcUrl?: string, rpcSecret?: string, downloadDir?: string }} config
|
||||
*/
|
||||
export function saveConfig(config) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 RPC 参数数组(自动添加 token)
|
||||
* @param {string} rpcSecret
|
||||
* @param {Array} extraParams
|
||||
* @returns {Array}
|
||||
*/
|
||||
function buildRpcParams(rpcSecret, extraParams = []) {
|
||||
const params = []
|
||||
if (rpcSecret && rpcSecret.trim()) {
|
||||
params.push(`token:${rpcSecret}`)
|
||||
}
|
||||
if (extraParams && extraParams.length > 0) {
|
||||
params.push(...extraParams)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 Aria2 JSON-RPC 接口
|
||||
* @param {string} rpcUrl
|
||||
* @param {string} rpcSecret
|
||||
* @param {string} method - 例如 'aria2.getVersion', 'aria2.addUri'
|
||||
* @param {Array} [extraParams] - 除 token 外的参数
|
||||
* @param {number} [timeout=5000]
|
||||
* @returns {Promise<Object>} RPC 响应的 data
|
||||
*/
|
||||
export async function callRpc(rpcUrl, rpcSecret, method, extraParams = [], timeout = 5000) {
|
||||
const requestBody = {
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now().toString(),
|
||||
method,
|
||||
params: buildRpcParams(rpcSecret, extraParams)
|
||||
}
|
||||
const response = await axios.post(rpcUrl, requestBody, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout
|
||||
})
|
||||
if (response.data && response.data.error) {
|
||||
throw new Error(response.data.error.message || 'Aria2 RPC 错误')
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 rpcUrl 是否指向 Gopeed(端口 9999 或 URL 含 /api/v1)
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isGopeedUrl(url) {
|
||||
if (!url) return false
|
||||
return url.includes(':9999') || url.includes('/api/v1')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Gopeed rpcUrl 中提取 baseUrl(去掉 /jsonrpc 或 /api/v1 后缀)
|
||||
* 例如 "http://localhost:9999/jsonrpc" → "http://localhost:9999"
|
||||
* @param {string} rpcUrl
|
||||
* @returns {string}
|
||||
*/
|
||||
function gopeedBaseUrl(rpcUrl) {
|
||||
return rpcUrl.replace(/\/jsonrpc$/, '').replace(/\/api\/v1.*$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 Gopeed REST API
|
||||
* @param {string} baseUrl - 例如 "http://localhost:9999"
|
||||
* @param {string} rpcSecret - Bearer token
|
||||
* @param {string} method - 'GET' | 'POST'
|
||||
* @param {string} path - 例如 '/api/v1/version'
|
||||
* @param {Object} [body] - POST body
|
||||
* @param {number} [timeout=5000]
|
||||
* @returns {Promise<Object>} 响应 data
|
||||
*/
|
||||
async function callGopeedApi(baseUrl, rpcSecret, method, path, body, timeout = 5000) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (rpcSecret && rpcSecret.trim()) {
|
||||
headers['X-Api-Token'] = rpcSecret
|
||||
}
|
||||
const url = baseUrl.replace(/\/$/, '') + path
|
||||
const response = await axios({ method, url, headers, data: body, timeout })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试下载器连接(自动识别 迅雷 / Gopeed / Aria2 / Motrix)
|
||||
* @param {string} [rpcUrl] - 不传则自动读取配置
|
||||
* @param {string} [rpcSecret] - 不传则自动读取配置
|
||||
* @returns {Promise<{ connected: boolean, version: string }>}
|
||||
*/
|
||||
export async function testConnection(rpcUrl, rpcSecret) {
|
||||
if (!rpcUrl) {
|
||||
const config = getConfig()
|
||||
// 迅雷不需要 RPC,直接检测 JS SDK
|
||||
if (config.downloaderType === 'thunder') {
|
||||
const available = typeof window !== 'undefined' && window.thunderLink && typeof window.thunderLink.newTask === 'function'
|
||||
return { connected: available, version: available ? 'JS-SDK' : '' }
|
||||
}
|
||||
rpcUrl = config.rpcUrl
|
||||
rpcSecret = rpcSecret ?? config.rpcSecret
|
||||
}
|
||||
try {
|
||||
if (isGopeedUrl(rpcUrl)) {
|
||||
// Gopeed 使用 REST API:GET /api/v1/info
|
||||
const base = gopeedBaseUrl(rpcUrl)
|
||||
const res = await callGopeedApi(base, rpcSecret || '', 'GET', '/api/v1/info', undefined, 3000)
|
||||
const d = (res && res.code === 0 && res.data) ? res.data : {}
|
||||
const version = [d.version, d.runtime].filter(Boolean).join(' + ') || ''
|
||||
return { connected: true, version }
|
||||
} else {
|
||||
// Aria2 / Motrix 使用 JSON-RPC
|
||||
const res = await callRpc(rpcUrl, rpcSecret || '', 'aria2.getVersion', [], 3000)
|
||||
if (res && res.result && res.result.version) {
|
||||
return { connected: true, version: res.result.version }
|
||||
}
|
||||
return { connected: false, version: '' }
|
||||
}
|
||||
} catch {
|
||||
return { connected: false, version: '' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动检测本地下载器(依次尝试 Motrix/Gopeed/Aria2)
|
||||
* @param {string} [rpcSecret] - 可选密钥
|
||||
* @returns {Promise<{ found: boolean, type: string, rpcUrl: string, version: string }>}
|
||||
*/
|
||||
export async function autoDetect(rpcSecret = '') {
|
||||
const candidates = [
|
||||
{ type: 'motrix', port: 16800, path: '/jsonrpc' },
|
||||
{ type: 'gopeed', port: 9999, path: '/api/v1/info', gopeed: true },
|
||||
{ type: 'aria2', port: 6800, path: '/jsonrpc' }
|
||||
]
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
if (c.gopeed) {
|
||||
// Gopeed:直接调 REST GET /api/v1/info
|
||||
const base = `http://localhost:${c.port}`
|
||||
const res = await callGopeedApi(base, rpcSecret || '', 'GET', '/api/v1/info', undefined, 3000)
|
||||
const d = (res && res.code === 0 && res.data) ? res.data : {}
|
||||
const version = [d.version, d.runtime].filter(Boolean).join(' + ') || 'unknown'
|
||||
return { found: true, type: c.type, rpcUrl: `${base}/api/v1`, version }
|
||||
} else {
|
||||
const url = `http://localhost:${c.port}${c.path}`
|
||||
const result = await testConnection(url, rpcSecret)
|
||||
if (result.connected) {
|
||||
return { found: true, type: c.type, rpcUrl: url, version: result.version }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 该端口未响应,继续下一个
|
||||
}
|
||||
}
|
||||
return { found: false, type: '', rpcUrl: '', version: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送下载任务到下载器(自动识别 迅雷 / Gopeed / Aria2 / Motrix)
|
||||
* @param {string} downloadUrl - 文件下载地址
|
||||
* @param {Object} [headers] - 请求头 {cookie, referer, user-agent, ...}
|
||||
* @param {string} [fileName] - 输出文件名
|
||||
* @param {{ rpcUrl?: string, rpcSecret?: string, downloadDir?: string, downloaderType?: string }} [configOverride] - 覆盖配置
|
||||
* @returns {Promise<string>} 任务 ID / GID
|
||||
*/
|
||||
export async function addDownload(downloadUrl, headers, fileName, configOverride) {
|
||||
const config = { ...getConfig(), ...configOverride }
|
||||
|
||||
if (config.downloaderType === 'thunder') {
|
||||
return addThunderDownload([{ url: downloadUrl, headers, fileName }], config)
|
||||
}
|
||||
|
||||
if (isGopeedUrl(config.rpcUrl)) {
|
||||
// Gopeed REST API:POST /api/v1/tasks
|
||||
const base = gopeedBaseUrl(config.rpcUrl)
|
||||
const extraHeader = {}
|
||||
if (headers && typeof headers === 'object') {
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key && value) extraHeader[key] = value
|
||||
}
|
||||
}
|
||||
const body = {
|
||||
req: { url: downloadUrl, extra: { header: extraHeader } },
|
||||
opt: {}
|
||||
}
|
||||
if (config.downloadDir) body.opt.path = config.downloadDir
|
||||
const res = await callGopeedApi(base, config.rpcSecret, 'POST', '/api/v1/tasks', body, 10000)
|
||||
// Gopeed 返回 { code: 0, data: "task-id" }
|
||||
if (res && res.code !== undefined && res.code !== 0) throw new Error(res.message || 'Gopeed 发送失败')
|
||||
if (res && res.data) return typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
// Aria2 / Motrix JSON-RPC
|
||||
const options = {}
|
||||
if (headers && typeof headers === 'object') {
|
||||
const headerArray = []
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key && value) headerArray.push(`${key}: ${value}`)
|
||||
}
|
||||
if (headerArray.length > 0) options.header = headerArray
|
||||
}
|
||||
if (fileName) options.out = fileName
|
||||
if (config.downloadDir) options.dir = config.downloadDir
|
||||
|
||||
const res = await callRpc(config.rpcUrl, config.rpcSecret, 'aria2.addUri', [[downloadUrl], options], 10000)
|
||||
if (res && res.result) return res.result // GID
|
||||
throw new Error('未知错误')
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送下载任务到下载器(aria2 用 system.multicall,gopeed 用 batch API,迅雷用 JS-SDK newTask)
|
||||
* @param {{ url: string, headers?: Object, fileName?: string }[]} tasks - 下载任务列表
|
||||
* @param {{ rpcUrl?: string, rpcSecret?: string, downloadDir?: string, downloaderType?: string }} [configOverride]
|
||||
* @returns {Promise<{ succeeded: number, failed: number, errors: string[] }>}
|
||||
*/
|
||||
export async function batchAddDownload(tasks, configOverride) {
|
||||
if (!tasks || tasks.length === 0) return { succeeded: 0, failed: 0, errors: [] }
|
||||
if (tasks.length === 1) {
|
||||
try {
|
||||
await addDownload(tasks[0].url, tasks[0].headers, tasks[0].fileName, configOverride)
|
||||
return { succeeded: 1, failed: 0, errors: [] }
|
||||
} catch (e) {
|
||||
return { succeeded: 0, failed: 1, errors: [e.message || '未知错误'] }
|
||||
}
|
||||
}
|
||||
|
||||
const config = { ...getConfig(), ...configOverride }
|
||||
|
||||
if (config.downloaderType === 'thunder') {
|
||||
try {
|
||||
await addThunderDownload(tasks, config)
|
||||
return { succeeded: tasks.length, failed: 0, errors: [] }
|
||||
} catch (e) {
|
||||
return { succeeded: 0, failed: tasks.length, errors: [e.message || '迅雷下载失败'] }
|
||||
}
|
||||
}
|
||||
|
||||
if (isGopeedUrl(config.rpcUrl)) {
|
||||
return batchAddGopeed(tasks, config)
|
||||
} else {
|
||||
return batchAddAria2(tasks, config)
|
||||
}
|
||||
}
|
||||
|
||||
async function batchAddAria2(tasks, config) {
|
||||
const calls = tasks.map(task => {
|
||||
const options = {}
|
||||
if (task.headers && typeof task.headers === 'object') {
|
||||
const headerArray = []
|
||||
for (const [key, value] of Object.entries(task.headers)) {
|
||||
if (key && value) headerArray.push(`${key}: ${value}`)
|
||||
}
|
||||
if (headerArray.length > 0) options.header = headerArray
|
||||
}
|
||||
if (task.fileName) options.out = task.fileName
|
||||
if (config.downloadDir) options.dir = config.downloadDir
|
||||
|
||||
const params = []
|
||||
if (config.rpcSecret && config.rpcSecret.trim()) {
|
||||
params.push(`token:${config.rpcSecret}`)
|
||||
}
|
||||
params.push([task.url], options)
|
||||
return { methodName: 'aria2.addUri', params }
|
||||
})
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now().toString(),
|
||||
method: 'system.multicall',
|
||||
params: [calls]
|
||||
}
|
||||
const response = await axios.post(config.rpcUrl, requestBody, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: Math.max(10000, tasks.length * 500)
|
||||
})
|
||||
const results = response.data && response.data.result
|
||||
if (!Array.isArray(results)) {
|
||||
throw new Error(response.data?.error?.message || 'system.multicall 返回异常')
|
||||
}
|
||||
let succeeded = 0, failed = 0
|
||||
const errors = []
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i]
|
||||
if (Array.isArray(r) && r.length > 0 && typeof r[0] === 'string') {
|
||||
succeeded++
|
||||
} else if (r && r.faultCode) {
|
||||
failed++
|
||||
errors.push(`${tasks[i].fileName || tasks[i].url}: ${r.faultString || '未知错误'}`)
|
||||
} else {
|
||||
succeeded++
|
||||
}
|
||||
}
|
||||
return { succeeded, failed, errors }
|
||||
} catch (e) {
|
||||
return { succeeded: 0, failed: tasks.length, errors: [e.message || 'multicall 请求失败'] }
|
||||
}
|
||||
}
|
||||
|
||||
async function batchAddGopeed(tasks, config) {
|
||||
const base = gopeedBaseUrl(config.rpcUrl)
|
||||
const reqs = tasks.map(task => {
|
||||
const extraHeader = {}
|
||||
if (task.headers && typeof task.headers === 'object') {
|
||||
for (const [key, value] of Object.entries(task.headers)) {
|
||||
if (key && value) extraHeader[key] = value
|
||||
}
|
||||
}
|
||||
const item = { req: { url: task.url, extra: { header: extraHeader } } }
|
||||
if (task.fileName) {
|
||||
item.opts = { name: task.fileName }
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
const body = { reqs }
|
||||
if (config.downloadDir) body.opts = { path: config.downloadDir }
|
||||
|
||||
try {
|
||||
const res = await callGopeedApi(base, config.rpcSecret, 'POST', '/api/v1/tasks/batch', body,
|
||||
Math.max(10000, tasks.length * 500))
|
||||
if (res && res.code !== undefined && res.code !== 0) {
|
||||
return { succeeded: 0, failed: tasks.length, errors: [res.message || 'Gopeed batch 失败'] }
|
||||
}
|
||||
const ids = Array.isArray(res?.data) ? res.data : []
|
||||
return { succeeded: ids.length || tasks.length, failed: 0, errors: [] }
|
||||
} catch (e) {
|
||||
return { succeeded: 0, failed: tasks.length, errors: [e.message || 'Gopeed batch 请求失败'] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过迅雷 JS-SDK 发送下载任务
|
||||
* @param {{ url: string, headers?: Object, fileName?: string }[]} tasks
|
||||
* @param {{ downloadDir?: string }} config
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
function addThunderDownload(tasks, config) {
|
||||
if (typeof window === 'undefined' || !window.thunderLink || typeof window.thunderLink.newTask !== 'function') {
|
||||
return Promise.reject(new Error('迅雷客户端未检测到,请确认已安装并启动迅雷'))
|
||||
}
|
||||
// 迅雷 JS-SDK 不支持自定义 Cookie,含 Cookie 的下载链接无法通过迅雷下载
|
||||
const firstHeaders = (tasks[0] && tasks[0].headers) || {}
|
||||
if (firstHeaders.cookie || firstHeaders.Cookie) {
|
||||
return Promise.reject(new Error('该文件需要 Cookie 认证,迅雷不支持自定义 Cookie,请使用 Aria2/Motrix/Gopeed'))
|
||||
}
|
||||
|
||||
// 遍历所有 header key 大小写不敏感地提取 referer / user-agent
|
||||
let referer = ''
|
||||
let userAgent = ''
|
||||
for (const [key, value] of Object.entries(firstHeaders)) {
|
||||
const lk = key.toLowerCase()
|
||||
if (lk === 'referer' && value) referer = value
|
||||
if (lk === 'user-agent' && value) userAgent = value
|
||||
}
|
||||
|
||||
const taskParam = {
|
||||
tasks: tasks.map(t => {
|
||||
const item = { url: t.url }
|
||||
if (t.fileName) item.name = t.fileName
|
||||
return item
|
||||
})
|
||||
}
|
||||
if (config.downloadDir) taskParam.downloadDir = config.downloadDir
|
||||
if (referer) taskParam.referer = referer
|
||||
if (userAgent) taskParam.userAgent = userAgent
|
||||
taskParam.threadCount = '1'
|
||||
|
||||
window.thunderLink.newTask(taskParam)
|
||||
return Promise.resolve('thunder-ok')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 RPC URL 猜测下载器类型
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
export function guessDownloaderType(url) {
|
||||
if (!url) return 'aria2'
|
||||
if (url.includes(':16800')) return 'motrix'
|
||||
if (url.includes(':9999')) return 'gopeed'
|
||||
return 'aria2'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查下载头中是否含有 Cookie(迅雷不支持)
|
||||
* @param {Object} [headers]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasCookieHeader(headers) {
|
||||
if (!headers || typeof headers !== 'object') return false
|
||||
return !!(headers.cookie || headers.Cookie)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查下载头中是否含有自定义 User-Agent(迅雷客户端可能不支持)
|
||||
* @param {Object} [headers]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasCustomUaHeader(headers) {
|
||||
if (!headers || typeof headers !== 'object') return false
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === 'user-agent' && headers[key]) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export default {
|
||||
getConfig,
|
||||
saveConfig,
|
||||
callRpc,
|
||||
testConnection,
|
||||
autoDetect,
|
||||
addDownload,
|
||||
batchAddDownload,
|
||||
guessDownloaderType,
|
||||
hasCookieHeader
|
||||
}
|
||||
@@ -313,7 +313,6 @@ export async function loadTypesFromApi(monaco) {
|
||||
cachedContent,
|
||||
'file:///types.js'
|
||||
);
|
||||
console.log('从缓存加载types.js成功');
|
||||
// 异步更新缓存
|
||||
updateTypesJsCache();
|
||||
return;
|
||||
@@ -334,7 +333,6 @@ export async function loadTypesFromApi(monaco) {
|
||||
typesJsContent,
|
||||
'file:///types.js'
|
||||
);
|
||||
console.log('加载types.js成功并已缓存');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载types.js失败,使用内置类型定义:', error);
|
||||
@@ -350,7 +348,6 @@ async function updateTypesJsCache() {
|
||||
if (response.ok) {
|
||||
const typesJsContent = await response.text();
|
||||
localStorage.setItem('playground_types_js', typesJsContent);
|
||||
console.log('types.js缓存已更新');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('更新types.js缓存失败:', error);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user