mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-08-30 05:22:01 +00:00
Compare commits
644
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ba0aff52 | ||
|
|
74e401d99c | ||
|
|
4995bca901 | ||
|
|
7299fd8762 | ||
|
|
efbadde4ed | ||
|
|
d1fa787bef | ||
|
|
c282fcb109 | ||
|
|
4995173de2 | ||
|
|
1c1068beba | ||
|
|
4a5240fcbc | ||
|
|
0f34672e33 | ||
|
|
ecd365767a | ||
|
|
f358140974 | ||
|
|
541c21f963 | ||
|
|
bdd253383d | ||
|
|
0fb53d5159 | ||
|
|
b9ff408bdd | ||
|
|
aeb4394cf8 | ||
|
|
8d419d3265 | ||
|
|
b70e30796f | ||
|
|
d3ef521773 | ||
|
|
2969c92d3d | ||
|
|
2fde421169 | ||
|
|
3494271150 | ||
|
|
675d930f86 | ||
|
|
5e20ed5050 | ||
|
|
91f4fcaa76 | ||
|
|
0754fe20f1 | ||
|
|
034fd20d3b | ||
|
|
363c603bbb | ||
|
|
24bf98bca9 | ||
|
|
275f6b9e6d | ||
|
|
5db9e422f4 | ||
|
|
705b6ba4b7 | ||
|
|
0edc057347 | ||
|
|
6ae5d998e6 | ||
|
|
cbb442ceb6 | ||
|
|
0103841fb5 | ||
|
|
b6b7f0d8b7 | ||
|
|
46c6827eda | ||
|
|
1ef6e120a8 | ||
|
|
65d518373d | ||
|
|
bca4da4b6c | ||
|
|
0fd78defcb | ||
|
|
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 | ||
|
|
ed40b254e4 | ||
|
|
8e9b9e6347 | ||
|
|
4fa2d88204 | ||
|
|
cb6d4811d2 | ||
|
|
c19601b209 | ||
|
|
6e6215ad7e | ||
|
|
3a25e5f2ae | ||
|
|
7fc6367b9e | ||
|
|
cb5c83be41 | ||
|
|
c760e47154 | ||
|
|
3d30835ffa | ||
|
|
ea47bb39eb | ||
|
|
c45967e175 | ||
|
|
1b357de2f3 | ||
|
|
cf76a5ddd6 | ||
|
|
4e27bf0dc4 | ||
|
|
39c082e4ee | ||
|
|
95193fc8f9 | ||
|
|
4f3131979f | ||
|
|
ad9a8ab0b0 | ||
|
|
1f3161216a | ||
|
|
e5f7b164a3 | ||
|
|
7974c92382 | ||
|
|
b51add45f1 | ||
|
|
cb132359fd | ||
|
|
b58b3658b5 | ||
|
|
9f25aca242 | ||
|
|
343c017c79 | ||
|
|
83af09cf58 | ||
|
|
449475785f | ||
|
|
047a8eab89 | ||
|
|
93835bd990 | ||
|
|
93ab3f3f3f | ||
|
|
de7703be83 | ||
|
|
ce1c4ee669 | ||
|
|
c60d9fdd61 | ||
|
|
2033542f49 | ||
|
|
3775cd0259 | ||
|
|
bec342d778 | ||
|
|
6305d805dd | ||
|
|
2ada2fddf7 | ||
|
|
66ba8b7ee8 | ||
|
|
2edf235941 | ||
|
|
57ef723368 | ||
|
|
366658b471 | ||
|
|
2a3244a8fa | ||
|
|
9912e6fef1 | ||
|
|
d475dcbcdc | ||
|
|
5ffe94e3a4 | ||
|
|
5809d3b664 | ||
|
|
2bed42a088 | ||
|
|
97e912c216 | ||
|
|
f657aa183c | ||
|
|
cf634c4464 | ||
|
|
a9b5d9ab57 | ||
|
|
f82267f8a7 | ||
|
|
46bd5819b3 | ||
|
|
d10a55d8cb | ||
|
|
e74d5ea97e | ||
|
|
1dfdff7024 | ||
|
|
f5c81e1b8e | ||
|
|
804b8853d9 | ||
|
|
f87a66bc79 | ||
|
|
74b9cc438c | ||
|
|
7829174cc0 | ||
|
|
2e558df96b | ||
|
|
474eea5f80 | ||
|
|
fc50d1f5ba | ||
|
|
3b63f48dfa | ||
|
|
95697de1d0 | ||
|
|
1aa9ecdc45 | ||
|
|
0a3dbc6342 | ||
|
|
72f215c301 | ||
|
|
add90984a5 | ||
|
|
857ac7a2c9 | ||
|
|
b807223614 | ||
|
|
c775dabd7e | ||
|
|
5ee0aae6e1 | ||
|
|
5c8e4d7754 | ||
|
|
79123448f6 | ||
|
|
8e4328f1eb | ||
|
|
63c810dcbb | ||
|
|
c71d4cfa87 | ||
|
|
2b17567a1d | ||
|
|
ba5f88af43 | ||
|
|
782f47c7c5 | ||
|
|
8f92ce9292 | ||
|
|
1c3b5082a2 | ||
|
|
8535f49786 | ||
|
|
92043cf415 | ||
|
|
c97a235e23 | ||
|
|
97c2fe6784 | ||
|
|
1baa6e36b8 | ||
|
|
9dbc8718a8 | ||
|
|
3107281cdc | ||
|
|
c5b2340fe6 | ||
|
|
2f6f977adb | ||
|
|
902443b511 | ||
|
|
322f173104 | ||
|
|
c61ba45d59 | ||
|
|
684e8d4c84 | ||
|
|
a96bde812d | ||
|
|
c0276f97d6 | ||
|
|
f60d2573e6 | ||
|
|
7e746ded60 | ||
|
|
11669c07ed | ||
|
|
0c0e4c9cc1 | ||
|
|
a3ab467c74 | ||
|
|
acb646ee2d | ||
|
|
5a02c38d57 | ||
|
|
bd9ac79ec9 | ||
|
|
d6c8b2f476 | ||
|
|
b616c59f1b | ||
|
|
c6603c0d83 | ||
|
|
ae0a5644b5 | ||
|
|
d87ab43a9b | ||
|
|
cfe084cb07 | ||
|
|
75521c87ba | ||
|
|
67042b39b3 | ||
|
|
c65b55d0c6 | ||
|
|
8e938acdb2 | ||
|
|
5db5b3c75a | ||
|
|
291df9d984 | ||
|
|
323c62f4e6 | ||
|
|
03e2bbbb91 | ||
|
|
4b8660932a | ||
|
|
35c7746e38 | ||
|
|
e2155aec45 | ||
|
|
aaf34a460e | ||
|
|
1bb30a9ed2 | ||
|
|
b1c68aa865 | ||
|
|
8b57d04a2e | ||
|
|
1a57bddef7 | ||
|
|
1d30716aa6 | ||
|
|
a2aabfc601 | ||
|
|
f7040a3c1c | ||
|
|
40d4a1fcb5 | ||
|
|
e01bb2d8d7 | ||
|
|
d8a4d2e39f | ||
|
|
dbbd5759ab | ||
|
|
0a59ff2357 | ||
|
|
4e1eb6654d | ||
|
|
b588ba740f | ||
|
|
af605a2be7 | ||
|
|
b2630a7e35 | ||
|
|
211fe92a35 | ||
|
|
be168a9f0d | ||
|
|
977f5dc6dd | ||
|
|
131cb7085d | ||
|
|
7fbd446a2a | ||
|
|
7b64699782 | ||
|
|
bad56037c4 | ||
|
|
180e54f278 | ||
|
|
ae89e0be26 | ||
|
|
f19b366b1b | ||
|
|
6b1bd573c8 | ||
|
|
43c82f2f16 | ||
|
|
e478486d73 | ||
|
|
32e5c69735 | ||
|
|
4ec5b491c5 | ||
|
|
6a0e4a695d | ||
|
|
e6d69331be | ||
|
|
f16e196040 | ||
|
|
2f67e3b04d | ||
|
|
4663751924 | ||
|
|
e6ae7e2545 | ||
|
|
74ec248b8c | ||
|
|
cd04eead07 | ||
|
|
2b4340a5d6 | ||
|
|
ff6913264f | ||
|
|
ffd88841d5 | ||
|
|
0ed0d2ed82 | ||
|
|
c2387c47ee | ||
|
|
65cc78de7d | ||
|
|
8bedfcf794 | ||
|
|
1cd059f842 | ||
|
|
42438f2bbd | ||
|
|
1fb6e5d660 | ||
|
|
3fa0588e03 | ||
|
|
5a008f4ddd | ||
|
|
1a0b3a75c3 | ||
|
|
c5ad19881c | ||
|
|
6188e5f2c6 | ||
|
|
6f19fbd300 | ||
|
|
5307da24ed | ||
|
|
e65da6fe4a | ||
|
|
7ed83e9437 | ||
|
|
f008f490d9 | ||
|
|
01109dedbd | ||
|
|
f2a78d8937 | ||
|
|
eef56b308c | ||
|
|
c9ca47b27d | ||
|
|
35e54f5ad5 | ||
|
|
310d516aac | ||
|
|
aee84e3cb3 | ||
|
|
f9fd68c0f4 | ||
|
|
9761a9e2d9 | ||
|
|
8bed7487de | ||
|
|
2b084a3cc8 | ||
|
|
1caabd1368 | ||
|
|
b5397ba303 | ||
|
|
36f9b53f9b | ||
|
|
26fcbaa9a6 | ||
|
|
a8a5b6e3ab | ||
|
|
588e087941 | ||
|
|
fe71db0967 | ||
|
|
9436575dcb | ||
|
|
4ea380d28e | ||
|
|
1f46b477e3 | ||
|
|
f7e17a10b5 | ||
|
|
6e19e29857 | ||
|
|
f6b5d72784 | ||
|
|
68f1334907 | ||
|
|
a83f4e9a2f | ||
|
|
fcd8709c7d | ||
|
|
854d0ea241 | ||
|
|
cff223ecd2 | ||
|
|
8f14762f43 | ||
|
|
c37ae638d2 | ||
|
|
bcfa46ec43 | ||
|
|
42cfe4aacf | ||
|
|
64dba88881 | ||
|
|
3f300ded96 | ||
|
|
1ceafc5c08 | ||
|
|
93adb06083 | ||
|
|
3ef1c81d13 | ||
|
|
84b4deffc6 | ||
|
|
2e7f1a7e52 | ||
|
|
59bea226e5 | ||
|
|
0bbef0c7a9 | ||
|
|
fc91192d0e | ||
|
|
8dbcaed813 | ||
|
|
577ec46f71 | ||
|
|
65bf4534c7 | ||
|
|
5b666e1fec | ||
|
|
e839f56a91 | ||
|
|
c5a327e2e0 | ||
|
|
5b5a11ac36 | ||
|
|
0c3efe326f | ||
|
|
b9100fb934 | ||
|
|
74a98713c1 | ||
|
|
339b80bbea | ||
|
|
ce0c3b3a1e | ||
|
|
1e611dd85b | ||
|
|
0791114e92 | ||
|
|
c85b9a01fd | ||
|
|
9c2a0e7f46 | ||
|
|
8699aef0da | ||
|
|
749bc67212 | ||
|
|
67e24e846b | ||
|
|
091def569a | ||
|
|
fb0cf9102b | ||
|
|
69ce48adb4 | ||
|
|
6c3ab975cc | ||
|
|
f501935365 | ||
|
|
c3593f335e | ||
|
|
351bdd9c58 | ||
|
|
812042b95e | ||
|
|
9957fcbce8 | ||
|
|
acb2c00cca | ||
|
|
9655b8715e | ||
|
|
13de30e8b5 | ||
|
|
1cfeae2ad2 | ||
|
|
31b4190eaf | ||
|
|
380e1c7482 | ||
|
|
f202800a0a | ||
|
|
96d246e64f | ||
|
|
75b460f4b0 | ||
|
|
1ba7af485d | ||
|
|
76c16c6312 | ||
|
|
66a09a8f1c | ||
|
|
235fed1b4e | ||
|
|
f75921ba03 | ||
|
|
202b261a53 | ||
|
|
95c39d258e | ||
|
|
71b35e4651 | ||
|
|
2465a4d6d3 | ||
|
|
30d4ce4781 | ||
|
|
27be70ea00 | ||
|
|
0c22267eaa | ||
|
|
d4f4ec67c3 | ||
|
|
df74d9a117 | ||
|
|
2fc7556f39 | ||
|
|
418dcde15f | ||
|
|
59db290cae | ||
|
|
2992d3586f | ||
|
|
7b8eea4865 | ||
|
|
6b2bba2854 | ||
|
|
4666e942b4 | ||
|
|
daa3c940ee | ||
|
|
e54c15dad1 | ||
|
|
054024e3e8 | ||
|
|
5be8c4548f | ||
|
|
60bd8ecf21 | ||
|
|
25edeaf709 | ||
|
|
1e7525f908 | ||
|
|
341e33a845 | ||
|
|
f5ef8c07a5 | ||
|
|
76da12da65 | ||
|
|
dacf93bd06 | ||
|
|
498bce5255 | ||
|
|
d67df58bc6 | ||
|
|
751c7163fe | ||
|
|
9bd5da8ac3 | ||
|
|
769e8545b6 | ||
|
|
a30c037138 | ||
|
|
3c7fc752fc | ||
|
|
54576e8cbc | ||
|
|
f2bac57ed3 | ||
|
|
a3220c36c1 | ||
|
|
821fc945cf | ||
|
|
784b3334f4 | ||
|
|
57c3355d3e | ||
|
|
9275e28e15 | ||
|
|
a82596dc70 | ||
|
|
add27186bc | ||
|
|
087ad0c120 | ||
|
|
5cd1db7e4b | ||
|
|
3cdbac1603 | ||
|
|
7402dd76cc | ||
|
|
de2e47628a | ||
|
|
aad0098169 | ||
|
|
922ce84000 | ||
|
|
aabfcd8c8b | ||
|
|
0e15d9f309 | ||
|
|
5ac89ac085 | ||
|
|
b3ec433d33 | ||
|
|
207b4cf6b3 | ||
|
|
03fbb3ef15 | ||
|
|
4ce62d6b98 | ||
|
|
1f504a8cd5 | ||
|
|
dd52f5a61f | ||
|
|
8369212bd3 | ||
|
|
c67911f1bb | ||
|
|
af95de8d15 | ||
|
|
ba2bfbd11a | ||
|
|
cc4b0ae90e | ||
|
|
399f55cae6 | ||
|
|
981513a557 | ||
|
|
326cfa838c | ||
|
|
b576e06099 | ||
|
|
1ad788c63f | ||
|
|
faf4d8e7ef | ||
|
|
d77095fe87 | ||
|
|
1bd1f611fe | ||
|
|
38f13524a1 | ||
|
|
d5588e3850 | ||
|
|
b7c2887d0c | ||
|
|
1cba9df571 | ||
|
|
32fc530a17 | ||
|
|
c9436f0a47 | ||
|
|
b8174a16f4 | ||
|
|
2a44a55764 | ||
|
|
0ad72c9108 | ||
|
|
b8340949d5 | ||
|
|
6c3433595f | ||
|
|
3e5e42fef9 | ||
|
|
c19f584b0d | ||
|
|
0bbf022c5d | ||
|
|
4516aa8300 | ||
|
|
30a53dd47c | ||
|
|
6e77c21021 | ||
|
|
f035cf4088 | ||
|
|
af5cba5ed6 | ||
|
|
8bb3d26b4c | ||
|
|
ef0829ab8f | ||
|
|
13f3546700 | ||
|
|
0cccba7b3b |
+206
-37
@@ -1,24 +1,16 @@
|
||||
# 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
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '*' # 只有推送tag时才会触发构建
|
||||
- '*'
|
||||
branches-ignore:
|
||||
- '*' # 排除所有分支的提交
|
||||
- '*'
|
||||
paths-ignore:
|
||||
- 'bin/**'
|
||||
- '.github/**'
|
||||
@@ -32,70 +24,247 @@ 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
|
||||
- name: 生成 Docker 标签
|
||||
id: docker_tag
|
||||
shell: bash
|
||||
run: |
|
||||
GIT_TAG=$(git tag --points-at HEAD | head -n 1)
|
||||
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
|
||||
tag="$(printf '%s' "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_.-]+/-/g; s/^-+//; s/-+$//')"
|
||||
if [ -z "$tag" ]; then
|
||||
tag="snapshot"
|
||||
fi
|
||||
echo "tag=$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 }}:${{ steps.docker_tag.outputs.tag }}
|
||||
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
|
||||
|
||||
# ============================================================
|
||||
# 选择 JDK 模块
|
||||
# ============================================================
|
||||
- name: 选择完整 JDK 模块集合
|
||||
run: |
|
||||
# 123 网盘/自定义 JS 解析器会通过 javax.script、Nashorn 和 SPI/反射加载模块,
|
||||
# jdeps 无法稳定识别这些运行时依赖。这里优先保证原生包可运行,使用完整
|
||||
# JDK 模块集合生成运行时,避免 Windows 精简 JRE 缺 java.scripting/jdk.dynalink 等模块。
|
||||
printf 'JDK_MODULES=ALL-MODULE-PATH\n' >> $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
|
||||
|
||||
echo "=== 关键 JDK 模块校验 ==="
|
||||
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^java.scripting@'
|
||||
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^jdk.dynalink@'
|
||||
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^jdk.unsupported@'
|
||||
|
||||
# ============================================================
|
||||
# 组装包目录
|
||||
# ============================================================
|
||||
- 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
|
||||
|
||||
+13
@@ -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
|
||||
@@ -80,3 +83,13 @@ yarn-error.log*
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# Build directories
|
||||
**/target/
|
||||
**/build/
|
||||
**/classes/
|
||||
**/out/
|
||||
**/${project.build.directory}/
|
||||
**/${project.basedir}/target/
|
||||
**/${basedir}/target/
|
||||
.spec-workflow/
|
||||
|
||||
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.debug.settings.onBuildFailureProceed": true
|
||||
}
|
||||
+8
-3
@@ -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"]
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
# 一款网盘分享链接云解析快速下载服务
|
||||
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://github.com/QAIU/netdisk-fast-download">
|
||||
<img src="https://img.shields.io/github/stars/QAIU/netdisk-fast-download?style=flat&logo=github" alt="GitHub Stars">
|
||||
</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>
|
||||
<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等,支持加密分享,以及部分网盘文件夹分享。
|
||||
## 介绍
|
||||
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/永硕E盘/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%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&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://www.ilanzou.com/s/CDx6xKbT&pwd=1234
|
||||
### 调用演示站预览:
|
||||
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%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%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4
|
||||
|
||||
```
|
||||
|
||||
@@ -42,18 +53,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,18 +66,17 @@ 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/)
|
||||
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用,需登录认证,公共/云端服务器风控严格建议自行部署(Windows 可直接用 run.bat 一键运行)
|
||||
- ~[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/)
|
||||
- [QQ闪传-qqsc](https://nutty.qq.com/nutty/ssr/26797.html)
|
||||
- [城通网盘-ct](https://www.ctfile.com)
|
||||
- [永硕E盘-ys](https://www.ysepan.com/)(空间分享,如 `https://xxx.ysepan.com/`,密码为空间访问密码;多文件请用文件列表接口)
|
||||
- [网易云音乐分享链接-mnes](https://music.163.com)
|
||||
- [酷狗音乐分享链接-mkgs](https://www.kugou.com)
|
||||
- [酷我音乐分享链接-mkws](https://kuwo.cn)
|
||||
@@ -80,15 +84,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/)
|
||||
@@ -142,6 +147,89 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
|
||||
- `{网盘标识}` 参考支持的网盘列表
|
||||
- `your_host` 替换为您的域名或 IP
|
||||
|
||||
### 认证参数(v0.2.1+)
|
||||
[可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
||||
部分网盘(如夸克、UC)需要登录后的 Cookie 才能解析和下载。可通过 `auth` 参数传递认证信息:
|
||||
|
||||
**参数格式**:`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码
|
||||
|
||||
**加密方式**:
|
||||
- 算法:AES/ECB/PKCS5Padding
|
||||
- 密钥:`nfd_auth_key2026`(16字节)
|
||||
- 流程:JSON → AES加密 → Base64 → URL编码
|
||||
|
||||
**JSON 结构**:
|
||||
```json
|
||||
{
|
||||
"authType": "cookie", // 认证类型: cookie/accesstoken/authorization/password/custom
|
||||
"token": "your_cookie_here", // Cookie 或 Token 内容
|
||||
"username": "", // 用户名(password 类型时使用)
|
||||
"password": "", // 密码(password 类型时使用)
|
||||
"ext1": "", // 扩展字段1(custom 类型时使用)
|
||||
"ext2": "" // 扩展字段2(custom 类型时使用)
|
||||
}
|
||||
```
|
||||
|
||||
**网盘认证要求**:
|
||||
| 网盘 | 认证要求 | 说明 |
|
||||
|------|---------|------|
|
||||
| 夸克网盘(QK) | **必须** | 必须配置 Cookie 才能解析 |
|
||||
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
|
||||
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
|
||||
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
|
||||
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
|
||||
|
||||
**使用示例**:
|
||||
```
|
||||
GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
|
||||
```
|
||||
|
||||
> 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。
|
||||
> [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
||||
|
||||
#### 密钥作用说明
|
||||
|
||||
- `server.authEncryptKey`
|
||||
- 作用:用于 `auth` 参数的 AES 加解密
|
||||
- 要求:16位(AES-128)
|
||||
|
||||
- `server.donatedAccountFailureTokenSignKey`
|
||||
- 作用:用于“捐赠账号失败计数 token”的 HMAC 签名/验签
|
||||
- 目的:防止客户端伪造失败计数请求
|
||||
- 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同
|
||||
|
||||
#### `auth` 临时认证参数 与 `auths` 静态配置认证 的区别
|
||||
|
||||
本项目存在两种互相独立的认证配置方式,作用范围不同,不要混淆:
|
||||
|
||||
| 方式 | 配置位置 | 生效范围 | 适用场景 |
|
||||
|------|---------|---------|---------|
|
||||
| `auth` 临时认证参数 | 请求 URL 上的 `auth` 查询参数 | **仅当次请求**,优先级高于 app-dev.yml 中的静态配置 | 调用方按用户临时提供的账号/Cookie/token 解析,不同请求可携带不同认证信息 |
|
||||
| `auths` 静态配置认证 | `app-dev.yml` 的 `auths.<网盘标识>` 节点 | **服务端长期生效**,所有未携带 `auth` 参数的请求都会复用 | 部署方自己长期配置一份账号,供所有请求默认使用 |
|
||||
|
||||
以 123网盘(`ye`)为例,`app-dev.yml` 中支持以下几种写法(三选一即可):
|
||||
|
||||
```yaml
|
||||
auths:
|
||||
ye:
|
||||
username: 你的123网盘账号
|
||||
password: 你的123网盘密码
|
||||
```
|
||||
```yaml
|
||||
auths:
|
||||
ye:
|
||||
token: 已登录后获取的 Authorization/AccessToken
|
||||
```
|
||||
```yaml
|
||||
auths:
|
||||
ye:
|
||||
authorization: 已登录后获取的 Authorization/AccessToken # 与 token 等价,二选一
|
||||
```
|
||||
|
||||
> ⚠️ 注意:YAML 中 key 后面不写值(如 `authorization:` 空着)等同于没配置,不会生效,必须填入真实的账号密码或 token 内容。
|
||||
|
||||
如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。
|
||||
|
||||
### 特殊说明
|
||||
|
||||
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
|
||||
@@ -222,9 +310,12 @@ json返回数据格式示例:
|
||||
"timestamp": 1736489219402
|
||||
}
|
||||
```
|
||||
#### 3. 文件夹解析(仅支持蓝奏云/蓝奏优享/小飞机网盘)
|
||||
#### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机/永硕E盘等)
|
||||
/v2/getFileList?url=分享链接&pwd=分享密码
|
||||
|
||||
永硕E盘(`ys`)空间链接先返回目录列表,再带 `dirId` 获取目录内文件:
|
||||
`/v2/getFileList?url=https://xxx.ysepan.com/&pwd=空间密码&dirId=目录编号`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
@@ -275,15 +366,16 @@ json返回数据格式示例:
|
||||
| 网盘名称 | 免登陆下载分享 | 加密分享 | 初始网盘空间 | 单文件大小限制 |
|
||||
|-------------|---------|----------|-----------|-----------------|
|
||||
| 蓝奏云 | √ | √ | 不限空间 | 100M |
|
||||
| 奶牛快传 | √ | X | 10G | 不限大小 |
|
||||
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
|
||||
| 小飞机网盘 | √ | √(密码可忽略) | 10G | 不限大小 |
|
||||
| 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 |
|
||||
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
|
||||
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
|
||||
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
|
||||
| 文叔叔 | √ | √ | 10G | 5GB |
|
||||
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
|
||||
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
||||
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
||||
| UC网盘 | x | √ | 10G | 不限大小 |
|
||||
| 飞书云盘 | √ | X | 15G | 不限大小 |
|
||||
|
||||
# 打包部署
|
||||
|
||||
@@ -363,8 +455,8 @@ 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
|
||||
unzip netdisk-fast-download-bin.zip
|
||||
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.4.2/netdisk-fast-download-linux-amd64.zip
|
||||
unzip netdisk-fast-download.zip
|
||||
cd netdisk-fast-download
|
||||
bash service-install.sh
|
||||
```
|
||||
@@ -436,23 +528,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
|
||||
@@ -461,7 +536,13 @@ Core模块集成Vert.x实现类似spring的注解式路由API
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#qaiu/netdisk-fast-download&Date)
|
||||
<a href="https://www.star-history.com/?repos=qaiu%2Fnetdisk-fast-download&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&theme=dark&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## **免责声明**
|
||||
- 用户在使用本项目时,应自行承担风险,并确保其行为符合当地法律法规。开发者不对用户因使用本项目而导致的任何后果负责。
|
||||
@@ -477,20 +558,5 @@ Core模块集成Vert.x实现类似spring的注解式路由API
|
||||
</p>
|
||||
|
||||
|
||||
### 关于赞助定制专属版
|
||||
1. 专属版提供对小飞机,蓝奏优享大文件解析的支持, 提供天翼云盘/移动云盘/联通云盘的解析支持。
|
||||
2. 可提供托管服务:包含部署服务和云服务器环境。
|
||||
3. 可提供功能定制开发。
|
||||
您可能需要提供一定的资金赞助支持定制专属版, 请添加以下任意一个联系方式详谈赞助模式:
|
||||
<p>qq: 197575894</p>
|
||||
<p>wechat: imcoding_</p>
|
||||
|
||||
<!--
|
||||

|
||||
|
||||
[手机端支付宝打赏跳转链接](https://qr.alipay.com/fkx01882dnoxxtjenhlxt53)
|
||||
-->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-2
@@ -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.13</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,26 @@ 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.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
import static cn.qaiu.vx.core.util.ConfigConstant.*;
|
||||
@@ -41,11 +47,25 @@ public final class Deploy {
|
||||
private Handler<JsonObject> handle;
|
||||
|
||||
private Thread mainThread;
|
||||
private final List<Runnable> preShutdownTasks = new CopyOnWriteArrayList<>();
|
||||
private final List<Runnable> postShutdownTasks = new CopyOnWriteArrayList<>();
|
||||
|
||||
public static Deploy instance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public void addPreShutdownTask(Runnable task) {
|
||||
if (task != null) {
|
||||
preShutdownTasks.add(task);
|
||||
}
|
||||
}
|
||||
|
||||
public void addPostShutdownTask(Runnable task) {
|
||||
if (task != null) {
|
||||
postShutdownTasks.add(task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param args 启动参数
|
||||
@@ -54,15 +74,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 +134,7 @@ public final class Deploy {
|
||||
|
||||
System.out.printf(logoTemplate,
|
||||
CommonUtil.getAppVersion(),
|
||||
VersionCommand.getVersion(),
|
||||
"4x",
|
||||
conf.getString("copyright"),
|
||||
year
|
||||
);
|
||||
@@ -119,44 +149,112 @@ public final class Deploy {
|
||||
customConfig = globalConfig.getJsonObject(CUSTOM);
|
||||
|
||||
JsonObject vertxConfig = globalConfig.getJsonObject(VERTX);
|
||||
Integer vertxConfigELPS = vertxConfig.getInteger(EVENT_LOOP_POOL_SIZE);
|
||||
var vertxOptions = vertxConfigELPS == 0 ?
|
||||
new VertxOptions() : new VertxOptions(vertxConfig);
|
||||
JsonObject vertxOptionsConfig = vertxConfig.copy();
|
||||
if (vertxOptionsConfig.getInteger(EVENT_LOOP_POOL_SIZE, 0) == 0) {
|
||||
vertxOptionsConfig.remove(EVENT_LOOP_POOL_SIZE);
|
||||
}
|
||||
if (vertxOptionsConfig.getInteger("workerPoolSize", 0) == 0) {
|
||||
vertxOptionsConfig.remove("workerPoolSize");
|
||||
}
|
||||
Integer vertxConfigELPS = vertxConfig.getInteger(EVENT_LOOP_POOL_SIZE, 0);
|
||||
var vertxOptions = vertxOptionsConfig.isEmpty() ?
|
||||
new VertxOptions() : new VertxOptions(vertxOptionsConfig);
|
||||
|
||||
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...");
|
||||
runShutdownTasks("before Vert.x close", preShutdownTasks);
|
||||
try {
|
||||
LOGGER.info("Closing Vert.x...");
|
||||
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);
|
||||
} finally {
|
||||
runShutdownTasks("after Vert.x close", postShutdownTasks);
|
||||
}
|
||||
}));
|
||||
//配置保存在共享数据中
|
||||
var sharedData = vertx.sharedData();
|
||||
LocalMap<String, Object> localMap = sharedData.getLocalMap(LOCAL);
|
||||
localMap.put(GLOBAL_CONFIG, globalConfig);
|
||||
localMap.put(CUSTOM_CONFIG, customConfig);
|
||||
localMap.put(SERVER, globalConfig.getJsonObject(SERVER));
|
||||
var future0 = vertx.createSharedWorkerExecutor("other-handle")
|
||||
.executeBlocking(() -> {
|
||||
WorkerExecutor otherHandleExecutor = vertx.createSharedWorkerExecutor("other-handle");
|
||||
var future0 = otherHandleExecutor.executeBlocking(() -> {
|
||||
handle.handle(globalConfig);
|
||||
return "Other handle complete";
|
||||
});
|
||||
|
||||
future0.onSuccess(res -> {
|
||||
otherHandleExecutor.close();
|
||||
LOGGER.info(res);
|
||||
// 部署 路由、异步service、反向代理 服务
|
||||
var future1 = vertx.deployVerticle(RouterVerticle.class, getWorkDeploymentOptions("Router"));
|
||||
var future2 = vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"));
|
||||
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy"));
|
||||
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy", 1));
|
||||
|
||||
Future.all(future1, future2, future3)
|
||||
.onSuccess(this::deployWorkVerticalSuccess)
|
||||
.onFailure(this::deployVerticalFailed);
|
||||
}).onFailure(e -> LOGGER.error("Other handle error", e));
|
||||
|
||||
JsonObject jsonObject = ((JsonObject) localMap.get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
|
||||
if (jsonObject != null) {
|
||||
genPwd(jsonObject);
|
||||
var future4 = vertx.deployVerticle(HttpProxyVerticle.class, getWorkDeploymentOptions("proxy", 1));
|
||||
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 -> {
|
||||
otherHandleExecutor.close();
|
||||
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================");
|
||||
}
|
||||
|
||||
private static void runShutdownTasks(String stage, List<Runnable> tasks) {
|
||||
if (tasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
LOGGER.info("Running {} shutdown tasks: {}", stage, tasks.size());
|
||||
for (Runnable task : tasks) {
|
||||
try {
|
||||
task.run();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Shutdown task failed at stage {}", stage, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +276,42 @@ public final class Deploy {
|
||||
var t1 = ((double) (System.currentTimeMillis() - startTime)) / 1000;
|
||||
var t2 = ((double) System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
|
||||
LOGGER.info("web服务启动成功 -> 用时: {}s, jvm启动用时: {}s", t1, t2);
|
||||
|
||||
// 检查是否处于安装引导模式(数据库未配置)
|
||||
Object installMode = VertxHolder.getVertxInstance().sharedData()
|
||||
.getLocalMap(LOCAL).get("installMode");
|
||||
if (Boolean.TRUE.equals(installMode)) {
|
||||
LOGGER.info("系统处于安装引导模式,等待用户完成数据库配置后再启动后置初始化...");
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常模式:部署 PostExecVerticle 执行 AppRun 实现
|
||||
deployPostExec();
|
||||
}
|
||||
|
||||
/**
|
||||
* 部署 PostExecVerticle(执行所有 AppRun 实现)
|
||||
* 安装引导完成后也可手动调用此方法触发后置初始化
|
||||
*/
|
||||
public void deployPostExec() {
|
||||
var vertx = VertxHolder.getVertxInstance();
|
||||
var postExecFuture = vertx.deployVerticle(PostExecVerticle.class, getWorkDeploymentOptions("postExec", 2));
|
||||
postExecFuture.onSuccess(id -> {
|
||||
LOGGER.info("PostExecVerticle 部署成功,AppRun 实现执行完成");
|
||||
}).onFailure(e -> {
|
||||
LOGGER.error("PostExecVerticle 部署失败", e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新部署 ServiceVerticle,重新注册因 DB 未就绪而失败的服务到 EventBus
|
||||
* 安装引导完成、DB 初始化后调用
|
||||
*/
|
||||
public void redeployServices() {
|
||||
var vertx = VertxHolder.getVertxInstance();
|
||||
vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"))
|
||||
.onSuccess(id -> LOGGER.info("ServiceVerticle 重新部署成功,DB 相关服务已注册"))
|
||||
.onFailure(e -> LOGGER.error("ServiceVerticle 重新部署失败", e));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.lang.annotation.*;
|
||||
public @interface HandleSortFilter {
|
||||
/**
|
||||
* 注册顺序,数字越大越先注册<br>
|
||||
* 前置拦截器会先执行后注册即数字小的, 后置拦截器会先执行先注册的即数字大的<br>
|
||||
* 值<0时会过滤掉该处理器
|
||||
*/
|
||||
int value() default 0;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.qaiu.vx.core.base;
|
||||
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
public interface AppRun {
|
||||
|
||||
/**
|
||||
* 执行方法
|
||||
* @param config 启动配置文件
|
||||
*/
|
||||
void execute(JsonObject config);
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
package cn.qaiu.vx.core.base;
|
||||
|
||||
import cn.qaiu.vx.core.annotaions.HandleSortFilter;
|
||||
import cn.qaiu.vx.core.interceptor.AfterInterceptor;
|
||||
import cn.qaiu.vx.core.model.JsonResult;
|
||||
import cn.qaiu.vx.core.util.CommonUtil;
|
||||
import cn.qaiu.vx.core.util.ReflectionUtil;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import org.reflections.Reflections;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.qaiu.vx.core.util.ResponseUtil.*;
|
||||
|
||||
@@ -22,9 +28,10 @@ public interface BaseHttpApi {
|
||||
|
||||
// 需要扫描注册的Router路径
|
||||
Reflections reflections = ReflectionUtil.getReflections();
|
||||
Logger LOGGER = LoggerFactory.getLogger(BaseHttpApi.class);
|
||||
|
||||
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
|
||||
if (!ctx.response().ended()) {
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireJsonObjectResponse(ctx, jsonObject);
|
||||
}
|
||||
handleAfterInterceptor(ctx, jsonObject);
|
||||
@@ -32,31 +39,93 @@ public interface BaseHttpApi {
|
||||
|
||||
|
||||
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult) {
|
||||
if (!ctx.response().ended()) {
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireJsonResultResponse(ctx, jsonResult);
|
||||
}
|
||||
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
|
||||
}
|
||||
|
||||
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireJsonObjectResponse(ctx, jsonObject, statusCode);
|
||||
}
|
||||
handleAfterInterceptor(ctx, jsonObject);
|
||||
}
|
||||
|
||||
|
||||
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireJsonResultResponse(ctx, jsonResult, statusCode);
|
||||
}
|
||||
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
|
||||
}
|
||||
|
||||
default Set<AfterInterceptor> getAfterInterceptor() {
|
||||
return AfterInterceptorHolder.INSTANCES;
|
||||
}
|
||||
|
||||
Set<Class<? extends AfterInterceptor>> afterInterceptorClassSet =
|
||||
reflections.getSubTypesOf(AfterInterceptor.class);
|
||||
if (afterInterceptorClassSet == null) {
|
||||
return null;
|
||||
class AfterInterceptorHolder {
|
||||
private static final Set<AfterInterceptor> INSTANCES = loadAfterInterceptors();
|
||||
|
||||
private static Set<AfterInterceptor> loadAfterInterceptors() {
|
||||
Set<Class<? extends AfterInterceptor>> afterInterceptorClassSet =
|
||||
reflections.getSubTypesOf(AfterInterceptor.class);
|
||||
if (afterInterceptorClassSet == null || afterInterceptorClassSet.isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return afterInterceptorClassSet.stream()
|
||||
.filter(AfterInterceptorHolder::isEnabled)
|
||||
.sorted(AfterInterceptorHolder::compareOrder)
|
||||
.map(AfterInterceptorHolder::newInterceptor)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.collectingAndThen(
|
||||
Collectors.toCollection(LinkedHashSet::new),
|
||||
Collections::unmodifiableSet));
|
||||
}
|
||||
|
||||
private static boolean isEnabled(Class<? extends AfterInterceptor> clazz) {
|
||||
HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class);
|
||||
return sort == null || sort.value() >= 0;
|
||||
}
|
||||
|
||||
private static int compareOrder(Class<? extends AfterInterceptor> left, Class<? extends AfterInterceptor> right) {
|
||||
return Integer.compare(order(left), order(right));
|
||||
}
|
||||
|
||||
private static int order(Class<? extends AfterInterceptor> clazz) {
|
||||
HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class);
|
||||
return sort == null ? 0 : sort.value();
|
||||
}
|
||||
|
||||
private static AfterInterceptor newInterceptor(Class<? extends AfterInterceptor> clazz) {
|
||||
try {
|
||||
return ReflectionUtil.newWithNoParam(clazz);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("AfterInterceptor 初始化失败,已跳过: {}", clazz.getName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return CommonUtil.sortClassSet(afterInterceptorClassSet);
|
||||
}
|
||||
|
||||
default void handleAfterInterceptor(RoutingContext ctx, JsonObject jsonObject) {
|
||||
Set<AfterInterceptor> afterInterceptor = getAfterInterceptor();
|
||||
if (afterInterceptor != null) {
|
||||
afterInterceptor.forEach(ai -> ai.handle(ctx, jsonObject));
|
||||
if (ctx.response().closed()) {
|
||||
return;
|
||||
}
|
||||
if (!ctx.response().ended()) {
|
||||
Set<AfterInterceptor> afterInterceptor = getAfterInterceptor();
|
||||
afterInterceptor.forEach(ai -> {
|
||||
try {
|
||||
ai.handle(ctx, jsonObject);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("AfterInterceptor 执行失败: {}", ai.getClass().getName(), e);
|
||||
}
|
||||
});
|
||||
if (!isResponseDone(ctx)) {
|
||||
fireTextResponse(ctx, "handleAfterInterceptor: response not end");
|
||||
}
|
||||
}
|
||||
|
||||
default boolean isResponseDone(RoutingContext ctx) {
|
||||
return ctx.response().ended() || ctx.response().closed();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -36,6 +34,7 @@ import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -76,15 +75,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));
|
||||
@@ -98,17 +97,9 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
mainRouter.route().handler(CorsHandler.create().addRelativeOrigin(".*").allowCredentials(true).allowedMethods(httpMethods));
|
||||
|
||||
// 配置文件上传路径
|
||||
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);
|
||||
mainRouter.route().handler(BodyHandler.create()
|
||||
.setUploadsDirectory("uploads")
|
||||
.setBodyLimit(2L * 1024 * 1024));
|
||||
|
||||
// 拦截器
|
||||
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
|
||||
@@ -139,8 +130,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;
|
||||
}
|
||||
@@ -186,13 +178,14 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
||||
route.handler(TimeoutHandler.create(SharedDataUtil.getCustomConfig().getInteger(ROUTE_TIME_OUT)));
|
||||
route.handler(ResponseTimeHandler.create());
|
||||
route.handler(ctx -> handlerMethod(instance, method, ctx)).failureHandler(ctx -> {
|
||||
if (ctx.response().ended()) return;
|
||||
if (isResponseDone(ctx)) 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 +203,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 +239,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 +308,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 +370,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 +395,45 @@ 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 JsonObject) {
|
||||
if (data instanceof JsonResult jsonResult) {
|
||||
doFireJsonResultResponse(ctx, (JsonResult<?>) data, jsonResult.getCode());
|
||||
} else 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);
|
||||
Future<?> responseFuture = (Future<?>) data;
|
||||
AtomicReference<RoutingContext> ctxRef = new AtomicReference<>(ctx);
|
||||
ctx.addEndHandler(v -> ctxRef.set(null));
|
||||
responseFuture.onComplete(ar -> {
|
||||
RoutingContext responseCtx = ctxRef.getAndSet(null);
|
||||
if (responseCtx == null || isResponseDone(responseCtx)) {
|
||||
return;
|
||||
}
|
||||
if (res instanceof JsonObject) {
|
||||
doFireJsonObjectResponse(ctx, ((JsonObject) res));
|
||||
} else if (res != null) {
|
||||
doFireJsonResultResponse(ctx, JsonResult.data(res));
|
||||
if (ar.succeeded()) {
|
||||
Object res = ar.result();
|
||||
if (res instanceof JsonResult jsonResult) {
|
||||
doFireJsonResultResponse(responseCtx, jsonResult, jsonResult.getCode());
|
||||
} else if (res instanceof JsonObject) {
|
||||
doFireJsonObjectResponse(responseCtx, ((JsonObject) res));
|
||||
} else if (res != null) {
|
||||
doFireJsonResultResponse(responseCtx, JsonResult.data(res));
|
||||
} else {
|
||||
doFireJsonResultResponse(responseCtx, JsonResult.data(null));
|
||||
}
|
||||
} else {
|
||||
handleAfterInterceptor(ctx, null);
|
||||
Throwable e = ar.cause();
|
||||
LOGGER.error("请求处理失败", e);
|
||||
String msg = e != null && e.getMessage() != null ? e.getMessage() : "服务器内部错误";
|
||||
doFireJsonResultResponse(responseCtx, JsonResult.error(msg), 500);
|
||||
}
|
||||
|
||||
}).onFailure(e -> doFireJsonResultResponse(ctx, JsonResult.error(e.getMessage())));
|
||||
});
|
||||
} 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,9 +13,12 @@ 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;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -28,6 +31,16 @@ public class CommonUtil {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(CommonUtil.class);
|
||||
|
||||
/** 正则表达式缓存,避免每次调用重新编译 */
|
||||
private static final ConcurrentHashMap<String, Pattern> PATTERN_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取预编译的 Pattern(带缓存)
|
||||
*/
|
||||
private static Pattern getCachedPattern(String regex) {
|
||||
return PATTERN_CACHE.computeIfAbsent(regex, Pattern::compile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配正则list
|
||||
*
|
||||
@@ -38,7 +51,7 @@ public class CommonUtil {
|
||||
public static boolean matchRegList(List<?> regList, String destStr) {
|
||||
// 判断是否忽略
|
||||
for (Object ignores : regList) {
|
||||
if (destStr.matches(ignores.toString())) {
|
||||
if (getCachedPattern(ignores.toString()).matcher(destStr).matches()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -117,7 +130,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 +151,7 @@ public class CommonUtil {
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}).collect(Collectors.toSet());
|
||||
}).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private static String appVersion;
|
||||
@@ -146,13 +159,15 @@ public class CommonUtil {
|
||||
public static String getAppVersion() {
|
||||
if (null == appVersion) {
|
||||
Properties properties = new Properties();
|
||||
try {
|
||||
properties.load(CommonUtil.class.getClassLoader().getResourceAsStream("app.properties"));
|
||||
if (!properties.isEmpty()) {
|
||||
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
|
||||
try (var is = CommonUtil.class.getClassLoader().getResourceAsStream("app.properties")) {
|
||||
if (is != null) {
|
||||
properties.load(is);
|
||||
if (!properties.isEmpty()) {
|
||||
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("读取app.properties失败", e);
|
||||
}
|
||||
}
|
||||
return appVersion;
|
||||
|
||||
@@ -4,9 +4,15 @@ import io.vertx.config.ConfigRetriever;
|
||||
import io.vertx.config.ConfigRetrieverOptions;
|
||||
import io.vertx.config.ConfigStoreOptions;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* 异步读取配置工具类
|
||||
* <br>Create date 2021/9/2 1:23
|
||||
@@ -24,7 +30,29 @@ public class ConfigUtil {
|
||||
* @return JsonObject的Future
|
||||
*/
|
||||
public static Future<JsonObject> readConfig(String format, String path, Vertx vertx) {
|
||||
// 读取yml配置
|
||||
// 支持 classpath: 前缀从类路径读取,否则从文件系统读取
|
||||
if (path != null && path.startsWith("classpath:")) {
|
||||
String resource = path.substring("classpath:".length());
|
||||
// 使用 executeBlocking(Callable) 直接返回 Future<JsonObject>
|
||||
return vertx.executeBlocking(() -> {
|
||||
InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
|
||||
if (is == null) {
|
||||
throw new RuntimeException("classpath resource not found: " + resource);
|
||||
}
|
||||
try (InputStream in = is) {
|
||||
byte[] bytes = in.readAllBytes();
|
||||
String content = new String(bytes, StandardCharsets.UTF_8);
|
||||
if ("json".equalsIgnoreCase(format)) {
|
||||
return new JsonObject(content);
|
||||
} else {
|
||||
throw new RuntimeException("unsupported classpath format: " + format);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Promise<JsonObject> promise = Promise.promise();
|
||||
|
||||
ConfigStoreOptions store = new ConfigStoreOptions()
|
||||
.setType("file")
|
||||
.setFormat(format)
|
||||
@@ -33,10 +61,45 @@ public class ConfigUtil {
|
||||
ConfigRetriever retriever = ConfigRetriever
|
||||
.create(vertx, new ConfigRetrieverOptions().addStore(store));
|
||||
|
||||
return retriever.getConfig();
|
||||
// 异步获取配置
|
||||
// 成功直接完成 promise
|
||||
retriever.getConfig()
|
||||
.onSuccess(config -> {
|
||||
promise.complete(config);
|
||||
retriever.close();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
retriever.close();
|
||||
// 读取失败时,尝试从 resources/ 子目录读取(兼容 Docker 卷挂载场景)
|
||||
String resourcesPath = "resources/" + path;
|
||||
if (!path.startsWith("resources/") && Files.exists(Path.of(resourcesPath))) {
|
||||
ConfigStoreOptions fallbackStore = new ConfigStoreOptions()
|
||||
.setType("file")
|
||||
.setFormat(format)
|
||||
.setConfig(new JsonObject().put("path", resourcesPath));
|
||||
ConfigRetriever fallbackRetriever = ConfigRetriever
|
||||
.create(vertx, new ConfigRetrieverOptions().addStore(fallbackStore));
|
||||
fallbackRetriever.getConfig()
|
||||
.onSuccess(config -> {
|
||||
promise.complete(config);
|
||||
fallbackRetriever.close();
|
||||
})
|
||||
.onFailure(e2 -> {
|
||||
promise.fail(new RuntimeException(
|
||||
"读取配置文件失败: " + path + " (也尝试了 " + resourcesPath + ")", e2));
|
||||
fallbackRetriever.close();
|
||||
});
|
||||
} else {
|
||||
promise.fail(new RuntimeException(
|
||||
"读取配置文件失败: " + path, err));
|
||||
}
|
||||
});
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 异步读取Yaml配置文件
|
||||
*
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.qaiu.vx.core.util;
|
||||
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public class FutureUtils {
|
||||
|
||||
/** 默认同步等待超时时间(秒) */
|
||||
private static final long DEFAULT_TIMEOUT_SECONDS = 120;
|
||||
|
||||
public static <T> T getResult(Future<T> future) {
|
||||
try {
|
||||
return future.toCompletionStage().toCompletableFuture()
|
||||
.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("线程被中断", e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new RuntimeException("等待Future超时(" + DEFAULT_TIMEOUT_SECONDS + "秒)", e);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
throw new RuntimeException(cause != null ? cause : e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> T getResult(Promise<T> promise) {
|
||||
try {
|
||||
return promise.future().toCompletionStage().toCompletableFuture()
|
||||
.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("线程被中断", e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new RuntimeException("等待Promise超时(" + DEFAULT_TIMEOUT_SECONDS + "秒)", e);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
throw new RuntimeException(cause != null ? cause : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import java.lang.reflect.Method;
|
||||
import java.net.URL;
|
||||
import java.text.ParseException;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
|
||||
|
||||
@@ -36,6 +40,16 @@ 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<>();
|
||||
|
||||
// 预编译的类型匹配正则,避免每次请求重新编译
|
||||
private static final Pattern BASIC_TYPE_PATTERN = Pattern.compile(
|
||||
"^java\\.lang\\.((Boolean)|(Character)|(Byte)|(Short)|(Integer)|(Long)|(Float)|(Double)|(String))$");
|
||||
private static final Pattern BASIC_TYPE_ARRAY_PATTERN = Pattern.compile(
|
||||
"^(boolean|char|byte|short|int|long|float|double|String)\\[]$");
|
||||
|
||||
/**
|
||||
* 以默认配置的基础包路径获取反射器
|
||||
@@ -47,52 +61,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 +140,7 @@ public final class ReflectionUtil {
|
||||
parameterTypes[j - k]));
|
||||
}
|
||||
} catch (NotFoundException e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("获取方法参数失败", e);
|
||||
}
|
||||
return paramMap;
|
||||
}
|
||||
@@ -185,7 +195,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 +227,7 @@ public final class ReflectionUtil {
|
||||
}
|
||||
return arr;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("数组类型转换失败: {}", value, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -231,8 +241,7 @@ public final class ReflectionUtil {
|
||||
if (ctClass.isPrimitive() || "java.util.Date".equals(ctClass.getName())) {
|
||||
return true;
|
||||
}
|
||||
return ctClass.getName().matches("^java\\.lang\\.((Boolean)|(Character)|(Byte)|(Short)|(Integer)|(Long)|" +
|
||||
"(Float)|(Double)|(String))$");
|
||||
return BASIC_TYPE_PATTERN.matcher(ctClass.getName()).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +252,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 BASIC_TYPE_ARRAY_PATTERN.matcher(ctClass.getName()).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,24 +12,46 @@ import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
|
||||
public class ResponseUtil {
|
||||
|
||||
public static void redirect(HttpServerResponse response, String url) {
|
||||
if (response.ended() || response.closed()) {
|
||||
return;
|
||||
}
|
||||
response.putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.putHeader("Referrer-Policy", "no-referrer")
|
||||
.putHeader(HttpHeaders.LOCATION, url).setStatusCode(302).end();
|
||||
}
|
||||
|
||||
public static void redirect(HttpServerResponse response, String url, Promise<?> promise) {
|
||||
redirect(response, url);
|
||||
promise.complete();
|
||||
try {
|
||||
redirect(response, url);
|
||||
promise.tryComplete();
|
||||
} catch (Throwable t) {
|
||||
promise.tryFail(t);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (ctx.response().ended() || ctx.response().closed()) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (ctx.ended() || ctx.closed()) {
|
||||
return;
|
||||
}
|
||||
ctx.putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.setStatusCode(200)
|
||||
.setStatusCode(statusCode)
|
||||
.end(jsonObject.encode());
|
||||
}
|
||||
|
||||
@@ -37,15 +59,25 @@ 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());
|
||||
}
|
||||
|
||||
public static void fireTextResponse(RoutingContext ctx, String text) {
|
||||
if (ctx.response().ended() || ctx.response().closed()) {
|
||||
return;
|
||||
}
|
||||
ctx.response().putHeader(CONTENT_TYPE, "text/html; charset=utf-8").end(text);
|
||||
}
|
||||
|
||||
public static void sendError(RoutingContext ctx, int statusCode) {
|
||||
if (ctx.response().ended() || ctx.response().closed()) {
|
||||
return;
|
||||
}
|
||||
ctx.response().setStatusCode(statusCode).end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package cn.qaiu.vx.core.util;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.shareddata.LocalMap;
|
||||
import io.vertx.core.shareddata.SharedData;
|
||||
|
||||
/**
|
||||
* vertx 共享数据
|
||||
@@ -13,10 +12,8 @@ import io.vertx.core.shareddata.SharedData;
|
||||
*/
|
||||
public class SharedDataUtil {
|
||||
|
||||
private static final SharedData sharedData = VertxHolder.getVertxInstance().sharedData();
|
||||
|
||||
public static SharedData shareData() {
|
||||
return sharedData;
|
||||
public static io.vertx.core.shareddata.SharedData shareData() {
|
||||
return VertxHolder.getVertxInstance().sharedData();
|
||||
}
|
||||
|
||||
public static LocalMap<String, Object> getLocalMap(String key) {
|
||||
@@ -24,7 +21,7 @@ public class SharedDataUtil {
|
||||
}
|
||||
|
||||
public static <T> LocalMap<String, T> getLocalMapWithCast(String key) {
|
||||
return sharedData.getLocalMap(key);
|
||||
return shareData().getLocalMap(key);
|
||||
}
|
||||
|
||||
public static JsonObject getJsonConfig(String key) {
|
||||
|
||||
@@ -1,57 +1,101 @@
|
||||
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.Future;
|
||||
import io.vertx.core.Promise;
|
||||
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 HttpServer httpServer;
|
||||
private volatile boolean stopping = false;
|
||||
|
||||
private JsonObject proxyPreConf;
|
||||
private JsonObject proxyServerConf;
|
||||
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
ProxyOptions proxyOptions = new ProxyOptions().setHost("127.0.0.1").setPort(7890);
|
||||
public void start(io.vertx.core.Promise<Void> startPromise) {
|
||||
stopping = false;
|
||||
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));
|
||||
HttpClientOptions httpClientOptions = new HttpClientOptions()
|
||||
.setMaxPoolSize(64)
|
||||
.setMaxWaitQueueSize(256)
|
||||
.setConnectTimeout(15000)
|
||||
.setIdleTimeout(60)
|
||||
.setKeepAlive(true);
|
||||
if (proxyOptions != null) {
|
||||
httpClientOptions.setProxyOptions(proxyOptions);
|
||||
}
|
||||
httpClient = vertx.createHttpClient(httpClientOptions);
|
||||
|
||||
// 创建并启动 HTTP 代理服务器,监听指定端口
|
||||
HttpServer server = vertx.createHttpServer(new HttpServerOptions().setClientAuth(ClientAuth.REQUIRED));
|
||||
server.requestHandler(this::handleClientRequest);
|
||||
HttpServerOptions httpServerOptions = new HttpServerOptions();
|
||||
if (proxyServerConf.containsKey("username") &&
|
||||
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
|
||||
httpServerOptions.setClientAuth(ClientAuth.REQUIRED);
|
||||
}
|
||||
|
||||
httpServer = vertx.createHttpServer(httpServerOptions);
|
||||
httpServer.requestHandler(this::handleClientRequest);
|
||||
|
||||
// 初始化 NetClient,用于在 CONNECT 请求中建立 TCP 连接隧道
|
||||
netClient = vertx.createNetClient(new NetClientOptions()
|
||||
.setProxyOptions(proxyOptions)
|
||||
NetClientOptions netClientOptions = new NetClientOptions();
|
||||
|
||||
if (proxyOptions != null) {
|
||||
netClientOptions.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());
|
||||
}
|
||||
});
|
||||
httpServer.listen(serverPort)
|
||||
.onSuccess(res -> {
|
||||
LOGGER.info("HTTP Proxy server started on port {}", serverPort);
|
||||
startPromise.complete();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("Failed to start HTTP Proxy server: " + err.getMessage(), err);
|
||||
closeClients().onComplete(close -> startPromise.fail(err));
|
||||
});
|
||||
}
|
||||
|
||||
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
|
||||
private void handleConnectRequest(HttpServerRequest clientRequest) {
|
||||
String[] uriParts = clientRequest.uri().split(":");
|
||||
if (uriParts.length != 2) {
|
||||
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid URI format");
|
||||
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid URI format");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,55 +105,85 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
||||
try {
|
||||
targetPort = Integer.parseInt(uriParts[1]);
|
||||
} catch (NumberFormatException e) {
|
||||
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid port");
|
||||
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid port");
|
||||
return;
|
||||
}
|
||||
clientRequest.pause();
|
||||
// 通过 NetClient 连接目标服务器并创建隧道
|
||||
netClient.connect(targetPort, targetHost, connectionAttempt -> {
|
||||
if (connectionAttempt.succeeded()) {
|
||||
NetSocket targetSocket = connectionAttempt.result();
|
||||
try {
|
||||
netClient.connect(targetPort, targetHost)
|
||||
.onSuccess(targetSocket -> {
|
||||
// Upgrade client connection to NetSocket and implement bidirectional data flow
|
||||
clientRequest.toNetSocket()
|
||||
.onSuccess(clientSocket -> {
|
||||
clientSocket.pipeTo(targetSocket)
|
||||
.onFailure(err -> {
|
||||
LOGGER.debug("CONNECT client -> target pipe closed", err);
|
||||
closeTunnelSockets(clientSocket, targetSocket);
|
||||
});
|
||||
targetSocket.pipeTo(clientSocket)
|
||||
.onFailure(err -> {
|
||||
LOGGER.debug("CONNECT target -> client pipe closed", err);
|
||||
closeTunnelSockets(clientSocket, targetSocket);
|
||||
});
|
||||
|
||||
// 升级客户端连接到 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");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
System.err.println("Failed to connect to target: " + connectionAttempt.cause().getMessage());
|
||||
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
|
||||
}
|
||||
});
|
||||
// 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();
|
||||
failClientRequestAndClose(clientRequest, 500, "Internal Server Error");
|
||||
});
|
||||
})
|
||||
.onFailure(connectionAttempt -> {
|
||||
LOGGER.warn("Failed to connect to target: {}", connectionAttempt.getMessage());
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to connect to target");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("CONNECT 请求创建失败", e);
|
||||
failClientRequestAndClose(clientRequest, 502, "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();
|
||||
if (stopping) {
|
||||
failClientResponse(clientRequest.response(), 503, "Service Unavailable");
|
||||
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
|
||||
// 打印来源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) {
|
||||
failClientResponse(clientRequest.response(), 403, null);
|
||||
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");
|
||||
failClientResponse(clientRequest.response(), 403, null);
|
||||
return;
|
||||
}
|
||||
if (split.length <= 1) {
|
||||
LOGGER.warn("Proxy-Authorization header format invalid: missing username:password separator");
|
||||
failClientResponse(clientRequest.response(), 403, null);
|
||||
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]);
|
||||
failClientResponse(clientRequest.response(), 403, null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (clientRequest.method() == HttpMethod.CONNECT) {
|
||||
// 处理 CONNECT 请求
|
||||
handleConnectRequest(clientRequest);
|
||||
@@ -124,61 +198,222 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
||||
// 获取目标主机
|
||||
String hostHeader = clientRequest.getHeader("Host");
|
||||
if (hostHeader == null) {
|
||||
clientRequest.response().setStatusCode(400).end("Host header is missing");
|
||||
failClientResponse(clientRequest.response(), 400, "Host header is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
String targetHost = hostHeader.split(":")[0];
|
||||
int targetPort = 80; // 默认为 HTTP 的端口
|
||||
clientRequest.pause(); // 暂停客户端请求的读取,避免数据丢失
|
||||
HostAndPort target;
|
||||
try {
|
||||
target = parseHostHeader(hostHeader);
|
||||
} catch (IllegalArgumentException e) {
|
||||
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid Host header");
|
||||
return;
|
||||
}
|
||||
String targetHost = target.host();
|
||||
int targetPort = extractPortFromUrl(clientRequest.uri(), target.port()); // 默认为 HTTP 的端口
|
||||
if (targetPort <= 0) {
|
||||
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid target port");
|
||||
return;
|
||||
}
|
||||
clientRequest.pause(); // 暂停客户端请求的读取,等上游请求创建完成
|
||||
|
||||
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
|
||||
.onSuccess(request -> {
|
||||
clientRequest.resume(); // 恢复客户端请求的读取
|
||||
try {
|
||||
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
|
||||
.onSuccess(request -> {
|
||||
// 逐个设置请求头
|
||||
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
|
||||
|
||||
// 逐个设置请求头
|
||||
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
|
||||
request.response()
|
||||
.onSuccess(response -> {
|
||||
HttpServerResponse clientResponse = clientRequest.response();
|
||||
if (clientResponse.ended() || clientResponse.closed()) {
|
||||
response.resume();
|
||||
return;
|
||||
}
|
||||
clientResponse.setStatusCode(response.statusCode());
|
||||
clientResponse.headers().setAll(response.headers());
|
||||
response.pipeTo(clientResponse)
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("HTTP代理响应转发失败", err);
|
||||
try {
|
||||
response.request().reset();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("HTTP代理上游响应已关闭", e);
|
||||
}
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
|
||||
});
|
||||
})
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("HTTP代理响应失败", err);
|
||||
try {
|
||||
request.reset();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("HTTP代理上游请求已关闭", e);
|
||||
}
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
|
||||
});
|
||||
|
||||
// 将客户端请求的 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");
|
||||
}
|
||||
}));
|
||||
})
|
||||
.onFailure(err -> {
|
||||
err.printStackTrace();
|
||||
clientRequest.response().setStatusCode(502).end("Bad Gateway: Request failed");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
// 停止 HTTP 客户端以释放资源
|
||||
if (httpClient != null) {
|
||||
httpClient.close();
|
||||
clientRequest.pipeTo(request)
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("HTTP代理请求转发失败", err);
|
||||
try {
|
||||
request.reset();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("HTTP代理上游请求已关闭", e);
|
||||
}
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
|
||||
});
|
||||
clientRequest.resume();
|
||||
})
|
||||
.onFailure(err -> {
|
||||
LOGGER.error("HTTP请求失败", err);
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Request failed");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("HTTP请求创建失败", e);
|
||||
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Request failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
private void failClientResponse(HttpServerResponse response, String message) {
|
||||
failClientResponse(response, 502, message);
|
||||
}
|
||||
|
||||
private void failClientResponse(HttpServerResponse response, int statusCode, String message) {
|
||||
if (response.ended() || response.closed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!response.headWritten()) {
|
||||
response.setStatusCode(statusCode);
|
||||
if (message == null) {
|
||||
response.end();
|
||||
} else {
|
||||
response.end(message);
|
||||
}
|
||||
} else {
|
||||
response.reset();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("客户端响应已关闭,忽略代理错误响应", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void failClientRequestAndClose(HttpServerRequest request, int statusCode, String message) {
|
||||
HttpServerResponse response = request.response();
|
||||
if (response.ended() || response.closed()) {
|
||||
closeClientConnection(request);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!response.headWritten()) {
|
||||
response.setStatusCode(statusCode);
|
||||
Future<Void> endFuture = message == null ? response.end() : response.end(message);
|
||||
endFuture.onComplete(v -> closeClientConnection(request));
|
||||
} else {
|
||||
response.reset();
|
||||
closeClientConnection(request);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("客户端响应已关闭,关闭代理连接", e);
|
||||
closeClientConnection(request);
|
||||
}
|
||||
}
|
||||
|
||||
private void closeClientConnection(HttpServerRequest request) {
|
||||
try {
|
||||
request.connection().close();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("关闭客户端代理连接失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void closeTunnelSockets(NetSocket clientSocket, NetSocket targetSocket) {
|
||||
try {
|
||||
clientSocket.close();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("关闭CONNECT客户端socket失败", e);
|
||||
}
|
||||
try {
|
||||
targetSocket.close();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("关闭CONNECT目标socket失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从 URL 中提取端口号
|
||||
*
|
||||
* @param urlString URL 字符串
|
||||
* @return 提取的端口号,如果没有指定端口,则返回默认端口
|
||||
*/
|
||||
public static int extractPortFromUrl(String urlString) {
|
||||
return extractPortFromUrl(urlString, 80);
|
||||
}
|
||||
|
||||
public static int extractPortFromUrl(String urlString, int defaultPort) {
|
||||
try {
|
||||
URI uri = new URI(urlString);
|
||||
int port = uri.getPort();
|
||||
// 如果 URL 没有指定端口,使用默认端口
|
||||
if (port == -1) {
|
||||
if ("https".equalsIgnoreCase(uri.getScheme())) {
|
||||
port = 443; // HTTPS 默认端口
|
||||
} else {
|
||||
port = defaultPort; // HTTP 默认端口
|
||||
}
|
||||
}
|
||||
return port;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("提取端口失败: {}", urlString, e);
|
||||
// 出现异常时返回 -1,表示提取失败
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private HostAndPort parseHostHeader(String hostHeader) {
|
||||
if (hostHeader.startsWith("[")) {
|
||||
int end = hostHeader.indexOf(']');
|
||||
if (end > 0) {
|
||||
String host = hostHeader.substring(1, end);
|
||||
int port = 80;
|
||||
if (hostHeader.length() > end + 2 && hostHeader.charAt(end + 1) == ':') {
|
||||
port = Integer.parseInt(hostHeader.substring(end + 2));
|
||||
}
|
||||
return new HostAndPort(host, port);
|
||||
}
|
||||
}
|
||||
int lastColon = hostHeader.lastIndexOf(':');
|
||||
if (lastColon > 0 && hostHeader.indexOf(':') == lastColon) {
|
||||
return new HostAndPort(hostHeader.substring(0, lastColon), Integer.parseInt(hostHeader.substring(lastColon + 1)));
|
||||
}
|
||||
return new HostAndPort(hostHeader, 80);
|
||||
}
|
||||
|
||||
private record HostAndPort(String host, int port) {
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void stop(Promise<Void> stopPromise) {
|
||||
stopping = true;
|
||||
Future<Void> serverClose = httpServer == null ? Future.succeededFuture() : httpServer.close();
|
||||
serverClose.onComplete(serverResult -> closeClients().onComplete(clientResult -> {
|
||||
if (serverResult.failed()) {
|
||||
stopPromise.fail(serverResult.cause());
|
||||
} else if (clientResult.failed()) {
|
||||
stopPromise.fail(clientResult.cause());
|
||||
} else {
|
||||
stopPromise.complete();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private Future<Void> closeClients() {
|
||||
Future<Void> httpClientClose = httpClient == null ? Future.succeededFuture() : httpClient.close();
|
||||
Future<Void> netClientClose = netClient == null ? Future.succeededFuture() : netClient.close();
|
||||
return Future.all(httpClientClose, netClientClose).mapEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,26 @@ package cn.qaiu.vx.core.verticle;
|
||||
import cn.qaiu.vx.core.util.*;
|
||||
import io.vertx.core.AbstractVerticle;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Handler;
|
||||
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.http.HttpServerResponse;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.net.PemKeyCertOptions;
|
||||
import io.vertx.ext.web.Route;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
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 +30,18 @@ import org.slf4j.LoggerFactory;
|
||||
import java.io.File;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
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>
|
||||
@@ -36,39 +50,201 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ReverseProxyVerticle.class);
|
||||
|
||||
private static final String PATH_PROXY_CONFIG = SharedDataUtil
|
||||
.getJsonConfig(ConfigConstant.GLOBAL_CONFIG)
|
||||
.getString("proxyConf");
|
||||
private static final Future<JsonObject> CONFIG = ConfigUtil.readYamlConfig(PATH_PROXY_CONFIG);
|
||||
private static final String DEFAULT_PATH_404 = "webroot/err/page404.html";
|
||||
|
||||
private static String serverName = "Vert.x-proxy-server"; //Server name in Http response header
|
||||
|
||||
public static String REROUTE_PATH_PREFIX = "/__rrvpspp"; //re_route_vert_proxy_server_path_prefix 硬编码
|
||||
|
||||
/**
|
||||
* 【优化】HttpClient连接池,按host:port缓存复用,避免每个请求都创建新连接
|
||||
*/
|
||||
private final Map<String, HttpClientEntry> httpClientPool = new ConcurrentHashMap<>();
|
||||
private final List<HttpServer> httpServers = new ArrayList<>();
|
||||
private volatile boolean stopping = false;
|
||||
|
||||
/**
|
||||
* 连接池条目。HttpProxy 会持有这里的 HttpClient 引用,不能在路由仍可用时关闭。
|
||||
*/
|
||||
private static class HttpClientEntry {
|
||||
final HttpClient client;
|
||||
|
||||
HttpClientEntry(HttpClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 【优化】高并发场景下的HttpClient配置
|
||||
*/
|
||||
private static final int MAX_POOL_SIZE = 32; // 最大连接池大小
|
||||
private static final int MAX_WAIT_QUEUE_SIZE = 128; // 最大等待队列大小
|
||||
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 = false; // 代理场景关闭管线化,避免慢响应堆积
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
CONFIG.onSuccess(this::handleProxyConfList);
|
||||
// createFileListener
|
||||
startPromise.complete();
|
||||
stopping = false;
|
||||
String pathProxyConfig = SharedDataUtil
|
||||
.getJsonConfig(ConfigConstant.GLOBAL_CONFIG)
|
||||
.getString("proxyConf");
|
||||
ConfigUtil.readYamlConfig(pathProxyConfig).onSuccess(config -> startProxyServers(config).onComplete(startPromise)).onFailure(e -> {
|
||||
LOGGER.info("web代理配置已禁用,当前仅支持API调用");
|
||||
startPromise.complete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 【优化】Verticle停止时清理HttpClient连接池
|
||||
*/
|
||||
@Override
|
||||
public void stop(Promise<Void> stopPromise) {
|
||||
stopping = true;
|
||||
LOGGER.info("Stopping ReverseProxyVerticle, closing {} servers and {} HttpClient connections...",
|
||||
httpServers.size(), httpClientPool.size());
|
||||
|
||||
List<Future<Void>> serverCloseFutures = new ArrayList<>();
|
||||
httpServers.forEach(server -> serverCloseFutures.add(server.close()));
|
||||
Future<Void> serverCloseFuture = serverCloseFutures.isEmpty()
|
||||
? Future.succeededFuture()
|
||||
: Future.all(serverCloseFutures).mapEmpty();
|
||||
|
||||
serverCloseFuture.onComplete(serverClose -> {
|
||||
List<Future<Void>> clientCloseFutures = new ArrayList<>();
|
||||
closeHttpClients(clientCloseFutures);
|
||||
Future<Void> clientCloseFuture = clientCloseFutures.isEmpty()
|
||||
? Future.succeededFuture()
|
||||
: Future.all(clientCloseFutures).mapEmpty();
|
||||
|
||||
clientCloseFuture.onComplete(clientClose -> {
|
||||
if (serverClose.succeeded()) {
|
||||
httpServers.clear();
|
||||
}
|
||||
if (clientClose.succeeded()) {
|
||||
httpClientPool.clear();
|
||||
}
|
||||
if (serverClose.failed()) {
|
||||
stopPromise.fail(serverClose.cause());
|
||||
} else if (clientClose.failed()) {
|
||||
stopPromise.fail(clientClose.cause());
|
||||
} else {
|
||||
stopPromise.complete();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void closeHttpClients(List<Future<Void>> closeFutures) {
|
||||
httpClientPool.values().forEach(entry -> {
|
||||
try {
|
||||
closeFutures.add(entry.client.close());
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Error closing HttpClient: {}", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 【优化】获取或创建HttpClient,实现连接池复用
|
||||
* @param host 目标主机
|
||||
* @param port 目标端口
|
||||
* @return HttpClient实例
|
||||
*/
|
||||
private HttpClient getOrCreateHttpClient(String host, int port) {
|
||||
String key = host + ":" + port;
|
||||
HttpClientEntry entry = 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(false) // 代理不解压,避免放大内存
|
||||
.setTcpKeepAlive(true) // TCP Keep-Alive
|
||||
.setTcpNoDelay(true) // 禁用Nagle算法,降低延迟
|
||||
.setTcpFastOpen(true) // 启用TCP Fast Open
|
||||
.setTcpQuickAck(true) // 启用TCP Quick ACK
|
||||
.setReuseAddress(true) // 允许地址重用
|
||||
.setReusePort(true); // 允许端口重用
|
||||
return new HttpClientEntry(vertx.createHttpClient(options));
|
||||
});
|
||||
return entry.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局可信上游代理 IP 集合(如 nginx),仅这些 IP 的 X-Forwarded-For 会被信任
|
||||
*/
|
||||
private Set<String> globalTrustedProxies = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 获取主配置文件
|
||||
*
|
||||
* @param config proxy config
|
||||
*/
|
||||
private void handleProxyConfList(JsonObject config) {
|
||||
private Future<Void> startProxyServers(JsonObject config) {
|
||||
serverName = config.getString("server-name");
|
||||
JsonArray proxyConfList = config.getJsonArray("proxy");
|
||||
if (proxyConfList != null) {
|
||||
proxyConfList.forEach(proxyConf -> {
|
||||
if (proxyConf instanceof JsonObject) {
|
||||
handleProxyConf((JsonObject) proxyConf);
|
||||
// 解析全局 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");
|
||||
List<Future<Void>> listenFutures = new ArrayList<>();
|
||||
if (proxyConfList != null) {
|
||||
proxyConfList.forEach(proxyConf -> {
|
||||
if (proxyConf instanceof JsonObject) {
|
||||
listenFutures.add(handleProxyConf((JsonObject) proxyConf));
|
||||
}
|
||||
});
|
||||
}
|
||||
return listenFutures.isEmpty() ? Future.succeededFuture() : Future.all(listenFutures).mapEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析真实客户端 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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +252,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
*
|
||||
* @param proxyConf 代理配置
|
||||
*/
|
||||
private void handleProxyConf(JsonObject proxyConf) {
|
||||
private Future<Void> handleProxyConf(JsonObject proxyConf) {
|
||||
// page404 path
|
||||
if (proxyConf.containsKey(
|
||||
|
||||
@@ -97,18 +273,31 @@ 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 -> {
|
||||
ctx.response().putHeader("Server", serverName);
|
||||
if (stopping) {
|
||||
sendProxyError(ctx, 503, "Service Unavailable");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if (!ctx.response().ended() && !ctx.response().closed()) {
|
||||
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,20 +306,34 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
}
|
||||
|
||||
// Send page404 page
|
||||
proxyRouter.errorHandler(404, ctx -> ctx.response().sendFile(proxyConf.getString("page404")));
|
||||
proxyRouter.errorHandler(404, ctx -> {
|
||||
sendNotFoundPage(ctx, proxyConf.getString("page404"));
|
||||
});
|
||||
proxyRouter.errorHandler(500, this::handleProxyFailure);
|
||||
|
||||
HttpServer server = getHttpsServer(proxyConf);
|
||||
server.requestHandler(proxyRouter);
|
||||
|
||||
Integer port = proxyConf.getInteger("listen");
|
||||
LOGGER.info("proxy server start on {} port", port);
|
||||
server.listen(port);
|
||||
return server.listen(port)
|
||||
.onSuccess(s -> httpServers.add(s))
|
||||
.onFailure(e -> LOGGER.error("proxy server start failed on {} port", port, e))
|
||||
.mapEmpty();
|
||||
}
|
||||
|
||||
private HttpServer getHttpsServer(JsonObject proxyConf) {
|
||||
HttpServerOptions httpServerOptions = new HttpServerOptions()
|
||||
.setCompressionSupported(true);
|
||||
|
||||
// 【优化】高并发服务器配置
|
||||
.setTcpKeepAlive(true) // TCP Keep-Alive
|
||||
.setTcpNoDelay(true) // 禁用Nagle算法
|
||||
.setCompressionSupported(true) // 启用压缩
|
||||
.setAcceptBacklog(1024) // 限制积压队列,避免小容器内存膨胀
|
||||
.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");
|
||||
|
||||
@@ -161,6 +364,67 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
||||
return vertx.createHttpServer(httpServerOptions);
|
||||
}
|
||||
|
||||
private void addProxyHandler(Route route, HttpProxy httpProxy) {
|
||||
Handler<RoutingContext> proxyHandler = ProxyHandler.create(httpProxy);
|
||||
route.handler(ctx -> {
|
||||
try {
|
||||
proxyHandler.handle(ctx);
|
||||
} catch (Throwable t) {
|
||||
LOGGER.error("反向代理处理异常", t);
|
||||
ctx.fail(t);
|
||||
}
|
||||
}).failureHandler(this::handleProxyFailure);
|
||||
}
|
||||
|
||||
private void handleProxyFailure(RoutingContext ctx) {
|
||||
Throwable failure = ctx.failure();
|
||||
if (failure != null) {
|
||||
LOGGER.error("反向代理路由失败", failure);
|
||||
}
|
||||
int statusCode = ctx.statusCode() > 0 ? ctx.statusCode() : 502;
|
||||
if (statusCode < 400) {
|
||||
statusCode = 502;
|
||||
}
|
||||
sendProxyError(ctx, statusCode, "Bad Gateway");
|
||||
}
|
||||
|
||||
private void sendNotFoundPage(RoutingContext ctx, String page404) {
|
||||
HttpServerResponse response = ctx.response();
|
||||
if (response.ended() || response.closed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (response.headWritten()) {
|
||||
response.reset();
|
||||
return;
|
||||
}
|
||||
response.sendFile(page404)
|
||||
.onFailure(e -> {
|
||||
LOGGER.warn("发送代理 404 页面失败: {}", page404, e);
|
||||
sendProxyError(ctx, 404, "404 not found");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("发送代理 404 页面异常: {}", page404, e);
|
||||
sendProxyError(ctx, 404, "404 not found");
|
||||
}
|
||||
}
|
||||
|
||||
private void sendProxyError(RoutingContext ctx, int statusCode, String message) {
|
||||
HttpServerResponse response = ctx.response();
|
||||
if (response.ended() || response.closed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!response.headWritten()) {
|
||||
response.setStatusCode(statusCode).end(message);
|
||||
} else {
|
||||
response.reset();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("代理响应已关闭,忽略错误响应", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理静态资源配置
|
||||
*
|
||||
@@ -184,7 +448,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 +460,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 +473,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 +513,64 @@ 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));
|
||||
addProxyHandler(route, 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();
|
||||
}
|
||||
});
|
||||
addProxyHandler(proxyRouter.route(originPath0 + "*"), 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) {
|
||||
|
||||
@@ -22,22 +22,22 @@ 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;
|
||||
private int port;
|
||||
private JsonObject globalConfig;
|
||||
|
||||
static {
|
||||
LOGGER.info(JacksonConfig.class.getSimpleName() + " >> ");
|
||||
JacksonConfig.nothing();
|
||||
LOGGER.info("To start listening to port {} ......", port);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Promise<Void> startPromise) {
|
||||
port = SharedDataUtil.getValueForServerConfig("port");
|
||||
globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
|
||||
LOGGER.info("To start listening to port {} ......", port);
|
||||
|
||||
// 端口是否占用
|
||||
if (CommonUtil.isPortUsing(port)) {
|
||||
throw new RuntimeException("Start fail: the '" + port + "' port is already in use...");
|
||||
@@ -49,9 +49,23 @@ 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()
|
||||
server.requestHandler(router).listen()
|
||||
.onSuccess(s -> startPromise.complete())
|
||||
.onFailure(e -> startPromise.fail(e.getCause()));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/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
|
||||
DEFAULT_JVM_OPTS="-Xmx${JVM_XMX:-512M} -Xss${JVM_XSS:-512k} -XX:MaxDirectMemorySize=${JVM_MAX_DIRECT_MEMORY:-256M} -DNFD_LOG_LEVEL=${NFD_LOG_LEVEL:-info} -DNFD_PLAYGROUND_ENABLED=${NFD_PLAYGROUND_ENABLED:-false}"
|
||||
if [ -n "${JVM_MAX_METASPACE:-}" ]; then
|
||||
DEFAULT_JVM_OPTS="$DEFAULT_JVM_OPTS -XX:MaxMetaspaceSize=$JVM_MAX_METASPACE"
|
||||
fi
|
||||
exec java ${DEFAULT_JVM_OPTS} ${JVM_OPTS} -Duser.timezone=${TZ:-Asia/Shanghai} -jar /app/netdisk-fast-download.jar
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"mvn": "^3.5.0"
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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")
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -20,6 +20,32 @@
|
||||
|------|------|------|------|
|
||||
| url | string | ✅ 是 | 分享链接(需URL编码) |
|
||||
| pwd | string | ❌ 否 | 分享密码 |
|
||||
| auth | string | ❌ 否 | 认证参数(AES加密后的JSON,用于需要登录的网盘) |
|
||||
|
||||
### 认证参数说明(v0.2.1+)
|
||||
|
||||
部分网盘(如夸克QK、UC网盘)需要登录后的 Cookie 才能解析。`auth` 参数用于传递认证信息:
|
||||
|
||||
**加密方式**:
|
||||
- 算法:AES/ECB/PKCS5Padding
|
||||
- 密钥:`nfd_auth_key2026`(16字节)
|
||||
- 流程:JSON → AES加密 → Base64 → URL编码
|
||||
|
||||
**JSON 结构**:
|
||||
```json
|
||||
{
|
||||
"authType": "cookie", // 认证类型: cookie/accesstoken/authorization
|
||||
"token": "your_cookie_here" // Cookie 或 Token 内容
|
||||
}
|
||||
```
|
||||
|
||||
**网盘认证要求**:
|
||||
| 网盘 | 认证要求 |
|
||||
|------|---------|
|
||||
| 夸克网盘(QK) | **必须** |
|
||||
| UC网盘(UC) | **必须** |
|
||||
| 小飞机网盘(FJ) | 大文件需要 |
|
||||
| 蓝奏优享(IZ) | 大文件需要 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -240,13 +240,13 @@ var encoded = JsHttpClient.urlEncode("hello world"); // "hello%20world"
|
||||
var decoded = JsHttpClient.urlDecode("hello%20world"); // "hello world"
|
||||
|
||||
// 发送简单表单数据
|
||||
var formResponse = http.sendForm({
|
||||
var formResponse = http.sendForm("https://api.example.com/login", {
|
||||
username: "user",
|
||||
password: "pass"
|
||||
});
|
||||
|
||||
// 发送JSON数据
|
||||
var jsonResponse = http.sendJson({
|
||||
var jsonResponse = http.sendJson("https://api.example.com/submit", {
|
||||
name: "test",
|
||||
value: 123
|
||||
});
|
||||
@@ -637,7 +637,7 @@ A: 使用 `shareLinkInfo.getSharePassword()` 方法。
|
||||
|
||||
### Q: 如何处理需要登录的网盘?
|
||||
|
||||
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm()` 发送登录表单。
|
||||
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm(url, data)` 发送登录表单。
|
||||
|
||||
### Q: 如何解析复杂的HTML?
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ List<FileInfo> files = tool.parseFileListSync();
|
||||
```
|
||||
|
||||
要点:
|
||||
- 必须先 WebClientVertxInit.init(Vertx);若未显式初始化,内部将懒加载 Vertx.vertx(),建议显式注入以统一生命周期。
|
||||
- 必须先 WebClientVertxInit.init(Vertx);未初始化时会直接报错,避免解析器偷偷创建第二个 Vert.x 实例。
|
||||
- 支持三种同步方法:
|
||||
- `parseSync()`: 解析单个文件下载链接
|
||||
- `parseFileListSync()`: 解析文件列表
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
# 认证参数传递指南 (Auth Parameter Guide)
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了网盘解析接口中携带认证参数的方法。通过 `auth` 参数,可以在解析请求时传递临时认证信息(如 Cookie、Token、用户名密码等),使解析器能够访问需要登录或授权的网盘资源。
|
||||
|
||||
## 网盘认证要求
|
||||
|
||||
| 网盘 | 类型代码 | 认证要求 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| 夸克网盘 | QK | **必须** | 必须配置 Cookie 才能解析和下载 |
|
||||
| UC网盘 | UC | **必须** | 必须配置 Cookie 才能解析和下载 |
|
||||
| 小飞机网盘 | FJ | 可选 | 大文件(>100MB)需要配置认证信息 |
|
||||
| 蓝奏优享 | IZ | 可选 | 大文件需要配置认证信息 |
|
||||
| 其他网盘 | - | 不需要 | 无需认证即可解析 |
|
||||
|
||||
> 💡 **如何获取 Cookie**: 在浏览器中登录对应网盘,打开开发者工具(F12),切换到 Network 标签,刷新页面,在请求头中找到 Cookie 字段并复制完整内容。
|
||||
|
||||
## 认证参数格式
|
||||
|
||||
### 编码流程
|
||||
|
||||
```
|
||||
JSON对象 → AES加密 → Base64编码 → URL编码
|
||||
```
|
||||
|
||||
### 解码流程
|
||||
|
||||
```
|
||||
URL解码 → Base64解码 → AES解密 → JSON对象
|
||||
```
|
||||
|
||||
### 加密配置
|
||||
|
||||
- **加密算法**: AES/ECB/PKCS5Padding
|
||||
- **密钥长度**: 16位(128位)
|
||||
- **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置)
|
||||
|
||||
### 密钥作用说明(重要)
|
||||
|
||||
当前系统中涉及两类不同用途的密钥:
|
||||
|
||||
1. `server.authEncryptKey`
|
||||
- 用途:加解密 `auth` 参数(前端/调用方传入的认证信息)
|
||||
- 影响范围:`/parser`、`/json/parser`、`/v2/linkInfo` 等接口中的 `auth` 参数
|
||||
- 注意:这是 **AES 对称加密密钥**,要求 16 位
|
||||
|
||||
2. `server.donatedAccountFailureTokenSignKey`
|
||||
- 用途:签名和验签“捐赠账号失败计数 token”(用于防伪造、失败计数)
|
||||
- 影响范围:捐赠账号失败计数与自动失效逻辑
|
||||
- 注意:这是 **HMAC 签名密钥**,与 `authEncryptKey` 已解耦,建议使用高强度随机字符串
|
||||
|
||||
> 建议:生产环境务必同时自定义这两个密钥,且不要设置为相同值。
|
||||
|
||||
## JSON 模型定义
|
||||
|
||||
### AuthParam 对象
|
||||
|
||||
```json
|
||||
{
|
||||
"authType": "string", // 认证类型(必填)
|
||||
"username": "string", // 用户名
|
||||
"password": "string", // 密码
|
||||
"token": "string", // Token/AccessToken/Cookie值
|
||||
"cookie": "string", // Cookie 字符串
|
||||
"auth": "string", // Authorization 头内容
|
||||
"ext1": "string", // 扩展字段1(格式: key:value)
|
||||
"ext2": "string", // 扩展字段2(格式: key:value)
|
||||
"ext3": "string", // 扩展字段3(格式: key:value)
|
||||
"ext4": "string", // 扩展字段4(格式: key:value)
|
||||
"ext5": "string" // 扩展字段5(格式: key:value)
|
||||
}
|
||||
```
|
||||
|
||||
### 认证类型 (authType)
|
||||
|
||||
| authType | 说明 | 主要字段 |
|
||||
|----------|------|---------|
|
||||
| `accesstoken` | 使用 AccessToken 认证 | `token` |
|
||||
| `cookie` | 使用 Cookie 认证 | `token` (存放 cookie 值) |
|
||||
| `authorization` | 使用 Authorization 头认证 | `token` |
|
||||
| `password` / `username_password` | 用户名密码认证 | `username`, `password` |
|
||||
| `custom` | 自定义认证(使用扩展字段) | `token`, `ext1`-`ext5` |
|
||||
|
||||
### 示例 JSON
|
||||
|
||||
#### 1. Token 认证
|
||||
```json
|
||||
{
|
||||
"authType": "accesstoken",
|
||||
"token": "your_access_token_here"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Cookie 认证
|
||||
```json
|
||||
{
|
||||
"authType": "cookie",
|
||||
"token": "session_id=abc123; user_token=xyz789"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 用户名密码认证
|
||||
```json
|
||||
{
|
||||
"authType": "password",
|
||||
"username": "your_username",
|
||||
"password": "your_password"
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 自定义认证
|
||||
```json
|
||||
{
|
||||
"authType": "custom",
|
||||
"token": "main_token",
|
||||
"ext1": "refresh_token:your_refresh_token",
|
||||
"ext2": "device_id:device123"
|
||||
}
|
||||
```
|
||||
|
||||
## 接口调用示例
|
||||
|
||||
### 基础接口
|
||||
|
||||
#### 1. 解析并重定向 (GET /parser)
|
||||
|
||||
```
|
||||
GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
- `url`: 网盘分享链接(必填)
|
||||
- `pwd`: 提取码(可选)
|
||||
- `auth`: 加密后的认证参数(可选)
|
||||
|
||||
**响应:** 302 重定向到直链
|
||||
|
||||
#### 2. 解析返回 JSON (GET /json/parser)
|
||||
|
||||
```
|
||||
GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"shareKey": "lz:xxxx",
|
||||
"directLink": "https://...",
|
||||
"cacheHit": false,
|
||||
"expires": "2026-02-05 12:00:00",
|
||||
"expiration": 1738728000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 获取链接信息 (GET /v2/linkInfo)
|
||||
|
||||
```
|
||||
GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**响应:** 返回下载链接、API 链接、预览链接等信息
|
||||
|
||||
## 各语言加密示例
|
||||
|
||||
### Java
|
||||
|
||||
```java
|
||||
import cn.qaiu.lz.common.util.AuthParamCodec;
|
||||
import cn.qaiu.lz.web.model.AuthParam;
|
||||
|
||||
// 方式1: 使用 AuthParam 对象
|
||||
AuthParam authParam = AuthParam.builder()
|
||||
.authType("accesstoken")
|
||||
.token("your_token_here")
|
||||
.build();
|
||||
String encrypted = AuthParamCodec.encode(authParam);
|
||||
|
||||
// 方式2: 快速编码
|
||||
String encrypted = AuthParamCodec.quickEncode("accesstoken", "your_token_here");
|
||||
|
||||
// 方式3: 用户名密码
|
||||
String encrypted = AuthParamCodec.quickEncodePassword("username", "password");
|
||||
|
||||
// 解码
|
||||
AuthParam decoded = AuthParamCodec.decode(encrypted);
|
||||
```
|
||||
|
||||
### JavaScript (浏览器/Node.js)
|
||||
|
||||
```javascript
|
||||
// 使用 CryptoJS 库
|
||||
const CryptoJS = require('crypto-js');
|
||||
|
||||
const AUTH_KEY = 'nfd_auth_key2026';
|
||||
|
||||
// 加密
|
||||
function encodeAuthParam(authObj) {
|
||||
const jsonStr = JSON.stringify(authObj);
|
||||
const encrypted = CryptoJS.AES.encrypt(jsonStr, CryptoJS.enc.Utf8.parse(AUTH_KEY), {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
const base64 = encrypted.toString();
|
||||
return encodeURIComponent(base64);
|
||||
}
|
||||
|
||||
// 解密
|
||||
function decodeAuthParam(encryptedAuth) {
|
||||
const base64 = decodeURIComponent(encryptedAuth);
|
||||
const decrypted = CryptoJS.AES.decrypt(base64, CryptoJS.enc.Utf8.parse(AUTH_KEY), {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
return JSON.parse(decrypted.toString(CryptoJS.enc.Utf8));
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const auth = encodeAuthParam({
|
||||
authType: 'accesstoken',
|
||||
token: 'your_token_here'
|
||||
});
|
||||
const url = `http://127.0.0.1:6400/parser?url=${shareUrl}&auth=${auth}`;
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import json
|
||||
import base64
|
||||
from urllib.parse import quote, unquote
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
|
||||
AUTH_KEY = b'nfd_auth_key2026'
|
||||
|
||||
def encode_auth_param(auth_obj):
|
||||
"""加密认证参数"""
|
||||
json_str = json.dumps(auth_obj, ensure_ascii=False)
|
||||
cipher = AES.new(AUTH_KEY, AES.MODE_ECB)
|
||||
padded = pad(json_str.encode('utf-8'), AES.block_size)
|
||||
encrypted = cipher.encrypt(padded)
|
||||
base64_str = base64.b64encode(encrypted).decode('utf-8')
|
||||
return quote(base64_str)
|
||||
|
||||
def decode_auth_param(encrypted_auth):
|
||||
"""解密认证参数"""
|
||||
base64_str = unquote(encrypted_auth)
|
||||
encrypted = base64.b64decode(base64_str)
|
||||
cipher = AES.new(AUTH_KEY, AES.MODE_ECB)
|
||||
decrypted = unpad(cipher.decrypt(encrypted), AES.block_size)
|
||||
return json.loads(decrypted.decode('utf-8'))
|
||||
|
||||
# 使用示例
|
||||
auth = encode_auth_param({
|
||||
'authType': 'accesstoken',
|
||||
'token': 'your_token_here'
|
||||
})
|
||||
url = f'http://127.0.0.1:6400/parser?url={share_url}&auth={auth}'
|
||||
```
|
||||
|
||||
### cURL 命令行
|
||||
|
||||
```bash
|
||||
# 假设已加密的 auth 参数为 ENCRYPTED_AUTH
|
||||
curl -L "http://127.0.0.1:6400/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH"
|
||||
|
||||
# 获取 JSON 响应
|
||||
curl "http://127.0.0.1:6400/json/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH"
|
||||
```
|
||||
|
||||
## 解析器使用认证信息
|
||||
|
||||
解析器可以从 `shareLinkInfo.otherParam.get("auths")` 获取 MultiMap 格式的认证信息:
|
||||
|
||||
```java
|
||||
// 在解析器中获取认证信息
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
|
||||
if (auths != null) {
|
||||
String authType = auths.get("authType");
|
||||
String token = auths.get("token");
|
||||
String username = auths.get("username");
|
||||
String password = auths.get("password");
|
||||
|
||||
// 根据 authType 使用相应的认证方式
|
||||
switch (authType) {
|
||||
case "accesstoken":
|
||||
// 使用 token 认证
|
||||
break;
|
||||
case "password":
|
||||
// 使用用户名密码登录
|
||||
break;
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **安全性**:
|
||||
- 不要在日志中打印完整的认证参数
|
||||
- 认证参数通过 HTTPS 传输更安全
|
||||
- 密钥应妥善保管,建议在生产环境中更换默认密钥
|
||||
|
||||
2. **缓存策略**:
|
||||
- 带有临时认证参数的请求目前不会被缓存
|
||||
- 每次请求都会重新解析
|
||||
|
||||
3. **兼容性**:
|
||||
- `auth` 参数与原有的 `pwd` 参数可以同时使用
|
||||
- 不提供 `auth` 参数时,使用后台配置的认证信息
|
||||
|
||||
4. **扩展字段**:
|
||||
- `ext1`-`ext5` 使用 `key:value` 格式
|
||||
- 适用于需要传递多个自定义参数的场景
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 `app-dev.yml` 中配置密钥:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
# auth参数加密密钥(16位AES密钥)
|
||||
authEncryptKey: 'your_custom_key16'
|
||||
|
||||
# 捐赠账号失败计数token签名密钥(HMAC)
|
||||
# 建议使用较长随机字符串,并与 authEncryptKey 不同
|
||||
donatedAccountFailureTokenSignKey: 'your_random_hmac_sign_key'
|
||||
```
|
||||
|
||||
### 密钥管理建议
|
||||
|
||||
- 不要在公开仓库提交生产密钥
|
||||
- 建议通过环境变量或私有配置注入
|
||||
- 调整 `authEncryptKey` 会影响 `auth` 参数兼容性
|
||||
- 调整 `donatedAccountFailureTokenSignKey` 会使已签发的失败计数 token 失效(短期可接受)
|
||||
|
||||
## 更新日志
|
||||
|
||||
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
|
||||
@@ -0,0 +1,118 @@
|
||||
# 认证参数传递指南 (简化版)
|
||||
|
||||
## JSON 对象模型
|
||||
|
||||
### AuthParam 对象
|
||||
|
||||
```json
|
||||
{
|
||||
"authType": "string", // 认证类型(必填)
|
||||
"username": "string", // 用户名
|
||||
"password": "string", // 密码
|
||||
"token": "string", // Token/AccessToken/Cookie值
|
||||
"cookie": "string", // Cookie 字符串
|
||||
"auth": "string", // Authorization 头内容
|
||||
"ext1": "string", // 扩展字段1(格式: key:value)
|
||||
"ext2": "string", // 扩展字段2(格式: key:value)
|
||||
"ext3": "string", // 扩展字段3(格式: key:value)
|
||||
"ext4": "string", // 扩展字段4(格式: key:value)
|
||||
"ext5": "string" // 扩展字段5(格式: key:value)
|
||||
}
|
||||
```
|
||||
|
||||
## 认证类型
|
||||
|
||||
| authType | 说明 | 主要字段 |
|
||||
|----------|------|---------|
|
||||
| `accesstoken` | AccessToken 认证 | `token` |
|
||||
| `cookie` | Cookie 认证 | `token` |
|
||||
| `authorization` | Authorization 头认证 | `token` |
|
||||
| `password` | 用户名密码认证 | `username`, `password` |
|
||||
| `custom` | 自定义认证 | `token`, `ext1`-`ext5` |
|
||||
|
||||
## 示例
|
||||
|
||||
### Token 认证
|
||||
```json
|
||||
{
|
||||
"authType": "accesstoken",
|
||||
"token": "your_access_token_here"
|
||||
}
|
||||
```
|
||||
|
||||
### Cookie 认证
|
||||
```json
|
||||
{
|
||||
"authType": "cookie",
|
||||
"token": "session_id=abc123; user_token=xyz789"
|
||||
}
|
||||
```
|
||||
|
||||
### 用户名密码
|
||||
```json
|
||||
{
|
||||
"authType": "password",
|
||||
"username": "your_username",
|
||||
"password": "your_password"
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义认证
|
||||
```json
|
||||
{
|
||||
"authType": "custom",
|
||||
"token": "main_token",
|
||||
"ext1": "refresh_token:your_refresh_token",
|
||||
"ext2": "device_id:device123"
|
||||
}
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. **编码流程**: JSON对象 → AES加密 → Base64编码 → URL编码
|
||||
2. **加密配置**: AES/ECB/PKCS5Padding, 密钥: `nfd_auth_key2026` (16位)
|
||||
3. **接口调用**: `GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}`
|
||||
|
||||
|
||||
|
||||
## 接口调用示例
|
||||
|
||||
### 基础接口
|
||||
|
||||
#### 1. 解析并重定向 (GET /parser)
|
||||
|
||||
```
|
||||
GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
- `url`: 网盘分享链接(必填)
|
||||
- `pwd`: 提取码(可选)
|
||||
- `auth`: 加密后的认证参数(可选)
|
||||
|
||||
**响应:** 302 重定向到直链
|
||||
|
||||
#### 2. 解析返回 JSON (GET /json/parser)
|
||||
|
||||
```
|
||||
GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"shareKey": "lz:xxxx",
|
||||
"directLink": "https://...",
|
||||
"cacheHit": false,
|
||||
"expires": "2026-02-05 12:00:00",
|
||||
"expiration": 1738728000000
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 获取链接信息 (GET /v2/linkInfo)
|
||||
|
||||
```
|
||||
GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数}
|
||||
```
|
||||
|
||||
**响应:** 返回下载链接、API 链接、预览链接等信息
|
||||
@@ -17,7 +17,7 @@
|
||||
this.temporaryExecutor = WebClientVertxInit.get().createSharedWorkerExecutor(
|
||||
"playground-temp-" + System.currentTimeMillis(),
|
||||
1, // 每个请求只需要1个线程
|
||||
10000000000L // 设置非常长的超时,避免被vertx强制中断
|
||||
10000000000L // 设置非常长的超时,避免触发Vert.x阻塞线程告警
|
||||
);
|
||||
|
||||
// 执行完成或超时后关闭
|
||||
|
||||
@@ -106,7 +106,7 @@ executionFuture.toCompletionStage()
|
||||
### 长期方案(需大量工作)
|
||||
1. **迁移到GraalVM JavaScript引擎**
|
||||
- 支持CPU时间限制
|
||||
- 可以强制中断
|
||||
- 相比Nashorn更容易实现受控取消
|
||||
- 更好的性能
|
||||
- 但需要额外依赖
|
||||
|
||||
|
||||
+43
-9
@@ -12,7 +12,7 @@
|
||||
|
||||
<groupId>cn.qaiu</groupId>
|
||||
<artifactId>parser</artifactId>
|
||||
<version>10.2.3</version>
|
||||
<version>${parserVersion}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>cn.qaiu:parser</name>
|
||||
@@ -35,9 +35,9 @@
|
||||
</developers>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:https://github.com/qaiu/netdisk-fast-download.git</connection>
|
||||
<developerConnection>scm:git:ssh://[email protected]:qaiu/netdisk-fast-download.git</developerConnection>
|
||||
<url>https://github.com/qaiu/netdisk-fast-download</url>
|
||||
<connection>scm:git:https://github.com/${github.owner}/${github.repo}.git</connection>
|
||||
<developerConnection>scm:git:ssh://[email protected]:${github.owner}/${github.repo}.git</developerConnection>
|
||||
<url>https://github.com/${github.owner}/${github.repo}</url>
|
||||
</scm>
|
||||
|
||||
<distributionManagement>
|
||||
@@ -52,20 +52,19 @@
|
||||
</distributionManagement>
|
||||
|
||||
<properties>
|
||||
<revision>0.1.8</revision>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
<!-- Versions -->
|
||||
<vertx.version>4.5.22</vertx.version>
|
||||
<vertx.version>4.5.27</vertx.version>
|
||||
<org.reflections.version>0.10.2</org.reflections.version>
|
||||
<lombok.version>1.18.38</lombok.version>
|
||||
<slf4j.version>2.0.5</slf4j.version>
|
||||
<slf4j.version>2.0.16</slf4j.version>
|
||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||
<jackson.version>2.14.2</jackson.version>
|
||||
<logback.version>1.5.19</logback.version>
|
||||
<jackson.version>2.18.6</jackson.version>
|
||||
<logback.version>1.5.32</logback.version>
|
||||
<junit.version>4.13.2</junit.version>
|
||||
</properties>
|
||||
|
||||
@@ -124,6 +123,41 @@
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
<!-- 从 git remote origin 自动识别 GitHub 仓库地址 -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.gmavenplus</groupId>
|
||||
<artifactId>gmavenplus-plugin</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.groovy</groupId>
|
||||
<artifactId>groovy</artifactId>
|
||||
<version>4.0.24</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>initialize</phase>
|
||||
<goals><goal>execute</goal></goals>
|
||||
<configuration>
|
||||
<scripts>
|
||||
<script>
|
||||
def url = 'git remote get-url origin'.execute().text.trim()
|
||||
def m = (url =~ 'github\\.com[:/]([^/]+)/([^/.]+?)(?:\\.git)?$')
|
||||
if (m.find()) {
|
||||
project.properties.setProperty('github.owner', m.group(1))
|
||||
project.properties.setProperty('github.repo', m.group(2))
|
||||
} else {
|
||||
project.properties.setProperty('github.owner', 'qaiu')
|
||||
project.properties.setProperty('github.repo', 'netdisk-fast-download')
|
||||
}
|
||||
</script>
|
||||
</scripts>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- 编译 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
|
||||
@@ -7,12 +7,15 @@ import org.slf4j.LoggerFactory;
|
||||
import cn.qaiu.parser.custom.CustomParserRegistry;
|
||||
|
||||
public class WebClientVertxInit {
|
||||
private Vertx vertx = null;
|
||||
private volatile Vertx vertx = null;
|
||||
private static final WebClientVertxInit INSTANCE = new WebClientVertxInit();
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebClientVertxInit.class);
|
||||
|
||||
public static void init(Vertx vx) {
|
||||
public static synchronized void init(Vertx vx) {
|
||||
if (vx == null) {
|
||||
throw new IllegalArgumentException("Vertx instance must not be null");
|
||||
}
|
||||
INSTANCE.vertx = vx;
|
||||
|
||||
// 自动加载JavaScript解析器脚本
|
||||
@@ -23,18 +26,10 @@ public class WebClientVertxInit {
|
||||
}
|
||||
}
|
||||
|
||||
public static Vertx get() {
|
||||
public static synchronized Vertx get() {
|
||||
if (INSTANCE.vertx == null) {
|
||||
log.info("getVertx: Vertx实例不存在, 创建Vertx实例.");
|
||||
INSTANCE.vertx = Vertx.vertx();
|
||||
|
||||
// 如果Vertx实例是新创建的,也尝试加载JavaScript脚本
|
||||
try {
|
||||
CustomParserRegistry.autoLoadJsScripts();
|
||||
} catch (Exception e) {
|
||||
log.warn("自动加载JavaScript解析器脚本失败", e);
|
||||
}
|
||||
throw new IllegalStateException("Vertx实例未初始化,请先调用 WebClientVertxInit.init(vertx)");
|
||||
}
|
||||
return INSTANCE.vertx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.qaiu.parser;//package cn.qaiu.lz.common.parser;
|
||||
|
||||
import cn.qaiu.WebClientVertxInit;
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory;
|
||||
@@ -7,10 +8,26 @@ import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public interface IPanTool {
|
||||
public interface IPanTool extends AutoCloseable {
|
||||
|
||||
/** 同步等待超时时间(秒) */
|
||||
long SYNC_TIMEOUT_SECONDS = 120;
|
||||
|
||||
ScheduledExecutorService CLOSE_AFTER_SCHEDULER = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "pan-tool-close-after");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/**
|
||||
* 解析文件
|
||||
@@ -18,8 +35,72 @@ public interface IPanTool {
|
||||
*/
|
||||
Future<String> parse();
|
||||
|
||||
static <T> Future<T> closeAfter(IPanTool tool, Supplier<Future<T>> action) {
|
||||
Promise<T> promise = Promise.promise();
|
||||
AtomicBoolean cleanupDone = new AtomicBoolean(false);
|
||||
ScheduledFuture<?> cleanupTask = null;
|
||||
try {
|
||||
Future<T> future = action.get();
|
||||
if (future == null) {
|
||||
closeQuietly(tool);
|
||||
return Future.failedFuture("解析器返回空 Future");
|
||||
}
|
||||
|
||||
cleanupTask = CLOSE_AFTER_SCHEDULER.schedule(() -> {
|
||||
if (cleanupDone.compareAndSet(false, true)) {
|
||||
closeQuietly(tool);
|
||||
failOnVertxContext(promise, "解析超时(" + SYNC_TIMEOUT_SECONDS + "秒)");
|
||||
}
|
||||
}, SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
ScheduledFuture<?> scheduledCleanupTask = cleanupTask;
|
||||
future.onComplete(ar -> {
|
||||
scheduledCleanupTask.cancel(false);
|
||||
if (!cleanupDone.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
closeQuietly(tool);
|
||||
if (ar.succeeded()) {
|
||||
promise.tryComplete(ar.result());
|
||||
} else {
|
||||
promise.tryFail(ar.cause());
|
||||
}
|
||||
});
|
||||
return promise.future();
|
||||
} catch (Throwable t) {
|
||||
if (cleanupTask != null) {
|
||||
cleanupTask.cancel(false);
|
||||
}
|
||||
closeQuietly(tool);
|
||||
return Future.failedFuture(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> void failOnVertxContext(Promise<T> promise, String message) {
|
||||
try {
|
||||
WebClientVertxInit.get().runOnContext(ignored -> promise.tryFail(message));
|
||||
} catch (Exception ignored) {
|
||||
promise.tryFail(message);
|
||||
}
|
||||
}
|
||||
|
||||
static void closeQuietly(IPanTool tool) {
|
||||
if (tool == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
tool.close();
|
||||
} catch (Exception ignored) {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
static void shutdownCloseAfterScheduler() {
|
||||
CLOSE_AFTER_SCHEDULER.shutdownNow();
|
||||
}
|
||||
|
||||
default String parseSync() {
|
||||
return parse().toCompletionStage().toCompletableFuture().join();
|
||||
return timedJoin(parse());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +114,7 @@ public interface IPanTool {
|
||||
}
|
||||
|
||||
default List<FileInfo> parseFileListSync() {
|
||||
return parseFileList().toCompletionStage().toCompletableFuture().join();
|
||||
return timedJoin(parseFileList());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +128,7 @@ public interface IPanTool {
|
||||
}
|
||||
|
||||
default String parseByIdSync() {
|
||||
return parseById().toCompletionStage().toCompletableFuture().join();
|
||||
return timedJoin(parseById());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +207,7 @@ public interface IPanTool {
|
||||
* @return Map<ClientLinkType, String> 客户端下载链接集合
|
||||
*/
|
||||
default Map<ClientLinkType, String> parseWithClientLinksSync() {
|
||||
return parseWithClientLinks().toCompletionStage().toCompletableFuture().join();
|
||||
return timedJoin(parseWithClientLinks());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,4 +218,26 @@ public interface IPanTool {
|
||||
default ShareLinkInfo getShareLinkInfo() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
// default no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时的同步等待工具方法,替代无超时的 join()
|
||||
*/
|
||||
private static <T> T timedJoin(Future<T> future) {
|
||||
try {
|
||||
return future.toCompletionStage().toCompletableFuture()
|
||||
.get(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("线程被中断", e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new RuntimeException("同步等待超时(" + SYNC_TIMEOUT_SECONDS + "秒)", e);
|
||||
} catch (java.util.concurrent.ExecutionException e) {
|
||||
throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URL;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
@@ -34,30 +37,67 @@ import java.util.zip.GZIPInputStream;
|
||||
* <p>{网盘标识}Tool, 网盘标识不超过5个字符, 可以取网盘名称首字母缩写或拼音首字母, <br>
|
||||
* 音乐类型的解析以M开头, 例如网易云音乐Mne</p>
|
||||
*/
|
||||
public abstract class PanBase implements IPanTool {
|
||||
public abstract class PanBase implements IPanTool, Closeable {
|
||||
protected Logger log = LoggerFactory.getLogger(this.getClass());
|
||||
|
||||
protected Promise<String> promise = Promise.promise();
|
||||
|
||||
/**
|
||||
* Http client
|
||||
*/
|
||||
protected WebClient client = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions());
|
||||
private static final int MAX_COMPRESSED_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
private static final int MAX_DECOMPRESSED_RESPONSE_CHARS = 16 * 1024 * 1024;
|
||||
private static final int MAX_ERROR_BODY_CHARS = 4096;
|
||||
|
||||
/**
|
||||
* Http client session (会话管理, 带cookie请求)
|
||||
* 共享的 WebClient 配置(设置超时避免连接无限期占用)
|
||||
*/
|
||||
private static final WebClientOptions SHARED_OPTIONS = new WebClientOptions()
|
||||
.setConnectTimeout(10000) // 连接超时 10 秒
|
||||
.setIdleTimeout(30) // 空闲超时 30 秒
|
||||
.setIdleTimeoutUnit(java.util.concurrent.TimeUnit.SECONDS);
|
||||
|
||||
private static final Object SHARED_CLIENT_LOCK = new Object();
|
||||
|
||||
/**
|
||||
* 共享的 WebClient 实例(线程安全,避免每请求创建导致资源泄漏)
|
||||
*/
|
||||
private static volatile WebClient sharedClient;
|
||||
private static volatile WebClient sharedClientNoRedirects;
|
||||
private static volatile WebClient sharedClientDisableUA;
|
||||
private static volatile boolean sharedClientsShutdown = false;
|
||||
|
||||
/**
|
||||
* Http client (默认使用共享实例,代理模式下使用独立实例)
|
||||
*/
|
||||
protected WebClient client = sharedClient();
|
||||
|
||||
/**
|
||||
* 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 = sharedClientNoRedirects();
|
||||
|
||||
/**
|
||||
* Http client disable UserAgent
|
||||
*/
|
||||
protected WebClient clientDisableUA = sharedClientDisableUA();
|
||||
|
||||
protected ShareLinkInfo shareLinkInfo;
|
||||
|
||||
/**
|
||||
* 标记是否为代理模式(代理模式创建的 WebClient 需要手动关闭)
|
||||
*/
|
||||
private boolean isProxyMode = false;
|
||||
|
||||
/**
|
||||
* 代理模式下创建的独立 WebClient 实例(需要在 close 时释放)
|
||||
*/
|
||||
private WebClient proxyClient = null;
|
||||
private WebClient proxyClientNoRedirects = null;
|
||||
|
||||
|
||||
/**
|
||||
* 子类重写此构造方法不需要添加额外逻辑
|
||||
* 如:
|
||||
@@ -72,6 +112,7 @@ public abstract class PanBase implements IPanTool {
|
||||
public PanBase(ShareLinkInfo shareLinkInfo) {
|
||||
this.shareLinkInfo = shareLinkInfo;
|
||||
if (shareLinkInfo.getOtherParam().containsKey("proxy")) {
|
||||
this.isProxyMode = true;
|
||||
JsonObject proxy = (JsonObject) shareLinkInfo.getOtherParam().get("proxy");
|
||||
ProxyOptions proxyOptions = new ProxyOptions()
|
||||
.setType(ProxyType.valueOf(proxy.getString("type").toUpperCase()))
|
||||
@@ -83,22 +124,156 @@ public abstract class PanBase implements IPanTool {
|
||||
if (StringUtils.isNotEmpty(proxy.getString("password"))) {
|
||||
proxyOptions.setPassword(proxy.getString("password"));
|
||||
}
|
||||
this.client = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions()
|
||||
// 代理模式下创建独立的 WebClient 实例(应用超时配置)
|
||||
this.proxyClient = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions(SHARED_OPTIONS)
|
||||
.setUserAgentEnabled(false)
|
||||
.setProxyOptions(proxyOptions));
|
||||
this.proxyClientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions(SHARED_OPTIONS).setFollowRedirects(false)
|
||||
.setUserAgentEnabled(false)
|
||||
.setProxyOptions(proxyOptions));
|
||||
|
||||
this.client = proxyClient;
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
this.clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions().setFollowRedirects(false)
|
||||
.setUserAgentEnabled(false)
|
||||
.setProxyOptions(proxyOptions));
|
||||
this.clientNoRedirects = proxyClientNoRedirects;
|
||||
}
|
||||
}
|
||||
|
||||
protected PanBase() {
|
||||
}
|
||||
|
||||
private static WebClient sharedClient() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
if (sharedClientsShutdown) {
|
||||
throw new IllegalStateException("共享 WebClient 已关闭");
|
||||
}
|
||||
if (sharedClient == null) {
|
||||
sharedClient = WebClient.create(WebClientVertxInit.get(), new WebClientOptions(SHARED_OPTIONS));
|
||||
}
|
||||
return sharedClient;
|
||||
}
|
||||
}
|
||||
|
||||
private static WebClient sharedClientNoRedirects() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
if (sharedClientsShutdown) {
|
||||
throw new IllegalStateException("共享 WebClient 已关闭");
|
||||
}
|
||||
if (sharedClientNoRedirects == null) {
|
||||
sharedClientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions(SHARED_OPTIONS).setFollowRedirects(false));
|
||||
}
|
||||
return sharedClientNoRedirects;
|
||||
}
|
||||
}
|
||||
|
||||
private static WebClient sharedClientDisableUA() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
if (sharedClientsShutdown) {
|
||||
throw new IllegalStateException("共享 WebClient 已关闭");
|
||||
}
|
||||
if (sharedClientDisableUA == null) {
|
||||
sharedClientDisableUA = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions(SHARED_OPTIONS).setUserAgentEnabled(false));
|
||||
}
|
||||
return sharedClientDisableUA;
|
||||
}
|
||||
}
|
||||
|
||||
public static void shutdownSharedClients() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
sharedClientsShutdown = true;
|
||||
closeSharedClient(sharedClient, "shared WebClient");
|
||||
closeSharedClient(sharedClientNoRedirects, "shared WebClientNoRedirects");
|
||||
closeSharedClient(sharedClientDisableUA, "shared WebClientDisableUA");
|
||||
sharedClient = null;
|
||||
sharedClientNoRedirects = null;
|
||||
sharedClientDisableUA = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeSharedClient(WebClient client, String name) {
|
||||
if (client == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
client.close();
|
||||
} catch (Exception e) {
|
||||
LoggerFactory.getLogger(PanBase.class).warn("关闭 {} 失败: {}", name, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF 防护: 校验通用自定义域名解析器 (CE/Ce4/Kd/Other 等) 即将请求的目标主机,
|
||||
* 拒绝解析到回环/内网/链路本地/组播等非公网地址的域名, 阻止攻击者通过可控 DNS
|
||||
* 记录 (或直接填写内网域名) 让服务端向内网/云元数据接口发起请求。
|
||||
* <p>
|
||||
* 必须在子类 parse() 中构造出 baseUrl/发起任何 clientSession 请求之前调用。
|
||||
*
|
||||
* @param url 从 shareLinkInfo.getShareUrl() 解析出的 URL
|
||||
* @throws IOException 当主机无法解析或解析结果落入禁止的地址段时抛出
|
||||
*/
|
||||
protected static void assertPublicHost(URL url) throws IOException {
|
||||
String host = url.getHost();
|
||||
InetAddress[] addresses;
|
||||
try {
|
||||
addresses = InetAddress.getAllByName(host);
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IOException("无法解析目标主机: " + host, e);
|
||||
}
|
||||
if (addresses.length == 0) {
|
||||
throw new IOException("无法解析目标主机: " + host);
|
||||
}
|
||||
for (InetAddress addr : addresses) {
|
||||
if (isDisallowedAddress(addr)) {
|
||||
throw new IOException("目标地址不允许访问(内网/回环/链路本地/组播): "
|
||||
+ host + " -> " + addr.getHostAddress());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断地址是否落入禁止访问的范围: 0.0.0.0/8, 10/8, 127/8, 169.254/16(含云元数据
|
||||
* 169.254.169.254), 172.16/12, 192.168/16, 100.64/10(CGNAT), 224/4~255/4(组播/保留),
|
||||
* 以及对应的 IPv6 回环/链路本地/唯一本地地址(fc00::/7)/组播地址; IPv4-映射的 IPv6
|
||||
* 地址(::ffff:a.b.c.d) 会先还原为 IPv4 再判断,避免绕过。
|
||||
*/
|
||||
private static boolean isDisallowedAddress(InetAddress addr) {
|
||||
byte[] bytes = addr.getAddress();
|
||||
if (bytes.length == 16 && isIPv4Mapped(bytes)) {
|
||||
byte[] v4 = new byte[4];
|
||||
System.arraycopy(bytes, 12, v4, 0, 4);
|
||||
bytes = v4;
|
||||
}
|
||||
if (bytes.length == 4) {
|
||||
int b0 = bytes[0] & 0xFF;
|
||||
int b1 = bytes[1] & 0xFF;
|
||||
if (b0 == 0) return true; // 0.0.0.0/8
|
||||
if (b0 == 10) return true; // 10.0.0.0/8
|
||||
if (b0 == 127) return true; // 127.0.0.0/8 loopback
|
||||
if (b0 == 169 && b1 == 254) return true; // 169.254.0.0/16 (含云元数据 169.254.169.254)
|
||||
if (b0 == 172 && b1 >= 16 && b1 <= 31) return true; // 172.16.0.0/12
|
||||
if (b0 == 192 && b1 == 168) return true; // 192.168.0.0/16
|
||||
if (b0 == 100 && b1 >= 64 && b1 <= 127) return true;// 100.64.0.0/10 CGNAT
|
||||
return b0 >= 224; // 224.0.0.0/4 组播 + 240.0.0.0/4 保留
|
||||
}
|
||||
// IPv6
|
||||
if (addr.isAnyLocalAddress() || addr.isLoopbackAddress()
|
||||
|| addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
|
||||
|| addr.isMulticastAddress()) {
|
||||
return true;
|
||||
}
|
||||
return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7 unique local
|
||||
}
|
||||
|
||||
private static boolean isIPv4Mapped(byte[] b) {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (b[i] != 0) return false;
|
||||
}
|
||||
return (b[10] & 0xFF) == 0xFF && (b[11] & 0xFF) == 0xFF;
|
||||
}
|
||||
|
||||
protected String baseMsg() {
|
||||
if (shareLinkInfo.getShareUrl() != null) {
|
||||
return shareLinkInfo.getPanName() + "-" + shareLinkInfo.getType() + ": url=" + shareLinkInfo.getShareUrl();
|
||||
@@ -123,16 +298,19 @@ public abstract class PanBase implements IPanTool {
|
||||
return;
|
||||
}
|
||||
String s = String.format(errorMsg.replaceAll("\\{}", "%s"), args);
|
||||
log.error("解析异常: " + s, t.fillInStackTrace());
|
||||
promise.fail(baseMsg() + ": 解析异常: " + s + " -> " + t);
|
||||
// 只记录异常消息和类型,不调用 fillInStackTrace 避免产生巨大栈信息
|
||||
log.error("解析异常: {} - {}: {}", s, t.getClass().getSimpleName(), t.getMessage());
|
||||
// 只传递异常消息,不传递完整异常对象,减少内存占用
|
||||
String failMsg = baseMsg() + ": 解析异常: " + s + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage();
|
||||
promise.fail(failMsg);
|
||||
} catch (Exception e) {
|
||||
log.error("ErrorMsg format fail. The parameter has been discarded", e);
|
||||
log.error("解析异常: " + errorMsg, t.fillInStackTrace());
|
||||
log.error("解析异常: {} - {}: {}", errorMsg, t.getClass().getSimpleName(), t.getMessage());
|
||||
if (promise.future().isComplete()) {
|
||||
log.warn("ErrorMsg format. Promise 已经完成, 无法再次失败: {}", errorMsg);
|
||||
return;
|
||||
}
|
||||
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t);
|
||||
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +350,7 @@ public abstract class PanBase implements IPanTool {
|
||||
* @return Handler
|
||||
*/
|
||||
protected Handler<Throwable> handleFail(String errorMsg) {
|
||||
return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.fillInStackTrace());
|
||||
return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||
}
|
||||
|
||||
protected Handler<Throwable> handleFail() {
|
||||
@@ -191,28 +369,23 @@ public abstract class PanBase implements IPanTool {
|
||||
String contentEncoding = res.getHeader("Content-Encoding");
|
||||
try {
|
||||
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
||||
// 如果是gzip压缩的响应体,解压
|
||||
return new JsonObject(decompressGzip((Buffer) res.body()));
|
||||
// 如果是gzip压缩的响应体,解压(只解压一次,缓存结果)
|
||||
String decompressed = decompressGzip((Buffer) res.body());
|
||||
return new JsonObject(decompressed);
|
||||
} else {
|
||||
return res.bodyAsJsonObject();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
||||
// 如果是gzip压缩的响应体,解压
|
||||
try {
|
||||
log.error(decompressGzip((Buffer) res.body()));
|
||||
fail(decompressGzip((Buffer) res.body()));
|
||||
//throw new RuntimeException("响应不是JSON格式");
|
||||
} catch (IOException ex) {
|
||||
log.error("响应gzip解压失败");
|
||||
fail("响应gzip解压失败: {}", ex.getMessage());
|
||||
//throw new RuntimeException("响应gzip解压失败", ex);
|
||||
}
|
||||
// gzip解压失败,记录错误
|
||||
log.error("响应gzip解压或JSON解析失败: {}", e.getMessage());
|
||||
fail("响应gzip解压或JSON解析失败: {}", e.getMessage());
|
||||
} else {
|
||||
log.error("解析失败: json格式异常: {}", res.bodyAsString());
|
||||
fail("解析失败: json格式异常: {}", res.bodyAsString());
|
||||
//throw new RuntimeException("解析失败: json格式异常");
|
||||
// 上游响应体可能来自内网探测目标,仅写日志,避免经 HTTP 500 回传给调用方
|
||||
String bodyPreview = responseBodyPreview(res);
|
||||
log.error("解析失败: json格式异常: {}", bodyPreview);
|
||||
fail("解析失败: json格式异常");
|
||||
}
|
||||
return JsonObject.of();
|
||||
}
|
||||
@@ -242,7 +415,18 @@ public abstract class PanBase implements IPanTool {
|
||||
protected void completeWithMeta(String url, Map<String, String> headers) {
|
||||
shareLinkInfo.getOtherParam().put("downloadUrl", url);
|
||||
if (headers != null && !headers.isEmpty()) {
|
||||
shareLinkInfo.getOtherParam().put("downloadHeaders", headers);
|
||||
// 过滤 null/空值,避免 cookie:null 覆盖入口参数或污染 curl 命令
|
||||
Map<String, String> clean = new HashMap<>();
|
||||
headers.forEach((k, v) -> {
|
||||
if (k != null && v != null && !v.isBlank()) {
|
||||
clean.put(k, v);
|
||||
}
|
||||
});
|
||||
if (!clean.isEmpty()) {
|
||||
shareLinkInfo.getOtherParam().put("downloadHeaders", clean);
|
||||
// UC/夸克等需带 cookie 的直链,标记前端走下载器
|
||||
shareLinkInfo.getOtherParam().put("needDownloader", true);
|
||||
}
|
||||
}
|
||||
promise.complete(url);
|
||||
}
|
||||
@@ -275,11 +459,15 @@ public abstract class PanBase implements IPanTool {
|
||||
if (iterator.hasNext()) {
|
||||
PanDomainTemplate next = iterator.next();
|
||||
log.debug("规则不匹配, 处理解析器转发: {} -> {}", shareLinkInfo.getPanName(), next.getDisplayName());
|
||||
ParserCreate.fromType(next.name())
|
||||
.fromAnyShareUrl(shareLinkInfo.getShareUrl())
|
||||
.createTool()
|
||||
.parse()
|
||||
.onComplete(promise);
|
||||
try {
|
||||
IPanTool nextTool = ParserCreate.fromType(next.name())
|
||||
.fromAnyShareUrl(shareLinkInfo.getShareUrl())
|
||||
.createTool();
|
||||
IPanTool.closeAfter(nextTool, nextTool::parse)
|
||||
.onComplete(promise);
|
||||
} catch (Exception e) {
|
||||
fail(e, "转发到下一个解析器失败: {}", next.getDisplayName());
|
||||
}
|
||||
} else {
|
||||
fail("error: 没有下一个解析处理器");
|
||||
}
|
||||
@@ -295,6 +483,12 @@ public abstract class PanBase implements IPanTool {
|
||||
* @throws IOException IOException
|
||||
*/
|
||||
private String decompressGzip(Buffer compressedData) throws IOException {
|
||||
if (compressedData == null) {
|
||||
return "";
|
||||
}
|
||||
if (compressedData.length() > MAX_COMPRESSED_RESPONSE_BYTES) {
|
||||
throw new IOException("gzip响应体过大: " + compressedData.length() + " bytes");
|
||||
}
|
||||
try (ByteArrayInputStream bais = new ByteArrayInputStream(compressedData.getBytes());
|
||||
GZIPInputStream gzis = new GZIPInputStream(bais);
|
||||
InputStreamReader isr = new InputStreamReader(gzis, StandardCharsets.UTF_8);
|
||||
@@ -303,18 +497,97 @@ public abstract class PanBase implements IPanTool {
|
||||
char[] buffer = new char[4096];
|
||||
int n;
|
||||
while ((n = isr.read(buffer)) != -1) {
|
||||
writer.write(buffer, 0, n);
|
||||
writeLimited(writer, buffer, n);
|
||||
}
|
||||
return writer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeLimited(StringWriter writer, char[] buffer, int len) throws IOException {
|
||||
if (writer.getBuffer().length() + len > MAX_DECOMPRESSED_RESPONSE_CHARS) {
|
||||
throw new IOException("gzip解压后响应体过大");
|
||||
}
|
||||
writer.write(buffer, 0, len);
|
||||
}
|
||||
|
||||
private String responseBodyPreview(HttpResponse<?> res) {
|
||||
if (res == null || res.body() == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
if (res.body() instanceof Buffer body) {
|
||||
int length = Math.min(body.length(), MAX_ERROR_BODY_CHARS);
|
||||
String preview = new String(body.getBytes(0, length), StandardCharsets.UTF_8);
|
||||
return body.length() > length ? preview + "...(truncated " + body.length() + " bytes)" : preview;
|
||||
}
|
||||
String text = res.bodyAsString();
|
||||
if (text == null || text.length() <= MAX_ERROR_BODY_CHARS) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, MAX_ERROR_BODY_CHARS) + "...(truncated " + text.length() + " chars)";
|
||||
} catch (Exception e) {
|
||||
return "<body preview failed: " + e.getMessage() + ">";
|
||||
}
|
||||
}
|
||||
|
||||
protected String getDomainName(){
|
||||
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将入口请求中的加密 auth 透传到子目录/下载链接,避免进入子目录后丢失认证。
|
||||
* otherParam 中的 key 为 {@code _authQuery}(由 web 层写入)。
|
||||
*/
|
||||
protected String appendAuthQuery(String url) {
|
||||
if (StringUtils.isBlank(url) || shareLinkInfo == null || shareLinkInfo.getOtherParam() == null) {
|
||||
return url;
|
||||
}
|
||||
Object authObj = shareLinkInfo.getOtherParam().get("_authQuery");
|
||||
if (authObj == null) {
|
||||
return url;
|
||||
}
|
||||
String auth = authObj.toString();
|
||||
if (StringUtils.isBlank(auth)) {
|
||||
return url;
|
||||
}
|
||||
// 已带 auth 则不再追加
|
||||
if (url.contains("auth=")) {
|
||||
return url;
|
||||
}
|
||||
try {
|
||||
String encoded = java.net.URLEncoder.encode(auth, StandardCharsets.UTF_8);
|
||||
return url + (url.contains("?") ? "&" : "?") + "auth=" + encoded;
|
||||
} catch (Exception e) {
|
||||
return url + (url.contains("?") ? "&" : "?") + "auth=" + auth;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShareLinkInfo getShareLinkInfo() {
|
||||
return shareLinkInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭代理模式下创建的 WebClient 资源
|
||||
* 非代理模式使用共享实例,不需要关闭
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
if (isProxyMode) {
|
||||
try {
|
||||
if (proxyClient != null) {
|
||||
proxyClient.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("关闭代理 WebClient 失败: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
if (proxyClientNoRedirects != null) {
|
||||
proxyClientNoRedirects.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("关闭代理 WebClientNoRedirects 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
@@ -175,7 +208,7 @@ public enum PanDomainTemplate {
|
||||
123795.com
|
||||
*/
|
||||
YE("123网盘",
|
||||
compile("https://www\\.(" +
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)*(" +
|
||||
"123254\\.com|" +
|
||||
"123957\\.com|" +
|
||||
"123295\\.com|" +
|
||||
@@ -199,7 +232,7 @@ public enum PanDomainTemplate {
|
||||
"123635\\.com|" +
|
||||
"123242\\.com|" +
|
||||
"123795\\.com" +
|
||||
")/s/(?<KEY>.+)(.html)?"),
|
||||
")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?<KEY>[a-zA-Z0-9]+-[a-zA-Z0-9]+|[a-zA-Z0-9_-]+)(?:\\.html)?(?:\\?.*)?"),
|
||||
"https://www.123pan.com/s/{shareKey}",
|
||||
Ye2Tool.class),
|
||||
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
|
||||
@@ -210,19 +243,20 @@ public enum PanDomainTemplate {
|
||||
EcTool.class),
|
||||
// https://cowtransfer.com/s/
|
||||
COW("奶牛快传",
|
||||
compile("https://(.*)cowtransfer\\.com/s/(?<KEY>.+)"),
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?cowtransfer\\.com/s/(?<KEY>.+)"),
|
||||
"https://cowtransfer.com/s/{shareKey}",
|
||||
CowTool.class),
|
||||
CT("城通网盘",
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
|
||||
"(?<KEY>[0-9a-zA-Z_-]+)(\\?p=(?<PWD>\\w+))?"),
|
||||
"https://474b.com/file/{shareKey}",
|
||||
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
|
||||
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
|
||||
"https://ctfile.com/file/{shareKey}",
|
||||
CtTool.class),
|
||||
// https://url94.ctfile.com/d/64115194-164803691-48508c?p=7609&d=164803691&fk=decb36
|
||||
CTD("城通网盘-目录",
|
||||
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/d/" +
|
||||
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
|
||||
"https://ctfile.com/d/{shareKey}",
|
||||
CtTool.class),
|
||||
// https://xxx.118pan.com/bxxx
|
||||
P118("118网盘",
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?118pan\\.com/b(?<KEY>.+)"),
|
||||
"https://qaiu.118pan.com/b{shareKey}",
|
||||
P118Tool.class),
|
||||
// https://www.vyuyun.com/s/QMa6ie?password=I4KG7H
|
||||
// https://www.vyuyun.com/s/QMa6ie/file?password=I4KG7H
|
||||
PVYY("微雨云存储",
|
||||
@@ -238,7 +272,7 @@ public enum PanDomainTemplate {
|
||||
PodTool.class),
|
||||
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
|
||||
PGD("GoogleDrive",
|
||||
compile("https://drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
|
||||
"https://drive.google.com/file/d/{shareKey}/view?usp=sharing",
|
||||
PgdTool.class),
|
||||
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
|
||||
@@ -248,11 +282,11 @@ public enum PanDomainTemplate {
|
||||
PicTool.class),
|
||||
// https://www.dropbox.com/scl/fi/cwnbms1yn8u6rcatzyta7/emqx-5.0.26-el7-amd64.tar.gz?rlkey=3uoi4bxz5mv93jmlaws0nlol1&e=8&st=fe0lclc2&dl=0
|
||||
PDB("dropbox",
|
||||
compile("https://www.dropbox.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
|
||||
compile("https://www\\.dropbox\\.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
|
||||
"https://www.dropbox.com/scl/fi/{shareKey}/?rlkey={pwd}&dl=0",
|
||||
PdbTool.class),
|
||||
P115("115网盘",
|
||||
compile("https://(115|anxia).com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
|
||||
compile("https://(115|anxia)\\.com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
|
||||
"https://115.com/s/{shareKey}?password={pwd}",
|
||||
P115Tool.class),
|
||||
// 链接:https://www.yunpan.com/surl_yD7wz4VgU9v(提取码:fc70)
|
||||
@@ -272,6 +306,27 @@ public enum PanDomainTemplate {
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?kdocs\\.cn/l/(?<KEY>.+)"),
|
||||
"https://www.kdocs.cn/l/{shareKey}",
|
||||
PwpsTool.class),
|
||||
|
||||
// https://fast.uc.cn/s/33197dd53ace4
|
||||
// https://drive.uc.cn/s/e623b6da278e4?public=1#/list/share
|
||||
UC("UC网盘",
|
||||
compile("https://(fast|drive)\\.uc\\.cn/s/(?<KEY>\\w+)(\\?public=\\d+)?([&#].*)?"),
|
||||
"https://drive.uc.cn/s/{shareKey}",
|
||||
UcTool.class),
|
||||
// https://pan.quark.cn/s/6a325cdaec58
|
||||
QK("夸克网盘",
|
||||
compile("https://pan\\.quark\\.cn/s/(?<KEY>\\w+)([&#].*)?"),
|
||||
"https://pan.quark.cn/s/{shareKey}",
|
||||
QkTool.class),
|
||||
|
||||
// https://xxx.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc
|
||||
// https://xxx.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg
|
||||
FS("飞书云盘",
|
||||
compile("https://[^.]+\\.feishu\\.cn/(?:file|drive/folder)/(?<KEY>[A-Za-z0-9_-]+)(\\?.*)?"),
|
||||
"https://feishu.cn/file/{shareKey}",
|
||||
"https://www.feishu.cn/",
|
||||
FsTool.class),
|
||||
|
||||
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
|
||||
// http://163cn.tv/xxx
|
||||
MNES("网易云音乐分享",
|
||||
@@ -280,7 +335,7 @@ public enum PanDomainTemplate {
|
||||
MnesTool.class),
|
||||
// https://music.163.com/#/song?id=xxx
|
||||
MNE("网易云音乐歌曲详情",
|
||||
compile("https://(y.)?music\\.163\\.com/(#|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
|
||||
compile("https://(y\\.)?music\\.163\\.com/(?:#/|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
|
||||
"https://music.163.com/#/song?id={shareKey}",
|
||||
MnesTool.MneTool.class),
|
||||
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
|
||||
@@ -301,7 +356,7 @@ public enum PanDomainTemplate {
|
||||
MkgsTool.class),
|
||||
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
|
||||
MKGS2("酷狗音乐分享2",
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+).html.*"),
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+)\\.html.*"),
|
||||
"https://www.kugou.com/share/{shareKey}.html",
|
||||
MkgsTool.Mkgs2Tool.class),
|
||||
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
|
||||
@@ -322,6 +377,16 @@ public enum PanDomainTemplate {
|
||||
MmgTool.class),
|
||||
// =====================私有盘解析==========================
|
||||
|
||||
// 永硕E盘空间分享:https://qaiu.ysepan.com/ (空间名即 shareKey,密码为空间访问密码)
|
||||
// 主域名 ysepan.com / ys168.com;备用 cccpan.com / ysupan.com / uupan.net / ysok.net
|
||||
YS("永硕E盘",
|
||||
compile("https?://(?!(?:www|zy|ht|api|c\\d+|ys-[a-zA-Z0-9]+)\\.)(?<KEY>[a-zA-Z\\d-]+)\\."
|
||||
+ "(?:ysepan\\.com|ys168\\.com|cccpan\\.com|ysupan\\.com|uupan\\.net|ysok\\.net)"
|
||||
+ "/?(?:\\?.*)?"),
|
||||
"https://{shareKey}.ysepan.com/",
|
||||
"https://www.ysepan.com/",
|
||||
YsTool.class),
|
||||
|
||||
// Cloudreve自定义域名解析, 解析器CeTool兜底策略, 即任意域名如果匹配不到对应的规则, 则由CeTool统一处理,
|
||||
// 如果不属于Cloudreve盘 则调用下一个自定义域名解析器, 若都处理不了则抛出异常, 这种匹配模式类似责任链
|
||||
// http(s)://pan.huang1111.cn/s/xxx
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
import static cn.qaiu.parser.PanDomainTemplate.KEY;
|
||||
@@ -23,6 +25,9 @@ import static cn.qaiu.parser.PanDomainTemplate.PWD;
|
||||
* Create at 2024/9/15 14:10
|
||||
*/
|
||||
public class ParserCreate {
|
||||
private static final Set<PanDomainTemplate> GENERIC_BUILT_IN_PARSERS =
|
||||
EnumSet.of(PanDomainTemplate.CE, PanDomainTemplate.KD, PanDomainTemplate.OTHER);
|
||||
|
||||
private final PanDomainTemplate panDomainTemplate;
|
||||
private final ShareLinkInfo shareLinkInfo;
|
||||
|
||||
@@ -81,16 +86,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()
|
||||
@@ -132,12 +137,12 @@ public class ParserCreate {
|
||||
if (StringUtils.isNotEmpty(pwd)) {
|
||||
shareLinkInfo.setSharePassword(pwd);
|
||||
}
|
||||
standardUrl = standardUrl.replace("{pwd}", pwd);
|
||||
} catch (Exception ignored) {}
|
||||
standardUrl = standardUrl.replace("{pwd}", StringUtils.defaultString(pwd));
|
||||
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||
|
||||
shareLinkInfo.setShareUrl(shareUrl);
|
||||
shareLinkInfo.setShareKey(shareKey);
|
||||
if (!(panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal())) {
|
||||
if (!isGenericBuiltInParser(panDomainTemplate)) {
|
||||
shareLinkInfo.setStandardUrl(standardUrl);
|
||||
}
|
||||
return this;
|
||||
@@ -197,7 +202,7 @@ public class ParserCreate {
|
||||
}
|
||||
|
||||
// 内置解析器处理
|
||||
if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
|
||||
if (isGenericBuiltInParser(panDomainTemplate)) {
|
||||
// 处理Cloudreve(ce)类: pan.huang1111.cn_s_wDz5TK _ -> /
|
||||
String[] s = shareKey.split("_");
|
||||
String standardUrl = "https://" + String.join("/", s);
|
||||
@@ -247,9 +252,19 @@ public class ParserCreate {
|
||||
return this;
|
||||
}
|
||||
|
||||
// 根据分享链接获取PanDomainTemplate实例(优先匹配自定义解析器)
|
||||
// 根据分享链接获取PanDomainTemplate实例
|
||||
public synchronized static ParserCreate fromShareUrl(String shareUrl) {
|
||||
// 优先查找支持正则匹配的自定义解析器
|
||||
if (StringUtils.isBlank(shareUrl)) {
|
||||
throw new IllegalArgumentException("shareUrl不能为空");
|
||||
}
|
||||
shareUrl = shareUrl.trim();
|
||||
|
||||
ParserCreate builtInParser = fromBuiltInShareUrl(shareUrl, false);
|
||||
if (builtInParser != null) {
|
||||
return builtInParser;
|
||||
}
|
||||
|
||||
// 明确内置解析器未命中时,再查找支持正则匹配的自定义解析器
|
||||
for (CustomParserConfig customConfig : CustomParserRegistry.getAll().values()) {
|
||||
if (customConfig.supportsFromShareUrl()) {
|
||||
Matcher matcher = customConfig.getMatchPattern().matcher(shareUrl);
|
||||
@@ -266,15 +281,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()
|
||||
@@ -295,22 +310,35 @@ public class ParserCreate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找内置解析器
|
||||
|
||||
// 最后再走 Cloudreve/可道云/其他网盘这类泛化兜底,避免抢走自定义解析器
|
||||
builtInParser = fromBuiltInShareUrl(shareUrl, true);
|
||||
if (builtInParser != null) {
|
||||
return builtInParser;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unsupported share URL");
|
||||
}
|
||||
|
||||
private static ParserCreate fromBuiltInShareUrl(String shareUrl, boolean genericOnly) {
|
||||
for (PanDomainTemplate panDomainTemplate : PanDomainTemplate.values()) {
|
||||
boolean genericParser = isGenericBuiltInParser(panDomainTemplate);
|
||||
if (genericOnly != genericParser) {
|
||||
continue;
|
||||
}
|
||||
if (panDomainTemplate.getPattern().matcher(shareUrl).matches()) {
|
||||
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
||||
.type(panDomainTemplate.name().toLowerCase())
|
||||
.panName(panDomainTemplate.getDisplayName())
|
||||
.shareUrl(shareUrl).build();
|
||||
if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
|
||||
if (isGenericBuiltInParser(panDomainTemplate)) {
|
||||
shareLinkInfo.setStandardUrl(shareUrl);
|
||||
}
|
||||
ParserCreate parserCreate = new ParserCreate(panDomainTemplate, shareLinkInfo);
|
||||
return parserCreate.normalizeShareLink();
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported share URL");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据type获取枚举实例(优先查找自定义解析器)
|
||||
@@ -353,7 +381,7 @@ public class ParserCreate {
|
||||
// 自定义解析器处理
|
||||
if (isCustomParser) {
|
||||
path = this.shareLinkInfo.getType() + "/" + this.shareLinkInfo.getShareKey();
|
||||
} else if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
|
||||
} else if (isGenericBuiltInParser(panDomainTemplate)) {
|
||||
// 处理Cloudreve(ce)类: pan.huang1111.cn_s_wDz5TK _ -> /
|
||||
path = this.shareLinkInfo.getType() + "/"
|
||||
+ this.shareLinkInfo.getShareUrl()
|
||||
@@ -381,7 +409,11 @@ public class ParserCreate {
|
||||
public CustomParserConfig getCustomParserConfig() {
|
||||
return customParserConfig;
|
||||
}
|
||||
|
||||
|
||||
private static boolean isGenericBuiltInParser(PanDomainTemplate panDomainTemplate) {
|
||||
return GENERIC_BUILT_IN_PARSERS.contains(panDomainTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内置解析器模板(仅当isCustomParser为false时有效)
|
||||
* @return 内置解析器模板,如果是自定义解析器则返回null
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package cn.qaiu.parser;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 解析器 Token/Cookie 缓存 — 支持多账号隔离。
|
||||
* <p>
|
||||
* 以 (diskType + "#" + accountKey) 作为缓存 key,不同账号的 token 互不覆盖。
|
||||
* accountKey 优先使用 _configId,其次使用 username、cookie 前16位等可区分标识。
|
||||
* </p>
|
||||
*/
|
||||
public final class TokenCache {
|
||||
|
||||
private TokenCache() {}
|
||||
|
||||
/** token 缓存 */
|
||||
private static final ConcurrentHashMap<String, String> tokenMap = new ConcurrentHashMap<>();
|
||||
/** 过期时间缓存(毫秒时间戳) */
|
||||
private static final ConcurrentHashMap<String, Long> expireMap = new ConcurrentHashMap<>();
|
||||
/** 同一 key 下的额外字符串缓存(如 userId) */
|
||||
private static final ConcurrentHashMap<String, String> extraMap = new ConcurrentHashMap<>();
|
||||
/** 布尔标记缓存(如 authFlag) */
|
||||
private static final ConcurrentHashMap<String, Boolean> flagMap = new ConcurrentHashMap<>();
|
||||
|
||||
// ============ key 构造 ============
|
||||
|
||||
public static String key(String diskType, String accountKey) {
|
||||
return diskType + "#" + (accountKey == null ? "_default" : accountKey);
|
||||
}
|
||||
|
||||
// ============ token ============
|
||||
|
||||
public static String getToken(String cacheKey) {
|
||||
return tokenMap.get(cacheKey);
|
||||
}
|
||||
|
||||
public static void putToken(String cacheKey, String token) {
|
||||
if (token == null) {
|
||||
tokenMap.remove(cacheKey);
|
||||
} else {
|
||||
tokenMap.put(cacheKey, token);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ expire ============
|
||||
|
||||
public static long getExpire(String cacheKey) {
|
||||
return expireMap.getOrDefault(cacheKey, 0L);
|
||||
}
|
||||
|
||||
public static void putExpire(String cacheKey, long expireMs) {
|
||||
expireMap.put(cacheKey, expireMs);
|
||||
}
|
||||
|
||||
public static boolean isExpired(String cacheKey) {
|
||||
long exp = getExpire(cacheKey);
|
||||
return exp <= 0 || System.currentTimeMillis() > exp;
|
||||
}
|
||||
|
||||
// ============ extra (userId 等) ============
|
||||
|
||||
public static String getExtra(String cacheKey) {
|
||||
return extraMap.get(cacheKey);
|
||||
}
|
||||
|
||||
public static void putExtra(String cacheKey, String value) {
|
||||
if (value == null) {
|
||||
extraMap.remove(cacheKey);
|
||||
} else {
|
||||
extraMap.put(cacheKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ flag (authFlag 等) ============
|
||||
|
||||
public static boolean getFlag(String cacheKey, boolean defaultValue) {
|
||||
return flagMap.getOrDefault(cacheKey, defaultValue);
|
||||
}
|
||||
|
||||
public static void putFlag(String cacheKey, boolean value) {
|
||||
flagMap.put(cacheKey, value);
|
||||
}
|
||||
|
||||
// ============ 清除 ============
|
||||
|
||||
public static void remove(String cacheKey) {
|
||||
tokenMap.remove(cacheKey);
|
||||
expireMap.remove(cacheKey);
|
||||
extraMap.remove(cacheKey);
|
||||
flagMap.remove(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定网盘类型的所有缓存(精准清除,不影响其他网盘类型)
|
||||
*/
|
||||
public static void removeByDiskType(String diskType) {
|
||||
String prefix = diskType + "#";
|
||||
tokenMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
expireMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
extraMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
flagMap.keySet().removeIf(k -> k.startsWith(prefix));
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
tokenMap.clear();
|
||||
expireMap.clear();
|
||||
extraMap.clear();
|
||||
flagMap.clear();
|
||||
}
|
||||
|
||||
// ============ Token 持久化队列 ============
|
||||
|
||||
/** 待持久化的 cachedToken 数据 (cacheKey -> [token, expireMs]) */
|
||||
private static final ConcurrentHashMap<String, String[]> persistQueue = new ConcurrentHashMap<>();
|
||||
/** 待回写的凭据更新 (cacheKey -> newCredential),如 PaliTool refresh_token 轮换 */
|
||||
private static final ConcurrentHashMap<String, String> credentialUpdateQueue = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 解析器登录成功后,将 token 加入持久化队列(下次 recordConfigUsage 回写 DB)
|
||||
*/
|
||||
public static void queueCachedTokenPersist(String cacheKey, String token, long expireMs) {
|
||||
if (cacheKey != null && token != null) {
|
||||
persistQueue.put(cacheKey, new String[]{token, String.valueOf(expireMs)});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭据本身被替换(如 PaliTool refresh_token 轮换),加入回写队列
|
||||
*/
|
||||
public static void queueCredentialUpdate(String cacheKey, String newCredential) {
|
||||
if (cacheKey != null && newCredential != null) {
|
||||
credentialUpdateQueue.put(cacheKey, newCredential);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费持久化队列:返回 [token, expireMs] 并移除;无数据返回 null
|
||||
*/
|
||||
public static String[] pollCachedTokenPersist(String cacheKey) {
|
||||
return cacheKey == null ? null : persistQueue.remove(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费凭据更新队列:返回新凭据并移除;无数据返回 null
|
||||
*/
|
||||
public static String pollCredentialUpdate(String cacheKey) {
|
||||
return cacheKey == null ? null : credentialUpdateQueue.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 客户端下载链接生成器工厂类
|
||||
* <p>
|
||||
* 支持的客户端类型:
|
||||
* <ul>
|
||||
* <li>CURL - cURL 命令,支持 Cookie</li>
|
||||
* <li>ARIA2 - Aria2 命令,支持 Cookie</li>
|
||||
* <li>THUNDER - 迅雷协议,不支持 Cookie</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
@@ -25,16 +32,10 @@ public class ClientLinkGeneratorFactory {
|
||||
// 静态初始化块,注册默认的生成器
|
||||
static {
|
||||
try {
|
||||
// 注册默认生成器 - 按指定顺序注册
|
||||
register(new Aria2LinkGenerator());
|
||||
register(new MotrixLinkGenerator());
|
||||
register(new BitCometLinkGenerator());
|
||||
register(new ThunderLinkGenerator());
|
||||
register(new WgetLinkGenerator());
|
||||
register(new CurlLinkGenerator());
|
||||
register(new IdmLinkGenerator());
|
||||
register(new FdmLinkGenerator());
|
||||
register(new PowerShellLinkGenerator());
|
||||
// 注册默认生成器 - 只保留3种(按需求)
|
||||
register(new CurlLinkGenerator()); // cURL 命令,支持 Cookie
|
||||
register(new Aria2LinkGenerator()); // Aria2 命令,支持 Cookie
|
||||
register(new ThunderLinkGenerator()); // 迅雷协议,不支持 Cookie
|
||||
|
||||
log.info("客户端链接生成器工厂初始化完成,已注册 {} 个生成器", generators.size());
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -2,27 +2,32 @@ package cn.qaiu.parser.clientlink;
|
||||
|
||||
/**
|
||||
* 客户端下载工具类型枚举
|
||||
* <p>
|
||||
* 支持的客户端类型:
|
||||
* <ul>
|
||||
* <li>CURL - cURL 命令行工具,支持 Cookie</li>
|
||||
* <li>ARIA2 - 多线程下载器,支持 Cookie</li>
|
||||
* <li>THUNDER - 迅雷下载器,不支持 Cookie(使用迅雷协议)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public enum ClientLinkType {
|
||||
ARIA2("aria2", "Aria2"),
|
||||
MOTRIX("motrix", "Motrix"),
|
||||
BITCOMET("bitcomet", "比特彗星"),
|
||||
THUNDER("thunder", "迅雷"),
|
||||
WGET("wget", "wget 命令"),
|
||||
CURL("curl", "cURL 命令"),
|
||||
IDM("idm", "IDM"),
|
||||
FDM("fdm", "Free Download Manager"),
|
||||
POWERSHELL("powershell", "PowerShell");
|
||||
CURL("curl", "cURL 命令", true, "命令行下载工具,支持Cookie"),
|
||||
ARIA2("aria2", "Aria2", true, "多线程下载器,支持Cookie"),
|
||||
THUNDER("thunder", "迅雷", false, "迅雷下载器,不支持Cookie");
|
||||
|
||||
private final String code;
|
||||
private final String displayName;
|
||||
private final boolean supportsCookie;
|
||||
private final String description;
|
||||
|
||||
ClientLinkType(String code, String displayName) {
|
||||
ClientLinkType(String code, String displayName, boolean supportsCookie, String description) {
|
||||
this.code = code;
|
||||
this.displayName = displayName;
|
||||
this.supportsCookie = supportsCookie;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
@@ -33,6 +38,14 @@ public enum ClientLinkType {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public boolean isSupportsCookie() {
|
||||
return supportsCookie;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return displayName;
|
||||
|
||||
@@ -7,6 +7,13 @@ import java.util.Map;
|
||||
/**
|
||||
* 客户端下载链接生成工具类
|
||||
* 提供便捷的静态方法来生成各种客户端下载链接
|
||||
* <p>
|
||||
* 支持的客户端类型:
|
||||
* <ul>
|
||||
* <li>CURL - cURL 命令,支持 Cookie</li>
|
||||
* <li>ARIA2 - Aria2 命令,支持 Cookie</li>
|
||||
* <li>THUNDER - 迅雷协议,不支持 Cookie</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
@@ -35,7 +42,7 @@ public class ClientLinkUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 curl 命令
|
||||
* 生成 curl 命令(支持 Cookie)
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return curl 命令字符串
|
||||
@@ -45,17 +52,7 @@ public class ClientLinkUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 wget 命令
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return wget 命令字符串
|
||||
*/
|
||||
public static String generateWgetCommand(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.WGET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 aria2 命令
|
||||
* 生成 aria2 命令(支持 Cookie)
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return aria2 命令字符串
|
||||
@@ -65,7 +62,7 @@ public class ClientLinkUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成迅雷链接
|
||||
* 生成迅雷链接(不支持 Cookie)
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return 迅雷协议链接
|
||||
@@ -74,56 +71,6 @@ public class ClientLinkUtils {
|
||||
return generateClientLink(info, ClientLinkType.THUNDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 IDM 链接
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return IDM 协议链接
|
||||
*/
|
||||
public static String generateIdmLink(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.IDM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成比特彗星链接
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return 比特彗星协议链接
|
||||
*/
|
||||
public static String generateBitCometLink(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.BITCOMET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Motrix 导入格式
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return Motrix JSON 格式字符串
|
||||
*/
|
||||
public static String generateMotrixFormat(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.MOTRIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 FDM 导入格式
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return FDM 格式字符串
|
||||
*/
|
||||
public static String generateFdmFormat(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.FDM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 PowerShell 命令
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return PowerShell 命令字符串
|
||||
*/
|
||||
public static String generatePowerShellCommand(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.POWERSHELL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 ShareLinkInfo 是否包含有效的下载元数据
|
||||
*
|
||||
|
||||
@@ -41,6 +41,8 @@ public class Aria2LinkGenerator implements ClientLinkGenerator {
|
||||
parts.add("--continue"); // 支持断点续传
|
||||
parts.add("--max-tries=3"); // 最大重试次数
|
||||
parts.add("--retry-wait=5"); // 重试等待时间
|
||||
parts.add("-s 8"); // 分成8片段下载
|
||||
parts.add("-x 8"); // 每个服务器使用8个连接
|
||||
|
||||
// 添加URL
|
||||
parts.add("\"" + meta.getUrl() + "\"");
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 比特彗星协议链接生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class BitCometLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 比特彗星支持 HTTP 下载,格式类似 IDM
|
||||
String encodedUrl = Base64.getEncoder().encodeToString(
|
||||
meta.getUrl().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
StringBuilder link = new StringBuilder("bitcomet:///?url=").append(encodedUrl);
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
StringBuilder headerStr = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (headerStr.length() > 0) {
|
||||
headerStr.append("\\r\\n");
|
||||
}
|
||||
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
}
|
||||
|
||||
String encodedHeaders = Base64.getEncoder().encodeToString(
|
||||
headerStr.toString().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&header=").append(encodedHeaders);
|
||||
}
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
String encodedFileName = Base64.getEncoder().encodeToString(
|
||||
meta.getFileName().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&filename=").append(encodedFileName);
|
||||
}
|
||||
|
||||
return link.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
// 如果编码失败,返回简单的URL
|
||||
return "bitcomet:///?url=" + meta.getUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.BITCOMET;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Free Download Manager 导入格式生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class FdmLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// FDM 支持简单的文本格式导入
|
||||
StringBuilder result = new StringBuilder();
|
||||
result.append("URL=").append(meta.getUrl()).append("\n");
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
result.append("Filename=").append(meta.getFileName()).append("\n");
|
||||
}
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
result.append("Headers=");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (!first) {
|
||||
result.append("; ");
|
||||
}
|
||||
result.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
first = false;
|
||||
}
|
||||
result.append("\n");
|
||||
}
|
||||
|
||||
result.append("Referer=").append(meta.getReferer() != null ? meta.getReferer() : "").append("\n");
|
||||
result.append("User-Agent=").append(meta.getUserAgent() != null ? meta.getUserAgent() : "").append("\n");
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.FDM;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IDM 协议链接生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class IdmLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 对URL进行Base64编码
|
||||
String encodedUrl = Base64.getEncoder().encodeToString(
|
||||
meta.getUrl().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
StringBuilder link = new StringBuilder("idm:///?url=").append(encodedUrl);
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
StringBuilder headerStr = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (headerStr.length() > 0) {
|
||||
headerStr.append("\\r\\n");
|
||||
}
|
||||
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
}
|
||||
|
||||
String encodedHeaders = Base64.getEncoder().encodeToString(
|
||||
headerStr.toString().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&header=").append(encodedHeaders);
|
||||
}
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
String encodedFileName = Base64.getEncoder().encodeToString(
|
||||
meta.getFileName().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&filename=").append(encodedFileName);
|
||||
}
|
||||
|
||||
return link.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
// 如果编码失败,返回简单的URL
|
||||
return "idm:///?url=" + meta.getUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.IDM;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Motrix 导入格式生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class MotrixLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 使用 Vert.x JsonObject 构建 JSON
|
||||
JsonObject taskJson = new JsonObject();
|
||||
taskJson.put("url", meta.getUrl());
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
taskJson.put("filename", meta.getFileName());
|
||||
}
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
JsonObject headersJson = new JsonObject();
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
headersJson.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
taskJson.put("headers", headersJson);
|
||||
}
|
||||
|
||||
// 设置输出文件名
|
||||
String outputFile = meta.getFileName() != null ? meta.getFileName() : "";
|
||||
taskJson.put("out", outputFile);
|
||||
|
||||
return taskJson.encodePrettily();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.MOTRIX;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* PowerShell 命令生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class PowerShellLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> lines = new ArrayList<>();
|
||||
|
||||
// 创建 WebRequestSession
|
||||
lines.add("$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession");
|
||||
|
||||
// 设置 User-Agent(如果存在)
|
||||
String userAgent = meta.getUserAgent();
|
||||
if (userAgent == null && meta.getHeaders() != null) {
|
||||
userAgent = meta.getHeaders().get("User-Agent");
|
||||
}
|
||||
if (userAgent != null && !userAgent.trim().isEmpty()) {
|
||||
lines.add("$session.UserAgent = \"" + escapePowerShellString(userAgent) + "\"");
|
||||
}
|
||||
|
||||
// 构建 Invoke-WebRequest 命令
|
||||
List<String> invokeParams = new ArrayList<>();
|
||||
invokeParams.add("Invoke-WebRequest");
|
||||
invokeParams.add("-UseBasicParsing");
|
||||
invokeParams.add("-Uri \"" + escapePowerShellString(meta.getUrl()) + "\"");
|
||||
|
||||
// 添加 WebSession
|
||||
invokeParams.add("-WebSession $session");
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
List<String> headerLines = new ArrayList<>();
|
||||
headerLines.add("-Headers @{");
|
||||
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (!first) {
|
||||
headerLines.add("");
|
||||
}
|
||||
headerLines.add(" \"" + escapePowerShellString(entry.getKey()) + "\"=\"" +
|
||||
escapePowerShellString(entry.getValue()) + "\"");
|
||||
first = false;
|
||||
}
|
||||
|
||||
headerLines.add("}");
|
||||
|
||||
// 将头部参数添加到主命令中
|
||||
invokeParams.add(String.join("`\n", headerLines));
|
||||
}
|
||||
|
||||
// 设置输出文件(如果指定了文件名)
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
invokeParams.add("-OutFile \"" + escapePowerShellString(meta.getFileName()) + "\"");
|
||||
}
|
||||
|
||||
// 将所有参数连接起来
|
||||
String invokeCommand = String.join(" `\n", invokeParams);
|
||||
lines.add(invokeCommand);
|
||||
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 PowerShell 字符串中的特殊字符
|
||||
*/
|
||||
private String escapePowerShellString(String str) {
|
||||
if (str == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return str.replace("`", "``")
|
||||
.replace("\"", "`\"")
|
||||
.replace("$", "`$");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.POWERSHELL;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* wget 命令生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class WgetLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.add("wget");
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
parts.add("--header=\"" + entry.getKey() + ": " + entry.getValue() + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
// 设置输出文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
parts.add("-O");
|
||||
parts.add("\"" + meta.getFileName() + "\"");
|
||||
}
|
||||
|
||||
// 添加URL
|
||||
parts.add("\"" + meta.getUrl() + "\"");
|
||||
|
||||
return String.join(" \\\n ", parts);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.WGET;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
public class CustomParserRegistry {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CustomParserRegistry.class);
|
||||
private static final int MAX_CUSTOM_PARSERS = Integer.getInteger("parser.custom.maxRegistrySize", 256);
|
||||
|
||||
/**
|
||||
* 存储自定义解析器配置的Map,key为类型标识,value为配置对象
|
||||
@@ -33,7 +34,7 @@ public class CustomParserRegistry {
|
||||
* @param config 解析器配置
|
||||
* @throws IllegalArgumentException 如果type已存在或与内置解析器冲突
|
||||
*/
|
||||
public static void register(CustomParserConfig config) {
|
||||
public static synchronized void register(CustomParserConfig config) {
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("config不能为空");
|
||||
}
|
||||
@@ -59,6 +60,11 @@ public class CustomParserRegistry {
|
||||
"类型标识 '" + type + "' 已被注册,请先注销或使用其他标识"
|
||||
);
|
||||
}
|
||||
if (CUSTOM_PARSERS.size() >= MAX_CUSTOM_PARSERS) {
|
||||
throw new IllegalArgumentException(
|
||||
"自定义解析器数量已达到上限(" + MAX_CUSTOM_PARSERS + "个),请先注销不需要的解析器"
|
||||
);
|
||||
}
|
||||
|
||||
CUSTOM_PARSERS.put(type, config);
|
||||
log.info("注册自定义解析器成功: {} ({})", config.getDisplayName(), type);
|
||||
@@ -171,7 +177,7 @@ public class CustomParserRegistry {
|
||||
* @param type 解析器类型标识
|
||||
* @return 是否注销成功
|
||||
*/
|
||||
public static boolean unregister(String type) {
|
||||
public static synchronized boolean unregister(String type) {
|
||||
if (type == null || type.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
@@ -213,7 +219,7 @@ public class CustomParserRegistry {
|
||||
/**
|
||||
* 清空所有自定义解析器
|
||||
*/
|
||||
public static void clear() {
|
||||
public static synchronized void clear() {
|
||||
CUSTOM_PARSERS.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,21 @@ package cn.qaiu.parser.customjs;
|
||||
|
||||
import cn.qaiu.WebClientVertxInit;
|
||||
import cn.qaiu.util.HttpResponseHelper;
|
||||
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.http.HttpClient;
|
||||
import io.vertx.core.http.HttpClientOptions;
|
||||
import io.vertx.core.http.HttpClientRequest;
|
||||
import io.vertx.core.http.HttpClientResponse;
|
||||
import io.vertx.core.http.HttpHeaders;
|
||||
import io.vertx.core.http.HttpMethod;
|
||||
import io.vertx.core.http.RequestOptions;
|
||||
import io.vertx.core.json.Json;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.net.ProxyOptions;
|
||||
import io.vertx.core.net.ProxyType;
|
||||
import io.vertx.ext.web.client.HttpRequest;
|
||||
import io.vertx.ext.web.client.HttpResponse;
|
||||
import io.vertx.ext.web.client.WebClient;
|
||||
import io.vertx.ext.web.client.WebClientOptions;
|
||||
import io.vertx.ext.web.client.WebClientSession;
|
||||
import io.vertx.ext.web.multipart.MultipartForm;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -27,8 +29,13 @@ import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -39,11 +46,60 @@ import java.util.regex.Pattern;
|
||||
* Create at 2025/10/17
|
||||
*/
|
||||
public class JsHttpClient {
|
||||
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JsHttpClient.class);
|
||||
|
||||
private final WebClient client;
|
||||
private final WebClientSession clientSession;
|
||||
private static final int MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
|
||||
private static final int MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
|
||||
private static final int MAX_HEADER_COUNT = 64;
|
||||
private static final int MAX_HEADER_VALUE_LENGTH = 4096;
|
||||
private static final int MAX_TIMEOUT_SECONDS = 120;
|
||||
private static final int MAX_REDIRECTS = 5;
|
||||
private static final String DEFAULT_ACCEPT_ENCODING = "gzip, deflate, br";
|
||||
|
||||
private static final Object SHARED_CLIENT_LOCK = new Object();
|
||||
// 共享 HttpClient 实例(非代理模式),懒加载避免类初始化阶段抢跑 Vert.x。
|
||||
private static volatile HttpClient sharedClient;
|
||||
private static volatile boolean sharedClientShutdown = false;
|
||||
|
||||
/**
|
||||
* 关闭共享 HttpClient(应用关闭时调用)
|
||||
*/
|
||||
public static void shutdownSharedClient() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
sharedClientShutdown = true;
|
||||
if (sharedClient != null) {
|
||||
sharedClient.close();
|
||||
sharedClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient sharedClient() {
|
||||
synchronized (SHARED_CLIENT_LOCK) {
|
||||
ensureSharedClientAvailable();
|
||||
if (sharedClient == null) {
|
||||
sharedClient = WebClientVertxInit.get().createHttpClient(
|
||||
new HttpClientOptions()
|
||||
.setConnectTimeout(10000)
|
||||
.setIdleTimeout(30)
|
||||
.setIdleTimeoutUnit(TimeUnit.SECONDS)
|
||||
.setMaxPoolSize(64));
|
||||
}
|
||||
return sharedClient;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureSharedClientAvailable() {
|
||||
if (sharedClientShutdown) {
|
||||
throw new IllegalStateException("共享 JavaScript HttpClient 已关闭");
|
||||
}
|
||||
}
|
||||
|
||||
private final HttpClient client;
|
||||
private final boolean ownClient; // 标记是否为自建 client(需要 close)
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final Object requestLock = new Object();
|
||||
private final Set<HttpClientRequest> activeRequests = ConcurrentHashMap.newKeySet();
|
||||
private MultiMap headers;
|
||||
private int timeoutSeconds = 30; // 默认超时时间30秒
|
||||
|
||||
@@ -61,11 +117,12 @@ public class JsHttpClient {
|
||||
};
|
||||
|
||||
public JsHttpClient() {
|
||||
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());;
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
ensureSharedClientAvailable();
|
||||
this.client = sharedClient();
|
||||
this.ownClient = false;
|
||||
this.headers = MultiMap.caseInsensitiveMultiMap();
|
||||
// 设置默认的Accept-Encoding头以支持压缩响应
|
||||
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
|
||||
// 设置默认的User-Agent头
|
||||
this.headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
|
||||
// 设置默认的Accept-Language头
|
||||
@@ -77,31 +134,35 @@ public class JsHttpClient {
|
||||
* @param proxyConfig 代理配置JsonObject,包含type、host、port、username、password
|
||||
*/
|
||||
public JsHttpClient(JsonObject proxyConfig) {
|
||||
ensureSharedClientAvailable();
|
||||
if (proxyConfig != null && proxyConfig.containsKey("type")) {
|
||||
ProxyOptions proxyOptions = new ProxyOptions()
|
||||
.setType(ProxyType.valueOf(proxyConfig.getString("type").toUpperCase()))
|
||||
.setHost(proxyConfig.getString("host"))
|
||||
.setPort(proxyConfig.getInteger("port"));
|
||||
|
||||
|
||||
if (StringUtils.isNotEmpty(proxyConfig.getString("username"))) {
|
||||
proxyOptions.setUsername(proxyConfig.getString("username"));
|
||||
}
|
||||
if (StringUtils.isNotEmpty(proxyConfig.getString("password"))) {
|
||||
proxyOptions.setPassword(proxyConfig.getString("password"));
|
||||
}
|
||||
|
||||
this.client = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions()
|
||||
.setUserAgentEnabled(false)
|
||||
|
||||
this.client = WebClientVertxInit.get().createHttpClient(
|
||||
new HttpClientOptions()
|
||||
.setConnectTimeout(10000)
|
||||
.setIdleTimeout(30)
|
||||
.setIdleTimeoutUnit(TimeUnit.SECONDS)
|
||||
.setMaxPoolSize(16)
|
||||
.setProxyOptions(proxyOptions));
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
this.ownClient = true;
|
||||
} else {
|
||||
this.client = WebClient.create(WebClientVertxInit.get());
|
||||
this.clientSession = WebClientSession.create(client);
|
||||
this.client = sharedClient();
|
||||
this.ownClient = false;
|
||||
}
|
||||
this.headers = MultiMap.caseInsensitiveMultiMap();
|
||||
// 设置默认的Accept-Encoding头以支持压缩响应
|
||||
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
|
||||
// 设置默认的User-Agent头
|
||||
this.headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
|
||||
// 设置默认的Accept-Language头
|
||||
@@ -183,13 +244,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpResponse get(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.getAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
return request.send();
|
||||
});
|
||||
return executeRequest(HttpMethod.GET, url, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,16 +253,25 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse getWithRedirect(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.getAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
String currentUrl = url;
|
||||
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
||||
validateUrlSecurity(currentUrl);
|
||||
JsHttpResponse response = executeRequest(HttpMethod.GET, currentUrl, null, false);
|
||||
if (!isRedirectStatus(response.statusCode())) {
|
||||
return response;
|
||||
}
|
||||
// 设置跟随重定向
|
||||
request.followRedirects(true);
|
||||
return request.send();
|
||||
});
|
||||
|
||||
if (redirectCount == MAX_REDIRECTS) {
|
||||
throw new RuntimeException("重定向次数超过限制: " + MAX_REDIRECTS);
|
||||
}
|
||||
|
||||
String location = response.header(HttpHeaders.LOCATION.toString());
|
||||
if (StringUtils.isBlank(location)) {
|
||||
throw new RuntimeException("重定向响应缺少Location头");
|
||||
}
|
||||
currentUrl = resolveRedirectUrl(currentUrl, location);
|
||||
}
|
||||
throw new RuntimeException("重定向处理失败");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,15 +281,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpResponse getNoRedirect(String url) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.getAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
// 设置不跟随重定向
|
||||
request.followRedirects(false);
|
||||
return request.send();
|
||||
});
|
||||
return executeRequest(HttpMethod.GET, url, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,26 +292,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpResponse post(String url, Object data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
if (data instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) data));
|
||||
} else if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> mapData = (Map<String, String>) data;
|
||||
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
|
||||
} else {
|
||||
return request.sendJson(data);
|
||||
}
|
||||
} else {
|
||||
return request.send();
|
||||
}
|
||||
});
|
||||
return executeRequest(HttpMethod.POST, url, bodyFromData(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,26 +303,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpResponse put(String url, Object data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.putAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
if (data instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) data));
|
||||
} else if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> mapData = (Map<String, String>) data;
|
||||
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
|
||||
} else {
|
||||
return request.sendJson(data);
|
||||
}
|
||||
} else {
|
||||
return request.send();
|
||||
}
|
||||
});
|
||||
return executeRequest(HttpMethod.PUT, url, bodyFromData(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,13 +312,8 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse delete(String url) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.deleteAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
return request.send();
|
||||
});
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.DELETE, url, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,26 +323,8 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse patch(String url, Object data) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.patchAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
if (data instanceof String) {
|
||||
return request.sendBuffer(Buffer.buffer((String) data));
|
||||
} else if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> mapData = (Map<String, String>) data;
|
||||
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
|
||||
} else {
|
||||
return request.sendJson(data);
|
||||
}
|
||||
} else {
|
||||
return request.send();
|
||||
}
|
||||
});
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.PATCH, url, bodyFromData(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,6 +335,12 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpClient putHeader(String name, String value) {
|
||||
if (name != null && value != null) {
|
||||
if (headers.size() >= MAX_HEADER_COUNT && !headers.contains(name)) {
|
||||
throw new IllegalArgumentException("请求头数量超过限制");
|
||||
}
|
||||
if (value.length() > MAX_HEADER_VALUE_LENGTH) {
|
||||
throw new IllegalArgumentException("请求头过长: " + name);
|
||||
}
|
||||
headers.set(name, value);
|
||||
}
|
||||
return this;
|
||||
@@ -353,9 +354,7 @@ public class JsHttpClient {
|
||||
public JsHttpClient putHeaders(Map<String, String> headersMap) {
|
||||
if (headersMap != null) {
|
||||
for (Map.Entry<String, String> entry : headersMap.entrySet()) {
|
||||
if (entry.getKey() != null && entry.getValue() != null) {
|
||||
headers.set(entry.getKey(), entry.getValue());
|
||||
}
|
||||
putHeader(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
return this;
|
||||
@@ -380,7 +379,7 @@ public class JsHttpClient {
|
||||
public JsHttpClient clearHeaders() {
|
||||
headers.clear();
|
||||
// 重新设置默认头
|
||||
headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
|
||||
headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
|
||||
headers.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6");
|
||||
return this;
|
||||
@@ -405,7 +404,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public JsHttpClient setTimeout(int seconds) {
|
||||
if (seconds > 0) {
|
||||
this.timeoutSeconds = seconds;
|
||||
this.timeoutSeconds = Math.min(seconds, MAX_TIMEOUT_SECONDS);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
@@ -450,19 +449,12 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse sendForm(Map<String, String> data) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs("");
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
MultiMap formData = MultiMap.caseInsensitiveMultiMap();
|
||||
if (data != null) {
|
||||
formData.addAll(data);
|
||||
}
|
||||
|
||||
return request.sendForm(formData);
|
||||
});
|
||||
throw new IllegalArgumentException("sendForm(data) 缺少请求URL,请使用 post(url, data)");
|
||||
}
|
||||
|
||||
public JsHttpResponse sendForm(String url, Map<String, String> data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.POST, url, formBody(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,34 +466,8 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse sendMultipartForm(String url, Map<String, Object> data) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs(url);
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
MultipartForm form = MultipartForm.create();
|
||||
|
||||
if (data != null) {
|
||||
for (Map.Entry<String, Object> entry : data.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
|
||||
if (value instanceof String) {
|
||||
form.attribute(key, (String) value);
|
||||
} else if (value instanceof byte[]) {
|
||||
form.binaryFileUpload(key, key, Buffer.buffer((byte[]) value), "application/octet-stream");
|
||||
} else if (value instanceof Buffer) {
|
||||
form.binaryFileUpload(key, key, (Buffer) value, "application/octet-stream");
|
||||
} else if (value != null) {
|
||||
// 其他类型转换为字符串
|
||||
form.attribute(key, value.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return request.sendMultipartForm(form);
|
||||
});
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.POST, url, multipartBody(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,44 +476,103 @@ public class JsHttpClient {
|
||||
* @return HTTP响应
|
||||
*/
|
||||
public JsHttpResponse sendJson(Object data) {
|
||||
return executeRequest(() -> {
|
||||
HttpRequest<Buffer> request = client.postAbs("");
|
||||
if (!headers.isEmpty()) {
|
||||
request.putHeaders(headers);
|
||||
}
|
||||
|
||||
return request.sendJson(data);
|
||||
});
|
||||
throw new IllegalArgumentException("sendJson(data) 缺少请求URL,请使用 post(url, data)");
|
||||
}
|
||||
|
||||
public JsHttpResponse sendJson(String url, Object data) {
|
||||
validateUrlSecurity(url);
|
||||
return executeRequest(HttpMethod.POST, url, jsonBody(data), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行HTTP请求(同步)
|
||||
*/
|
||||
private JsHttpResponse executeRequest(RequestExecutor executor) {
|
||||
private JsHttpResponse executeRequest(HttpMethod method, String url, RequestBody requestBody, boolean followRedirects) {
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("HTTP客户端已关闭");
|
||||
}
|
||||
AtomicReference<HttpClientRequest> requestRef = new AtomicReference<>();
|
||||
AtomicBoolean abandoned = new AtomicBoolean(false);
|
||||
try {
|
||||
Promise<HttpResponse<Buffer>> promise = Promise.promise();
|
||||
Future<HttpResponse<Buffer>> future = executor.execute();
|
||||
|
||||
future.onComplete(result -> {
|
||||
if (result.succeeded()) {
|
||||
promise.complete(result.result());
|
||||
} else {
|
||||
promise.fail(result.cause());
|
||||
Promise<JsHttpResponse> promise = Promise.promise();
|
||||
|
||||
RequestOptions options = new RequestOptions()
|
||||
.setMethod(method)
|
||||
.setAbsoluteURI(url)
|
||||
.setFollowRedirects(followRedirects)
|
||||
.setTimeout(TimeUnit.SECONDS.toMillis(timeoutSeconds))
|
||||
.setHeaders(MultiMap.caseInsensitiveMultiMap().setAll(headers));
|
||||
|
||||
client.request(options).onComplete(ar -> {
|
||||
if (ar.failed()) {
|
||||
promise.tryFail(ar.cause());
|
||||
return;
|
||||
}
|
||||
}).onFailure(Throwable::printStackTrace);
|
||||
|
||||
// 等待响应完成(使用配置的超时时间)
|
||||
HttpResponse<Buffer> response = promise.future().toCompletionStage()
|
||||
|
||||
HttpClientRequest request = ar.result();
|
||||
synchronized (requestLock) {
|
||||
if (closed.get() || abandoned.get()) {
|
||||
request.reset();
|
||||
promise.tryFail("HTTP客户端已关闭");
|
||||
return;
|
||||
}
|
||||
activeRequests.add(request);
|
||||
requestRef.set(request);
|
||||
request.exceptionHandler(e -> {
|
||||
finishRequest(request);
|
||||
promise.tryFail(e);
|
||||
});
|
||||
request.response().onComplete(responseAr -> {
|
||||
if (responseAr.succeeded()) {
|
||||
collectResponse(request, responseAr.result(), promise);
|
||||
} else {
|
||||
finishRequest(request);
|
||||
promise.tryFail(responseAr.cause());
|
||||
}
|
||||
});
|
||||
|
||||
if (closed.get() || abandoned.get()) {
|
||||
request.reset();
|
||||
finishRequest(request);
|
||||
promise.tryFail("HTTP客户端已关闭");
|
||||
return;
|
||||
}
|
||||
if (requestBody == null || requestBody.body() == null) {
|
||||
request.end().onFailure(e -> {
|
||||
finishRequest(request);
|
||||
promise.tryFail(e);
|
||||
});
|
||||
} else {
|
||||
request.headers().set(HttpHeaders.CONTENT_LENGTH, String.valueOf(requestBody.body().length()));
|
||||
if (StringUtils.isNotEmpty(requestBody.contentType())) {
|
||||
request.headers().set(HttpHeaders.CONTENT_TYPE, requestBody.contentType());
|
||||
}
|
||||
request.end(requestBody.body()).onFailure(e -> {
|
||||
finishRequest(request);
|
||||
promise.tryFail(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return promise.future().toCompletionStage()
|
||||
.toCompletableFuture()
|
||||
.get(timeoutSeconds, TimeUnit.SECONDS);
|
||||
|
||||
return new JsHttpResponse(response);
|
||||
|
||||
|
||||
} catch (TimeoutException e) {
|
||||
// RequestOptions timeout 通常会先触发;这里再兜底,避免等待线程返回后请求还在后台下载。
|
||||
String errorMsg = "HTTP请求超时(" + timeoutSeconds + "秒)";
|
||||
abandoned.set(true);
|
||||
synchronized (requestLock) {
|
||||
abortRequest(requestRef);
|
||||
}
|
||||
log.error(errorMsg, e);
|
||||
throw new RuntimeException(errorMsg, e);
|
||||
} catch (Exception e) {
|
||||
abandoned.set(true);
|
||||
synchronized (requestLock) {
|
||||
abortRequest(requestRef);
|
||||
}
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg == null || errorMsg.trim().isEmpty()) {
|
||||
errorMsg = e.getClass().getSimpleName();
|
||||
@@ -559,13 +584,196 @@ public class JsHttpClient {
|
||||
throw new RuntimeException("HTTP请求执行失败: " + errorMsg, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求执行器接口
|
||||
*/
|
||||
@FunctionalInterface
|
||||
private interface RequestExecutor {
|
||||
Future<HttpResponse<Buffer>> execute();
|
||||
|
||||
private static boolean isRedirectStatus(int statusCode) {
|
||||
return statusCode == 301 || statusCode == 302 || statusCode == 303
|
||||
|| statusCode == 307 || statusCode == 308;
|
||||
}
|
||||
|
||||
private String resolveRedirectUrl(String currentUrl, String location) {
|
||||
try {
|
||||
URI redirectUri = new URI(currentUrl).resolve(location.trim());
|
||||
String scheme = redirectUri.getScheme();
|
||||
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
|
||||
throw new SecurityException("🔒 安全拦截: 重定向协议不被允许");
|
||||
}
|
||||
String redirectUrl = redirectUri.toString();
|
||||
validateUrlSecurity(redirectUrl);
|
||||
return redirectUrl;
|
||||
} catch (SecurityException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("解析重定向地址失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectResponse(HttpClientRequest request, HttpClientResponse response, Promise<JsHttpResponse> promise) {
|
||||
Buffer body = Buffer.buffer();
|
||||
AtomicBoolean done = new AtomicBoolean(false);
|
||||
|
||||
String contentLengthHeader = response.getHeader(HttpHeaders.CONTENT_LENGTH.toString());
|
||||
if (StringUtils.isNumeric(contentLengthHeader)) {
|
||||
long contentLength = Long.parseLong(contentLengthHeader);
|
||||
if (contentLength > MAX_RESPONSE_BODY_BYTES) {
|
||||
done.set(true);
|
||||
request.reset();
|
||||
finishRequest(request);
|
||||
promise.tryFail("响应体过大: " + contentLength + " bytes");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
response.exceptionHandler(e -> {
|
||||
if (done.compareAndSet(false, true)) {
|
||||
finishRequest(request);
|
||||
promise.tryFail(e);
|
||||
}
|
||||
});
|
||||
response.handler(chunk -> {
|
||||
if (done.get()) {
|
||||
return;
|
||||
}
|
||||
if (body.length() + chunk.length() > MAX_RESPONSE_BODY_BYTES) {
|
||||
if (done.compareAndSet(false, true)) {
|
||||
request.reset();
|
||||
finishRequest(request);
|
||||
promise.tryFail("响应体过大: " + (body.length() + chunk.length()) + " bytes");
|
||||
}
|
||||
return;
|
||||
}
|
||||
body.appendBuffer(chunk);
|
||||
});
|
||||
response.endHandler(v -> {
|
||||
if (done.compareAndSet(false, true)) {
|
||||
finishRequest(request);
|
||||
promise.tryComplete(new JsHttpResponse(
|
||||
response.statusCode(),
|
||||
MultiMap.caseInsensitiveMultiMap().setAll(response.headers()),
|
||||
body,
|
||||
response.statusMessage(),
|
||||
null
|
||||
));
|
||||
}
|
||||
});
|
||||
response.resume();
|
||||
}
|
||||
|
||||
private void finishRequest(HttpClientRequest request) {
|
||||
if (request != null) {
|
||||
activeRequests.remove(request);
|
||||
}
|
||||
}
|
||||
|
||||
private void abortRequest(AtomicReference<HttpClientRequest> requestRef) {
|
||||
HttpClientRequest request = requestRef.get();
|
||||
if (request != null) {
|
||||
try {
|
||||
request.reset();
|
||||
} finally {
|
||||
finishRequest(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private RequestBody bodyFromData(Object data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
if (data instanceof String str) {
|
||||
return plainTextBody(str);
|
||||
}
|
||||
if (data instanceof Buffer buffer) {
|
||||
return limitedBody(buffer, null);
|
||||
}
|
||||
if (data instanceof byte[] bytes) {
|
||||
return limitedBody(Buffer.buffer(bytes), null);
|
||||
}
|
||||
if (data instanceof Map<?, ?> map) {
|
||||
Map<String, String> formMap = new HashMap<>();
|
||||
map.forEach((key, value) -> {
|
||||
if (key != null && value != null) {
|
||||
formMap.put(String.valueOf(key), String.valueOf(value));
|
||||
}
|
||||
});
|
||||
return formBody(formMap);
|
||||
}
|
||||
return jsonBody(data);
|
||||
}
|
||||
|
||||
private RequestBody plainTextBody(String data) {
|
||||
return limitedBody(Buffer.buffer(data, StandardCharsets.UTF_8.name()), null);
|
||||
}
|
||||
|
||||
private RequestBody jsonBody(Object data) {
|
||||
Buffer body = data == null ? Buffer.buffer() : Buffer.buffer(Json.encode(data), StandardCharsets.UTF_8.name());
|
||||
return limitedBody(body, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
private RequestBody formBody(Map<String, String> data) {
|
||||
StringBuilder encoded = new StringBuilder();
|
||||
if (data != null) {
|
||||
for (Map.Entry<String, String> entry : data.entrySet()) {
|
||||
if (encoded.length() > 0) {
|
||||
encoded.append('&');
|
||||
}
|
||||
encoded.append(urlEncode(entry.getKey()));
|
||||
encoded.append('=');
|
||||
encoded.append(urlEncode(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return limitedBody(Buffer.buffer(encoded.toString(), StandardCharsets.UTF_8.name()),
|
||||
"application/x-www-form-urlencoded; charset=utf-8");
|
||||
}
|
||||
|
||||
private RequestBody multipartBody(Map<String, Object> data) {
|
||||
String boundary = "----NetdiskJsHttpClientBoundary" + UUID.randomUUID().toString().replace("-", "");
|
||||
Buffer body = Buffer.buffer();
|
||||
if (data != null) {
|
||||
for (Map.Entry<String, Object> entry : data.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (key == null || value == null) {
|
||||
continue;
|
||||
}
|
||||
appendAscii(body, "--" + boundary + "\r\n");
|
||||
if (value instanceof byte[] bytes) {
|
||||
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key)
|
||||
+ "\"; filename=\"" + escapeMultipart(key) + "\"\r\n");
|
||||
appendAscii(body, "Content-Type: application/octet-stream\r\n\r\n");
|
||||
body.appendBytes(bytes);
|
||||
appendAscii(body, "\r\n");
|
||||
} else if (value instanceof Buffer buffer) {
|
||||
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key)
|
||||
+ "\"; filename=\"" + escapeMultipart(key) + "\"\r\n");
|
||||
appendAscii(body, "Content-Type: application/octet-stream\r\n\r\n");
|
||||
body.appendBuffer(buffer);
|
||||
appendAscii(body, "\r\n");
|
||||
} else {
|
||||
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key) + "\"\r\n\r\n");
|
||||
body.appendString(String.valueOf(value), StandardCharsets.UTF_8.name());
|
||||
appendAscii(body, "\r\n");
|
||||
}
|
||||
ensureRequestBodyLimit(body);
|
||||
}
|
||||
}
|
||||
appendAscii(body, "--" + boundary + "--\r\n");
|
||||
return limitedBody(body, "multipart/form-data; boundary=" + boundary);
|
||||
}
|
||||
|
||||
private static void appendAscii(Buffer body, String value) {
|
||||
body.appendString(value, StandardCharsets.US_ASCII.name());
|
||||
}
|
||||
|
||||
private static String escapeMultipart(String value) {
|
||||
return value.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private static RequestBody limitedBody(Buffer body, String contentType) {
|
||||
ensureRequestBodyLimit(body);
|
||||
return new RequestBody(body, contentType);
|
||||
}
|
||||
|
||||
private record RequestBody(Buffer body, String contentType) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,10 +781,29 @@ public class JsHttpClient {
|
||||
*/
|
||||
public static class JsHttpResponse {
|
||||
|
||||
private final HttpResponse<Buffer> response;
|
||||
private final int statusCode;
|
||||
private final MultiMap headers;
|
||||
private final Buffer body;
|
||||
private final String statusMessage;
|
||||
private final HttpResponse<Buffer> originalResponse;
|
||||
|
||||
public JsHttpResponse(HttpResponse<Buffer> response) {
|
||||
this.response = response;
|
||||
this(
|
||||
response.statusCode(),
|
||||
MultiMap.caseInsensitiveMultiMap().setAll(response.headers()),
|
||||
response.body(),
|
||||
response.statusMessage(),
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
public JsHttpResponse(int statusCode, MultiMap headers, Buffer body, String statusMessage,
|
||||
HttpResponse<Buffer> originalResponse) {
|
||||
this.statusCode = statusCode;
|
||||
this.headers = headers == null ? MultiMap.caseInsensitiveMultiMap() : headers;
|
||||
this.body = body == null ? Buffer.buffer() : body;
|
||||
this.statusMessage = statusMessage;
|
||||
this.originalResponse = originalResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -584,7 +811,7 @@ public class JsHttpClient {
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
public String body() {
|
||||
return HttpResponseHelper.asText(response);
|
||||
return HttpResponseHelper.asText(body, header(HttpHeaders.CONTENT_ENCODING.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -593,7 +820,7 @@ public class JsHttpClient {
|
||||
*/
|
||||
public Object json() {
|
||||
try {
|
||||
JsonObject jsonObject = HttpResponseHelper.asJson(response);
|
||||
JsonObject jsonObject = HttpResponseHelper.asJson(body, header(HttpHeaders.CONTENT_ENCODING.toString()));
|
||||
if (jsonObject == null || jsonObject.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -611,7 +838,7 @@ public class JsHttpClient {
|
||||
* @return 状态码
|
||||
*/
|
||||
public int statusCode() {
|
||||
return response.statusCode();
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -620,7 +847,7 @@ public class JsHttpClient {
|
||||
* @return 头值
|
||||
*/
|
||||
public String header(String name) {
|
||||
return response.getHeader(name);
|
||||
return headers.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -628,10 +855,9 @@ public class JsHttpClient {
|
||||
* @return 响应头Map
|
||||
*/
|
||||
public Map<String, String> headers() {
|
||||
MultiMap responseHeaders = response.headers();
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (String name : responseHeaders.names()) {
|
||||
result.put(name, responseHeaders.get(name));
|
||||
for (String name : headers.names()) {
|
||||
result.put(name, headers.get(name));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -649,8 +875,14 @@ public class JsHttpClient {
|
||||
* 获取原始响应对象
|
||||
* @return HttpResponse对象
|
||||
*/
|
||||
@Deprecated
|
||||
public HttpResponse<Buffer> getOriginalResponse() {
|
||||
return response;
|
||||
if (originalResponse == null) {
|
||||
throw new UnsupportedOperationException(
|
||||
"流式HTTP客户端不再保留原始Vert.x HttpResponse,请使用statusCode/header/headers/body/bodyBytes方法"
|
||||
);
|
||||
}
|
||||
return originalResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -658,11 +890,8 @@ public class JsHttpClient {
|
||||
* @return 响应体字节数组
|
||||
*/
|
||||
public byte[] bodyBytes() {
|
||||
Buffer buffer = response.body();
|
||||
if (buffer == null) {
|
||||
return new byte[0];
|
||||
}
|
||||
return buffer.getBytes();
|
||||
ensureResponseBodyLimit(body);
|
||||
return body.getBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -670,11 +899,46 @@ public class JsHttpClient {
|
||||
* @return 响应体大小(字节)
|
||||
*/
|
||||
public long bodySize() {
|
||||
Buffer buffer = response.body();
|
||||
if (buffer == null) {
|
||||
return 0;
|
||||
return body.length();
|
||||
}
|
||||
|
||||
public String statusMessage() {
|
||||
return statusMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 HttpClient 释放连接池资源
|
||||
* 仅关闭自建的 client(代理模式),共享实例不关闭
|
||||
*/
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
synchronized (requestLock) {
|
||||
for (HttpClientRequest request : activeRequests) {
|
||||
try {
|
||||
request.reset();
|
||||
} catch (Exception e) {
|
||||
log.debug("重置 JavaScript HTTP 请求失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
return buffer.length();
|
||||
activeRequests.clear();
|
||||
}
|
||||
if (ownClient && client != null) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureResponseBodyLimit(Buffer buffer) {
|
||||
if (buffer != null && buffer.length() > MAX_RESPONSE_BODY_BYTES) {
|
||||
throw new IllegalArgumentException("响应体过大: " + buffer.length() + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureRequestBodyLimit(Buffer buffer) {
|
||||
if (buffer != null && buffer.length() > MAX_REQUEST_BODY_BYTES) {
|
||||
throw new IllegalArgumentException("请求体过大: " + buffer.length() + " bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.IPanTool;
|
||||
import cn.qaiu.parser.custom.CustomParserConfig;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.WorkerExecutor;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory;
|
||||
@@ -20,6 +21,13 @@ import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -29,21 +37,46 @@ 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 String FETCH_RUNTIME_JS = null;
|
||||
|
||||
private static volatile WorkerExecutor EXECUTOR;
|
||||
private static final Object EXECUTOR_LOCK = new Object();
|
||||
private static volatile boolean executorShutdown = false;
|
||||
|
||||
/** 安全网调度器:当 onComplete 未触发时,延迟强制释放资源 */
|
||||
private static final ScheduledExecutorService CLEANUP_SCHEDULER =
|
||||
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "js-parser-cleanup-safety");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
private static final long EXECUTION_TIMEOUT_SECONDS = 30;
|
||||
private static final int MAX_RESULT_STRING_LENGTH = 1024 * 1024;
|
||||
private static final int MAX_FILE_LIST_SIZE = 1000;
|
||||
private static final int MAX_FILE_FIELD_LENGTH = 4096;
|
||||
private static final int MAX_CONCURRENT_EXECUTIONS =
|
||||
Math.max(1, Integer.getInteger("parser.custom.js.maxConcurrentExecutions", 32));
|
||||
private static final Semaphore EXECUTION_PERMITS = new Semaphore(MAX_CONCURRENT_EXECUTIONS);
|
||||
|
||||
private static volatile String FETCH_RUNTIME_JS = null;
|
||||
|
||||
private final CustomParserConfig config;
|
||||
private final ShareLinkInfo shareLinkInfo;
|
||||
private final ScriptEngine engine;
|
||||
private volatile ScriptEngine engine;
|
||||
private final Object engineLock = new Object();
|
||||
private final JsHttpClient httpClient;
|
||||
private final JsLogger jsLogger;
|
||||
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
|
||||
private final JsFetchBridge fetchBridge;
|
||||
/** 标记是否已释放,防止重复关闭 */
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final Object lifecycleLock = new Object();
|
||||
private volatile boolean running = false;
|
||||
private volatile boolean closeRequested = false;
|
||||
/** 安全网定时任务句柄,正常完成时取消 */
|
||||
private volatile ScheduledFuture<?> safetyCleanupFuture = null;
|
||||
|
||||
public JsParserExecutor(ShareLinkInfo shareLinkInfo, CustomParserConfig config) {
|
||||
this.config = config;
|
||||
@@ -59,7 +92,6 @@ public class JsParserExecutor implements IPanTool {
|
||||
this.jsLogger = new JsLogger("JsParser-" + config.getType());
|
||||
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
|
||||
this.fetchBridge = new JsFetchBridge(httpClient);
|
||||
this.engine = initEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +143,7 @@ public class JsParserExecutor implements IPanTool {
|
||||
if (engine == null) {
|
||||
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
|
||||
}
|
||||
this.engine = engine;
|
||||
|
||||
// 注入Java对象到JavaScript环境
|
||||
engine.put("http", httpClient);
|
||||
@@ -145,28 +178,207 @@ public class JsParserExecutor implements IPanTool {
|
||||
throw new RuntimeException("JavaScript引擎初始化失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private ScriptEngine engine() {
|
||||
ScriptEngine current = engine;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
synchronized (engineLock) {
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("JavaScript解析器已关闭");
|
||||
}
|
||||
if (engine == null) {
|
||||
engine = initEngine();
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
||||
private void beginExecution() {
|
||||
synchronized (lifecycleLock) {
|
||||
if (closed.get() || closeRequested) {
|
||||
throw new IllegalStateException("JavaScript解析器已关闭");
|
||||
}
|
||||
if (running) {
|
||||
throw new IllegalStateException("JavaScript解析器已在运行");
|
||||
}
|
||||
running = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void finishExecution() {
|
||||
synchronized (lifecycleLock) {
|
||||
running = false;
|
||||
if (closeRequested) {
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源(ScriptEngine 和 HttpClient),避免内存泄漏
|
||||
* 幂等:可安全多次调用
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (lifecycleLock) {
|
||||
closeRequested = true;
|
||||
cancelSafetyCleanup();
|
||||
if (running || closed.get()) {
|
||||
closeExternalResources();
|
||||
return;
|
||||
}
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
|
||||
private void doClose() {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
closeRequested = false;
|
||||
closeExternalResources();
|
||||
cleanupEngine();
|
||||
}
|
||||
|
||||
private void closeExternalResources() {
|
||||
if (httpClient != null) {
|
||||
httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupEngine() {
|
||||
// 清除 ScriptEngine 持有的所有引用和内部状态,帮助 GC 回收
|
||||
if (engine != null) {
|
||||
try {
|
||||
engine.put("http", null);
|
||||
engine.put("logger", null);
|
||||
engine.put("shareLinkInfo", null);
|
||||
engine.put("JavaFetch", null);
|
||||
// 彻底清除 ENGINE_SCOPE bindings,释放 JS AST、编译函数、闭包等运行时状态
|
||||
var bindings = engine.getBindings(javax.script.ScriptContext.ENGINE_SCOPE);
|
||||
if (bindings != null) {
|
||||
bindings.clear();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("清理 ScriptEngine bindings 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelSafetyCleanup() {
|
||||
// 取消安全网定时任务(如果正常完成则无需再触发)
|
||||
if (safetyCleanupFuture != null) {
|
||||
safetyCleanupFuture.cancel(false);
|
||||
safetyCleanupFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭全局 WorkerExecutor 和清理调度器(应在应用关闭时调用)
|
||||
*/
|
||||
public static void shutdownExecutor() {
|
||||
synchronized (EXECUTOR_LOCK) {
|
||||
executorShutdown = true;
|
||||
if (EXECUTOR != null) {
|
||||
EXECUTOR.close();
|
||||
EXECUTOR = null;
|
||||
log.info("JsParserExecutor WorkerExecutor 已关闭");
|
||||
}
|
||||
}
|
||||
CLEANUP_SCHEDULER.shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建 WorkerExecutor(懒加载)
|
||||
*/
|
||||
private static WorkerExecutor getExecutor() {
|
||||
synchronized (EXECUTOR_LOCK) {
|
||||
if (executorShutdown) {
|
||||
throw new IllegalStateException("JavaScript解析器 WorkerExecutor 已关闭");
|
||||
}
|
||||
if (EXECUTOR == null) {
|
||||
EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
|
||||
}
|
||||
return EXECUTOR;
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Future<T> executeBlockingWithPermit(String operation, Callable<T> blockingCode) {
|
||||
if (!EXECUTION_PERMITS.tryAcquire()) {
|
||||
String message = "JavaScript " + operation + " 执行并发已满,请稍后重试";
|
||||
jsLogger.error(message);
|
||||
close();
|
||||
return Future.failedFuture(message);
|
||||
}
|
||||
|
||||
try {
|
||||
return getExecutor().executeBlocking(() -> {
|
||||
boolean executionStarted = false;
|
||||
try {
|
||||
beginExecution();
|
||||
executionStarted = true;
|
||||
return blockingCode.call();
|
||||
} finally {
|
||||
if (executionStarted) {
|
||||
finishExecution();
|
||||
}
|
||||
EXECUTION_PERMITS.release();
|
||||
}
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
EXECUTION_PERMITS.release();
|
||||
close();
|
||||
return Future.failedFuture(e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Future<T> withTimeout(Future<T> executionFuture, String operation) {
|
||||
Promise<T> promise = Promise.promise();
|
||||
try {
|
||||
safetyCleanupFuture = CLEANUP_SCHEDULER.schedule(() -> {
|
||||
if (promise.tryFail("JavaScript " + operation + " 执行超时(" + EXECUTION_TIMEOUT_SECONDS + "秒)")) {
|
||||
jsLogger.error("{} 执行超时,已停止外部HTTP资源;ScriptEngine将在执行线程退出后清理", operation);
|
||||
close();
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.warn("安全网调度失败: {}", e.getMessage());
|
||||
}
|
||||
executionFuture.onComplete(ar -> {
|
||||
cancelSafetyCleanup();
|
||||
if (ar.succeeded()) {
|
||||
promise.tryComplete(ar.result());
|
||||
} else {
|
||||
promise.tryFail(ar.cause());
|
||||
}
|
||||
close();
|
||||
});
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
jsLogger.info("开始执行JavaScript解析器: {}", config.getType());
|
||||
|
||||
|
||||
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
||||
return EXECUTOR.executeBlocking(() -> {
|
||||
Future<String> executionFuture = executeBlockingWithPermit("parse", () -> {
|
||||
ScriptEngine engine = engine();
|
||||
// 直接调用全局parse函数
|
||||
Object parseFunction = engine.get("parse");
|
||||
if (parseFunction == null) {
|
||||
throw new RuntimeException("JavaScript代码中未找到parse函数");
|
||||
}
|
||||
|
||||
|
||||
if (parseFunction instanceof ScriptObjectMirror parseMirror) {
|
||||
|
||||
Object result = parseMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
||||
|
||||
|
||||
if (result instanceof String) {
|
||||
jsLogger.info("解析成功: {}", result);
|
||||
return (String) result;
|
||||
String resultText = limitResultString((String) result, "parse");
|
||||
jsLogger.info("解析成功,结果长度: {}", resultText.length());
|
||||
return resultText;
|
||||
} else {
|
||||
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
|
||||
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
|
||||
result != null ? result.getClass().getSimpleName() : "null");
|
||||
throw new RuntimeException("parse方法返回值类型错误");
|
||||
}
|
||||
@@ -174,32 +386,34 @@ public class JsParserExecutor implements IPanTool {
|
||||
throw new RuntimeException("parse函数类型错误");
|
||||
}
|
||||
});
|
||||
return withTimeout(executionFuture, "parse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType());
|
||||
|
||||
|
||||
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
||||
return EXECUTOR.executeBlocking(() -> {
|
||||
Future<List<FileInfo>> executionFuture = executeBlockingWithPermit("parseFileList", () -> {
|
||||
ScriptEngine engine = engine();
|
||||
// 直接调用全局parseFileList函数
|
||||
Object parseFileListFunction = engine.get("parseFileList");
|
||||
if (parseFileListFunction == null) {
|
||||
throw new RuntimeException("JavaScript代码中未找到parseFileList函数");
|
||||
}
|
||||
|
||||
|
||||
// 调用parseFileList方法
|
||||
if (parseFileListFunction instanceof ScriptObjectMirror parseFileListMirror) {
|
||||
|
||||
Object result = parseFileListMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
||||
|
||||
|
||||
if (result instanceof ScriptObjectMirror resultMirror) {
|
||||
List<FileInfo> fileList = convertToFileInfoList(resultMirror);
|
||||
|
||||
|
||||
jsLogger.info("文件列表解析成功,共 {} 个文件", fileList.size());
|
||||
return fileList;
|
||||
} else {
|
||||
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
|
||||
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
|
||||
result != null ? result.getClass().getSimpleName() : "null");
|
||||
throw new RuntimeException("parseFileList方法返回值类型错误");
|
||||
}
|
||||
@@ -207,30 +421,33 @@ public class JsParserExecutor implements IPanTool {
|
||||
throw new RuntimeException("parseFileList函数类型错误");
|
||||
}
|
||||
});
|
||||
return withTimeout(executionFuture, "parseFileList");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType());
|
||||
|
||||
|
||||
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
||||
return EXECUTOR.executeBlocking(() -> {
|
||||
Future<String> executionFuture = executeBlockingWithPermit("parseById", () -> {
|
||||
ScriptEngine engine = engine();
|
||||
// 直接调用全局parseById函数
|
||||
Object parseByIdFunction = engine.get("parseById");
|
||||
if (parseByIdFunction == null) {
|
||||
throw new RuntimeException("JavaScript代码中未找到parseById函数");
|
||||
}
|
||||
|
||||
|
||||
// 调用parseById方法
|
||||
if (parseByIdFunction instanceof ScriptObjectMirror parseByIdMirror) {
|
||||
|
||||
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
||||
|
||||
|
||||
if (result instanceof String) {
|
||||
jsLogger.info("按ID解析成功: {}", result);
|
||||
return (String) result;
|
||||
String resultText = limitResultString((String) result, "parseById");
|
||||
jsLogger.info("按ID解析成功,结果长度: {}", resultText.length());
|
||||
return resultText;
|
||||
} else {
|
||||
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
|
||||
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
|
||||
result != null ? result.getClass().getSimpleName() : "null");
|
||||
throw new RuntimeException("parseById方法返回值类型错误");
|
||||
}
|
||||
@@ -238,6 +455,7 @@ public class JsParserExecutor implements IPanTool {
|
||||
throw new RuntimeException("parseById函数类型错误");
|
||||
}
|
||||
});
|
||||
return withTimeout(executionFuture, "parseById");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,6 +465,9 @@ public class JsParserExecutor implements IPanTool {
|
||||
List<FileInfo> fileList = new ArrayList<>();
|
||||
|
||||
if (resultMirror.isArray()) {
|
||||
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
|
||||
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
|
||||
}
|
||||
for (int i = 0; i < resultMirror.size(); i++) {
|
||||
Object item = resultMirror.get(String.valueOf(i));
|
||||
if (item instanceof ScriptObjectMirror) {
|
||||
@@ -270,13 +491,13 @@ public class JsParserExecutor implements IPanTool {
|
||||
|
||||
// 设置基本字段
|
||||
if (itemMirror.hasMember("fileName")) {
|
||||
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
|
||||
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
|
||||
}
|
||||
if (itemMirror.hasMember("fileId")) {
|
||||
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
|
||||
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
|
||||
}
|
||||
if (itemMirror.hasMember("fileType")) {
|
||||
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
|
||||
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
|
||||
}
|
||||
if (itemMirror.hasMember("size")) {
|
||||
Object size = itemMirror.getMember("size");
|
||||
@@ -285,16 +506,16 @@ public class JsParserExecutor implements IPanTool {
|
||||
}
|
||||
}
|
||||
if (itemMirror.hasMember("sizeStr")) {
|
||||
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
|
||||
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
|
||||
}
|
||||
if (itemMirror.hasMember("createTime")) {
|
||||
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
|
||||
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
|
||||
}
|
||||
if (itemMirror.hasMember("updateTime")) {
|
||||
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
|
||||
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
|
||||
}
|
||||
if (itemMirror.hasMember("createBy")) {
|
||||
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
|
||||
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
|
||||
}
|
||||
if (itemMirror.hasMember("downloadCount")) {
|
||||
Object downloadCount = itemMirror.getMember("downloadCount");
|
||||
@@ -303,16 +524,16 @@ public class JsParserExecutor implements IPanTool {
|
||||
}
|
||||
}
|
||||
if (itemMirror.hasMember("fileIcon")) {
|
||||
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
|
||||
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
|
||||
}
|
||||
if (itemMirror.hasMember("panType")) {
|
||||
fileInfo.setPanType(itemMirror.getMember("panType").toString());
|
||||
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
|
||||
}
|
||||
if (itemMirror.hasMember("parserUrl")) {
|
||||
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
|
||||
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
|
||||
}
|
||||
if (itemMirror.hasMember("previewUrl")) {
|
||||
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
|
||||
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
|
||||
}
|
||||
|
||||
return fileInfo;
|
||||
@@ -322,4 +543,22 @@ public class JsParserExecutor implements IPanTool {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String limitResultString(String value, String operation) {
|
||||
if (value.length() > MAX_RESULT_STRING_LENGTH) {
|
||||
throw new RuntimeException(operation + " 返回结果过大: " + value.length() + " 字符");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String limitField(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString();
|
||||
if (text.length() > MAX_FILE_FIELD_LENGTH) {
|
||||
throw new RuntimeException("文件字段过长: " + text.length() + " 字符");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,20 +21,37 @@ import java.util.concurrent.*;
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
*/
|
||||
public class JsPlaygroundExecutor {
|
||||
public class JsPlaygroundExecutor implements AutoCloseable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JsPlaygroundExecutor.class);
|
||||
|
||||
// JavaScript执行超时时间(秒)
|
||||
private static final long EXECUTION_TIMEOUT_SECONDS = 30;
|
||||
private static final int MAX_RESULT_STRING_LENGTH = 1024 * 1024;
|
||||
private static final int MAX_FILE_LIST_SIZE = 1000;
|
||||
private static final int MAX_FILE_FIELD_LENGTH = 4096;
|
||||
private static final int TIMEOUT_LOG_RETAIN = 50;
|
||||
|
||||
// 使用独立的线程池,不受Vert.x的BlockedThreadChecker监控
|
||||
private static final ExecutorService INDEPENDENT_EXECUTOR = Executors.newCachedThreadPool(r -> {
|
||||
Thread thread = new Thread(r);
|
||||
thread.setName("playground-independent-" + System.currentTimeMillis());
|
||||
thread.setDaemon(true); // 设置为守护线程,服务关闭时自动清理
|
||||
return thread;
|
||||
});
|
||||
// 使用有界线程池,防止线程无限增长导致内存溢出
|
||||
private static final int POOL_MAX_THREADS = 16;
|
||||
private static final int POOL_QUEUE_CAPACITY = 256;
|
||||
private static final ExecutorService INDEPENDENT_EXECUTOR = new ThreadPoolExecutor(
|
||||
4, POOL_MAX_THREADS, 60L, TimeUnit.SECONDS,
|
||||
new ArrayBlockingQueue<>(POOL_QUEUE_CAPACITY),
|
||||
r -> {
|
||||
Thread thread = new Thread(r);
|
||||
thread.setName("playground-independent-" + thread.getId());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
},
|
||||
(r, executor) -> {
|
||||
// 拒绝策略:记录日志并抛出异常,避免阻塞 Vert.x EventLoop
|
||||
log.warn("演练场线程池已满,拒绝任务。活跃线程: {}, 队列大小: {}",
|
||||
((ThreadPoolExecutor) executor).getActiveCount(),
|
||||
((ThreadPoolExecutor) executor).getQueue().size());
|
||||
throw new java.util.concurrent.RejectedExecutionException("演练场线程池已满,请稍后重试");
|
||||
}
|
||||
);
|
||||
|
||||
// 超时调度线程池,用于处理超时中断
|
||||
private static final ScheduledExecutorService TIMEOUT_SCHEDULER = Executors.newScheduledThreadPool(2, r -> {
|
||||
@@ -43,14 +60,29 @@ public class JsPlaygroundExecutor {
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
/**
|
||||
* 关闭静态线程池(应在应用关闭时调用)
|
||||
*/
|
||||
public static void shutdownPools() {
|
||||
INDEPENDENT_EXECUTOR.shutdown();
|
||||
TIMEOUT_SCHEDULER.shutdown();
|
||||
log.info("JsPlaygroundExecutor 线程池已关闭");
|
||||
}
|
||||
|
||||
private final ShareLinkInfo shareLinkInfo;
|
||||
private final String jsCode;
|
||||
private final ScriptEngine engine;
|
||||
private volatile ScriptEngine engine;
|
||||
private final Object engineLock = new Object();
|
||||
private final JsHttpClient httpClient;
|
||||
private final JsPlaygroundLogger playgroundLogger;
|
||||
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
|
||||
private final JsFetchBridge fetchBridge;
|
||||
/** 标记是否已释放,防止重复关闭 */
|
||||
private volatile boolean closed = false;
|
||||
private final Object lifecycleLock = new Object();
|
||||
private volatile boolean running = false;
|
||||
private volatile boolean closeRequested = false;
|
||||
|
||||
/**
|
||||
* 创建演练场执行器
|
||||
@@ -72,7 +104,6 @@ public class JsPlaygroundExecutor {
|
||||
this.playgroundLogger = new JsPlaygroundLogger();
|
||||
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
|
||||
this.fetchBridge = new JsFetchBridge(httpClient);
|
||||
this.engine = initEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +120,7 @@ public class JsPlaygroundExecutor {
|
||||
if (engine == null) {
|
||||
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
|
||||
}
|
||||
this.engine = engine;
|
||||
|
||||
// 注入Java对象到JavaScript环境
|
||||
engine.put("http", httpClient);
|
||||
@@ -133,9 +165,14 @@ public class JsPlaygroundExecutor {
|
||||
*/
|
||||
public Future<String> executeParseAsync() {
|
||||
Promise<String> promise = Promise.promise();
|
||||
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
|
||||
final CompletableFuture<String> executionFuture;
|
||||
try {
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
beginExecution();
|
||||
try {
|
||||
ScriptEngine engine = engine();
|
||||
playgroundLogger.infoJava("开始执行parse方法");
|
||||
try {
|
||||
Object parseFunction = engine.get("parse");
|
||||
@@ -151,8 +188,9 @@ public class JsPlaygroundExecutor {
|
||||
log.debug("[JsPlaygroundExecutor] parse函数执行完成,当前日志数量: {}", playgroundLogger.size());
|
||||
|
||||
if (result instanceof String) {
|
||||
playgroundLogger.infoJava("解析成功,返回结果: " + result);
|
||||
return (String) result;
|
||||
String resultText = limitResultString((String) result, "parse");
|
||||
playgroundLogger.infoJava("解析成功,返回结果长度: " + resultText.length());
|
||||
return resultText;
|
||||
} else {
|
||||
String errorMsg = "parse方法返回值类型错误,期望String,实际: " +
|
||||
(result != null ? result.getClass().getSimpleName() : "null");
|
||||
@@ -167,25 +205,27 @@ public class JsPlaygroundExecutor {
|
||||
playgroundLogger.errorJava("执行parse方法失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
|
||||
// 创建超时任务,强制取消执行
|
||||
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true); // 强制中断执行线程
|
||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
||||
log.warn("JavaScript执行超时,已强制取消");
|
||||
} finally {
|
||||
finishExecution();
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||
log.warn("演练场线程池已满,任务被拒绝");
|
||||
close(); // 释放已创建的 ScriptEngine 和 HttpClient 资源
|
||||
promise.fail(new RuntimeException("演练场线程池已满,请稍后重试", e));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
ScheduledFuture<?> timeoutTask = scheduleTimeout(executionFuture, "parse");
|
||||
|
||||
// 处理执行结果
|
||||
executionFuture.whenComplete((result, error) -> {
|
||||
// 取消超时任务
|
||||
timeoutTask.cancel(false);
|
||||
|
||||
|
||||
if (error != null) {
|
||||
if (error instanceof CancellationException) {
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
|
||||
playgroundLogger.errorJava(timeoutMsg);
|
||||
log.error(timeoutMsg);
|
||||
promise.fail(new RuntimeException(timeoutMsg));
|
||||
@@ -197,10 +237,10 @@ public class JsPlaygroundExecutor {
|
||||
promise.complete(result);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 执行parseFileList方法(异步,带超时控制)
|
||||
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
|
||||
@@ -209,9 +249,14 @@ public class JsPlaygroundExecutor {
|
||||
*/
|
||||
public Future<List<FileInfo>> executeParseFileListAsync() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
CompletableFuture<List<FileInfo>> executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
|
||||
final CompletableFuture<List<FileInfo>> executionFuture;
|
||||
try {
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
beginExecution();
|
||||
try {
|
||||
ScriptEngine engine = engine();
|
||||
playgroundLogger.infoJava("开始执行parseFileList方法");
|
||||
try {
|
||||
Object parseFileListFunction = engine.get("parseFileList");
|
||||
@@ -242,25 +287,27 @@ public class JsPlaygroundExecutor {
|
||||
playgroundLogger.errorJava("执行parseFileList方法失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
|
||||
// 创建超时任务,强制取消执行
|
||||
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true); // 强制中断执行线程
|
||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
||||
log.warn("JavaScript执行超时,已强制取消");
|
||||
} finally {
|
||||
finishExecution();
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||
log.warn("演练场线程池已满,任务被拒绝");
|
||||
close(); // 释放已创建的 ScriptEngine 和 HttpClient 资源
|
||||
promise.fail(new RuntimeException("演练场线程池已满,请稍后重试", e));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
ScheduledFuture<?> timeoutTask = scheduleTimeout(executionFuture, "parseFileList");
|
||||
|
||||
// 处理执行结果
|
||||
executionFuture.whenComplete((result, error) -> {
|
||||
// 取消超时任务
|
||||
timeoutTask.cancel(false);
|
||||
|
||||
|
||||
if (error != null) {
|
||||
if (error instanceof CancellationException) {
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
|
||||
playgroundLogger.errorJava(timeoutMsg);
|
||||
log.error(timeoutMsg);
|
||||
promise.fail(new RuntimeException(timeoutMsg));
|
||||
@@ -272,10 +319,10 @@ public class JsPlaygroundExecutor {
|
||||
promise.complete(result);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 执行parseById方法(异步,带超时控制)
|
||||
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
|
||||
@@ -284,9 +331,14 @@ public class JsPlaygroundExecutor {
|
||||
*/
|
||||
public Future<String> executeParseByIdAsync() {
|
||||
Promise<String> promise = Promise.promise();
|
||||
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
|
||||
final CompletableFuture<String> executionFuture;
|
||||
try {
|
||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||
executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||
beginExecution();
|
||||
try {
|
||||
ScriptEngine engine = engine();
|
||||
playgroundLogger.infoJava("开始执行parseById方法");
|
||||
try {
|
||||
Object parseByIdFunction = engine.get("parseById");
|
||||
@@ -300,8 +352,9 @@ public class JsPlaygroundExecutor {
|
||||
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, playgroundLogger);
|
||||
|
||||
if (result instanceof String) {
|
||||
playgroundLogger.infoJava("按ID解析成功: " + result);
|
||||
return (String) result;
|
||||
String resultText = limitResultString((String) result, "parseById");
|
||||
playgroundLogger.infoJava("按ID解析成功,返回结果长度: " + resultText.length());
|
||||
return resultText;
|
||||
} else {
|
||||
String errorMsg = "parseById方法返回值类型错误,期望String,实际: " +
|
||||
(result != null ? result.getClass().getSimpleName() : "null");
|
||||
@@ -316,25 +369,27 @@ public class JsPlaygroundExecutor {
|
||||
playgroundLogger.errorJava("执行parseById方法失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
|
||||
// 创建超时任务,强制取消执行
|
||||
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true); // 强制中断执行线程
|
||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
||||
log.warn("JavaScript执行超时,已强制取消");
|
||||
} finally {
|
||||
finishExecution();
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
}, INDEPENDENT_EXECUTOR);
|
||||
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||
log.warn("演练场线程池已满,任务被拒绝");
|
||||
close(); // 释放已创建的 ScriptEngine 和 HttpClient 资源
|
||||
promise.fail(new RuntimeException("演练场线程池已满,请稍后重试", e));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
ScheduledFuture<?> timeoutTask = scheduleTimeout(executionFuture, "parseById");
|
||||
|
||||
// 处理执行结果
|
||||
executionFuture.whenComplete((result, error) -> {
|
||||
// 取消超时任务
|
||||
timeoutTask.cancel(false);
|
||||
|
||||
|
||||
if (error != null) {
|
||||
if (error instanceof CancellationException) {
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
|
||||
playgroundLogger.errorJava(timeoutMsg);
|
||||
log.error(timeoutMsg);
|
||||
promise.fail(new RuntimeException(timeoutMsg));
|
||||
@@ -346,7 +401,7 @@ public class JsPlaygroundExecutor {
|
||||
promise.complete(result);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
@@ -355,7 +410,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;
|
||||
}
|
||||
|
||||
@@ -373,6 +428,9 @@ public class JsPlaygroundExecutor {
|
||||
List<FileInfo> fileList = new ArrayList<>();
|
||||
|
||||
if (resultMirror.isArray()) {
|
||||
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
|
||||
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
|
||||
}
|
||||
for (int i = 0; i < resultMirror.size(); i++) {
|
||||
Object item = resultMirror.get(String.valueOf(i));
|
||||
if (item instanceof ScriptObjectMirror) {
|
||||
@@ -396,13 +454,13 @@ public class JsPlaygroundExecutor {
|
||||
|
||||
// 设置基本字段
|
||||
if (itemMirror.hasMember("fileName")) {
|
||||
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
|
||||
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
|
||||
}
|
||||
if (itemMirror.hasMember("fileId")) {
|
||||
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
|
||||
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
|
||||
}
|
||||
if (itemMirror.hasMember("fileType")) {
|
||||
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
|
||||
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
|
||||
}
|
||||
if (itemMirror.hasMember("size")) {
|
||||
Object size = itemMirror.getMember("size");
|
||||
@@ -411,16 +469,16 @@ public class JsPlaygroundExecutor {
|
||||
}
|
||||
}
|
||||
if (itemMirror.hasMember("sizeStr")) {
|
||||
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
|
||||
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
|
||||
}
|
||||
if (itemMirror.hasMember("createTime")) {
|
||||
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
|
||||
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
|
||||
}
|
||||
if (itemMirror.hasMember("updateTime")) {
|
||||
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
|
||||
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
|
||||
}
|
||||
if (itemMirror.hasMember("createBy")) {
|
||||
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
|
||||
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
|
||||
}
|
||||
if (itemMirror.hasMember("downloadCount")) {
|
||||
Object downloadCount = itemMirror.getMember("downloadCount");
|
||||
@@ -429,24 +487,154 @@ public class JsPlaygroundExecutor {
|
||||
}
|
||||
}
|
||||
if (itemMirror.hasMember("fileIcon")) {
|
||||
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
|
||||
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
|
||||
}
|
||||
if (itemMirror.hasMember("panType")) {
|
||||
fileInfo.setPanType(itemMirror.getMember("panType").toString());
|
||||
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
|
||||
}
|
||||
if (itemMirror.hasMember("parserUrl")) {
|
||||
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
|
||||
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
|
||||
}
|
||||
if (itemMirror.hasMember("previewUrl")) {
|
||||
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
|
||||
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
|
||||
}
|
||||
|
||||
return fileInfo;
|
||||
|
||||
|
||||
} catch (Exception e) {
|
||||
playgroundLogger.errorJava("转换FileInfo对象失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String limitResultString(String value, String operation) {
|
||||
if (value.length() > MAX_RESULT_STRING_LENGTH) {
|
||||
throw new RuntimeException(operation + " 返回结果过大: " + value.length() + " 字符");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String limitField(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString();
|
||||
if (text.length() > MAX_FILE_FIELD_LENGTH) {
|
||||
throw new RuntimeException("文件字段过长: " + text.length() + " 字符");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private void beginExecution() {
|
||||
synchronized (lifecycleLock) {
|
||||
if (closed) {
|
||||
throw new CancellationException("演练场执行器已关闭");
|
||||
}
|
||||
if (running) {
|
||||
throw new IllegalStateException("演练场执行器已在运行");
|
||||
}
|
||||
running = true;
|
||||
}
|
||||
}
|
||||
|
||||
private ScriptEngine engine() {
|
||||
ScriptEngine current = engine;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
synchronized (engineLock) {
|
||||
if (closed) {
|
||||
throw new CancellationException("演练场执行器已关闭");
|
||||
}
|
||||
if (engine == null) {
|
||||
engine = initEngine();
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
||||
private void finishExecution() {
|
||||
synchronized (lifecycleLock) {
|
||||
running = false;
|
||||
if (closeRequested) {
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ScheduledFuture<?> scheduleTimeout(CompletableFuture<?> executionFuture, String operation) {
|
||||
// cancel(true) 只能请求中断,Nashorn 死循环不保证立即停止。
|
||||
return TIMEOUT_SCHEDULER.schedule(() -> {
|
||||
if (!executionFuture.isDone()) {
|
||||
executionFuture.cancel(true);
|
||||
playgroundLogger.errorJava(operation + " 执行超时,已请求取消并停止外部HTTP资源");
|
||||
forceCloseAfterTimeout();
|
||||
log.warn("JavaScript {} 执行超时,已请求取消;Nashorn长循环可能继续占用线程,ScriptEngine将在执行线程退出后清理", operation);
|
||||
}
|
||||
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void forceCloseAfterTimeout() {
|
||||
synchronized (lifecycleLock) {
|
||||
closeRequested = true;
|
||||
if (running || closed) {
|
||||
closeExternalResources();
|
||||
} else {
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
playgroundLogger.trimToLast(TIMEOUT_LOG_RETAIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源(HttpClient 和 ScriptEngine),避免内存泄漏
|
||||
* 幂等:可安全多次调用
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (lifecycleLock) {
|
||||
closeRequested = true;
|
||||
if (running || closed) {
|
||||
closeExternalResources();
|
||||
return;
|
||||
}
|
||||
doClose();
|
||||
}
|
||||
}
|
||||
|
||||
private void doClose() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
closeRequested = false;
|
||||
closeExternalResources();
|
||||
cleanupEngine();
|
||||
log.debug("JsPlaygroundExecutor 资源已释放");
|
||||
}
|
||||
|
||||
private void closeExternalResources() {
|
||||
if (httpClient != null) {
|
||||
httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupEngine() {
|
||||
// 清除 ScriptEngine 的所有 bindings,释放 JS 运行时引用
|
||||
if (engine != null) {
|
||||
try {
|
||||
// 清除注入的 Java 对象引用
|
||||
engine.put("http", null);
|
||||
engine.put("logger", null);
|
||||
engine.put("shareLinkInfo", null);
|
||||
engine.put("JavaFetch", null);
|
||||
// 清除所有 ENGINE_SCOPE bindings,包括 eval 加载的 JS 函数
|
||||
var bindings = engine.getBindings(javax.script.ScriptContext.ENGINE_SCOPE);
|
||||
if (bindings != null) {
|
||||
bindings.clear();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("清理 ScriptEngine bindings 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,12 @@ 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 static final int MAX_LOG_MESSAGE_LENGTH = 4096;
|
||||
private final List<LogEntry> logs = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
/**
|
||||
@@ -56,9 +63,25 @@ public class JsPlaygroundLogger {
|
||||
if (obj == null) {
|
||||
return "null";
|
||||
}
|
||||
return obj.toString();
|
||||
String msg = obj.toString();
|
||||
if (msg.length() <= MAX_LOG_MESSAGE_LENGTH) {
|
||||
return msg;
|
||||
}
|
||||
return msg.substring(0, MAX_LOG_MESSAGE_LENGTH) + "...[truncated]";
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加日志条目,超过最大容量时移除最早的条目
|
||||
*/
|
||||
private void addLog(LogEntry entry) {
|
||||
synchronized (logs) {
|
||||
if (logs.size() >= MAX_LOG_SIZE) {
|
||||
logs.remove(0);
|
||||
}
|
||||
logs.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录日志(内部方法)
|
||||
* @param level 日志级别
|
||||
@@ -67,8 +90,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,10 +132,10 @@ public class JsPlaygroundLogger {
|
||||
public void error(Object message, Throwable throwable) {
|
||||
String msg = toString(message);
|
||||
if (throwable != null) {
|
||||
msg = msg + ": " + throwable.getMessage();
|
||||
msg = toString(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层调用的内部方法 =====
|
||||
@@ -149,12 +172,12 @@ public class JsPlaygroundLogger {
|
||||
* 错误日志(带异常,供Java层调用)
|
||||
*/
|
||||
public void errorJava(String message, Throwable throwable) {
|
||||
String msg = message;
|
||||
String msg = toString(message);
|
||||
if (throwable != null) {
|
||||
msg = msg + ": " + throwable.getMessage();
|
||||
msg = toString(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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,4 +202,17 @@ public class JsPlaygroundLogger {
|
||||
public void clear() {
|
||||
logs.clear();
|
||||
}
|
||||
|
||||
public void trimToLast(int maxEntries) {
|
||||
if (maxEntries < 0) {
|
||||
throw new IllegalArgumentException("maxEntries不能小于0");
|
||||
}
|
||||
synchronized (logs) {
|
||||
int removeCount = logs.size() - maxEntries;
|
||||
if (removeCount <= 0) {
|
||||
return;
|
||||
}
|
||||
logs.subList(0, removeCount).clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ public class JsScriptLoader {
|
||||
|
||||
private static final String RESOURCE_PATH = "custom-parsers";
|
||||
private static final String EXTERNAL_PATH = "./custom-parsers";
|
||||
private static final long MAX_SCRIPT_SIZE_BYTES = 128 * 1024;
|
||||
private static final int MAX_EXTERNAL_SCRIPT_COUNT = 100;
|
||||
|
||||
// 系统属性配置的外部目录路径
|
||||
private static final String EXTERNAL_PATH_PROPERTY = "parser.custom-parsers.path";
|
||||
@@ -81,14 +83,16 @@ public class JsScriptLoader {
|
||||
try {
|
||||
InputStream inputStream = JsScriptLoader.class.getClassLoader()
|
||||
.getResourceAsStream(resourceFile);
|
||||
|
||||
|
||||
if (inputStream != null) {
|
||||
String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
||||
configs.add(config);
|
||||
|
||||
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
|
||||
log.debug("从资源目录加载脚本: {}", fileName);
|
||||
try (inputStream) {
|
||||
String jsCode = readResourceScript(inputStream, resourceFile);
|
||||
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
||||
configs.add(config);
|
||||
|
||||
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
|
||||
log.debug("从资源目录加载脚本: {}", fileName);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("加载资源脚本失败: {}", resourceFile, e);
|
||||
@@ -139,21 +143,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);
|
||||
}
|
||||
@@ -208,8 +211,10 @@ public class JsScriptLoader {
|
||||
paths.filter(Files::isRegularFile)
|
||||
.filter(path -> path.toString().endsWith(".js"))
|
||||
.filter(path -> !isExcludedFile(path.getFileName().toString()))
|
||||
.limit(MAX_EXTERNAL_SCRIPT_COUNT)
|
||||
.forEach(path -> {
|
||||
try {
|
||||
ensureScriptSize(path);
|
||||
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
|
||||
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
||||
configs.add(config);
|
||||
@@ -263,6 +268,7 @@ public class JsScriptLoader {
|
||||
throw new IllegalArgumentException("文件不存在: " + filePath);
|
||||
}
|
||||
|
||||
ensureScriptSize(path);
|
||||
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
|
||||
return JsScriptMetadataParser.parseScript(jsCode);
|
||||
|
||||
@@ -280,14 +286,16 @@ public class JsScriptLoader {
|
||||
try {
|
||||
InputStream inputStream = JsScriptLoader.class.getClassLoader()
|
||||
.getResourceAsStream(resourcePath);
|
||||
|
||||
|
||||
if (inputStream == null) {
|
||||
throw new IllegalArgumentException("资源文件不存在: " + resourcePath);
|
||||
}
|
||||
|
||||
String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
return JsScriptMetadataParser.parseScript(jsCode);
|
||||
|
||||
|
||||
try (inputStream) {
|
||||
String jsCode = readResourceScript(inputStream, resourcePath);
|
||||
return JsScriptMetadataParser.parseScript(jsCode);
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("读取资源文件失败: " + resourcePath, e);
|
||||
}
|
||||
@@ -347,4 +355,19 @@ public class JsScriptLoader {
|
||||
fileName.contains(".test.") ||
|
||||
fileName.contains(".spec.");
|
||||
}
|
||||
|
||||
private static void ensureScriptSize(Path path) throws IOException {
|
||||
long size = Files.size(path);
|
||||
if (size > MAX_SCRIPT_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException("JavaScript脚本超过128KB限制: " + path.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
private static String readResourceScript(InputStream inputStream, String name) throws IOException {
|
||||
byte[] bytes = inputStream.readNBytes((int) MAX_SCRIPT_SIZE_BYTES + 1);
|
||||
if (bytes.length > MAX_SCRIPT_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException("JavaScript资源脚本超过128KB限制: " + name);
|
||||
}
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ public class Ce4Tool extends PanBase {
|
||||
|
||||
try {
|
||||
URL url = new URL(shareLinkInfo.getShareUrl());
|
||||
assertPublicHost(url);
|
||||
String baseUrl = url.getProtocol() + "://" + url.getHost();
|
||||
// 如果有端口,拼接上端口
|
||||
if (url.getPort() != -1) {
|
||||
@@ -110,7 +111,8 @@ public class Ce4Tool extends PanBase {
|
||||
private void requestShareDetail(String baseUrl, String key, String pwd, String path) {
|
||||
String shareApiUrl = baseUrl + SHARE_API_PATH + key;
|
||||
|
||||
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
|
||||
// 禁止跟随重定向:防止公网 host 302 到内网/元数据绕过 assertPublicHost
|
||||
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
|
||||
if (pwd != null && !pwd.isEmpty()) {
|
||||
httpRequest.addQueryParam("password", pwd);
|
||||
}
|
||||
@@ -231,7 +233,7 @@ public class Ce4Tool extends PanBase {
|
||||
.put("uris", new JsonArray().add(filePath))
|
||||
.put("download", true);
|
||||
|
||||
clientSession.postAbs(fileUrlApi)
|
||||
clientNoRedirects.postAbs(fileUrlApi)
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.sendJsonObject(requestBody)
|
||||
.onSuccess(res -> {
|
||||
|
||||
@@ -2,6 +2,7 @@ package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.IPanTool;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
@@ -42,6 +43,7 @@ public class CeTool extends PanBase {
|
||||
String pwd = shareLinkInfo.getSharePassword();
|
||||
try {
|
||||
URL url = new URL(shareLinkInfo.getShareUrl());
|
||||
assertPublicHost(url);
|
||||
String baseUrl = url.getProtocol() + "://" + url.getHost();
|
||||
// 如果有端口,拼接上端口
|
||||
if (url.getPort() != -1) {
|
||||
@@ -76,7 +78,8 @@ public class CeTool extends PanBase {
|
||||
private void tryV4Ping(String baseUrl, String key, String pwd) {
|
||||
String pingUrlV4 = baseUrl + PING_API_V4_PATH;
|
||||
|
||||
clientSession.getAbs(pingUrlV4).send().onSuccess(res -> {
|
||||
// 禁止跟随重定向:assertPublicHost 只校验初始 host,自动 30x 会绕过 SSRF 防护
|
||||
clientNoRedirects.getAbs(pingUrlV4).send().onSuccess(res -> {
|
||||
if (res.statusCode() == 200) {
|
||||
try {
|
||||
JsonObject json = asJson(res);
|
||||
@@ -106,7 +109,7 @@ public class CeTool extends PanBase {
|
||||
private void tryV3Ping(String baseUrl, String key, String pwd) {
|
||||
String pingUrlV3 = baseUrl + PING_API_V3_PATH;
|
||||
|
||||
clientSession.getAbs(pingUrlV3).send().onSuccess(res -> {
|
||||
clientNoRedirects.getAbs(pingUrlV3).send().onSuccess(res -> {
|
||||
if (res.statusCode() == 200) {
|
||||
try {
|
||||
JsonObject json = asJson(res);
|
||||
@@ -137,7 +140,7 @@ public class CeTool extends PanBase {
|
||||
*/
|
||||
private void verifyV3AndParse(String baseUrl, String key, String pwd) {
|
||||
String shareApiUrl = baseUrl + SHARE_API_PATH + key;
|
||||
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
|
||||
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
|
||||
if (pwd != null && !pwd.isEmpty()) {
|
||||
httpRequest.addQueryParam("password", pwd);
|
||||
}
|
||||
@@ -160,6 +163,7 @@ public class CeTool extends PanBase {
|
||||
} catch (Exception e) {
|
||||
log.debug("v3 share API解析失败: {}", e.getMessage());
|
||||
}
|
||||
tryV4ShareApi(baseUrl, key, pwd);
|
||||
}).onFailure(t -> {
|
||||
log.debug("v3 share API请求失败: {}", t.getMessage());
|
||||
// 请求失败,尝试 v4 或下一个解析器
|
||||
@@ -172,7 +176,7 @@ public class CeTool extends PanBase {
|
||||
*/
|
||||
private void tryV4ShareApi(String baseUrl, String key, String pwd) {
|
||||
String shareApiUrl = baseUrl + "/api/v4/share/info/" + key;
|
||||
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
|
||||
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
|
||||
if (pwd != null && !pwd.isEmpty()) {
|
||||
httpRequest.addQueryParam("password", pwd);
|
||||
}
|
||||
@@ -206,7 +210,8 @@ public class CeTool extends PanBase {
|
||||
*/
|
||||
private void delegateToCe4Tool() {
|
||||
log.debug("检测到Cloudreve 4.x,转发到Ce4Tool处理");
|
||||
new Ce4Tool(shareLinkInfo).parse().onComplete(promise);
|
||||
Ce4Tool ce4Tool = new Ce4Tool(shareLinkInfo);
|
||||
IPanTool.closeAfter(ce4Tool, ce4Tool::parse).onComplete(promise);
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +292,8 @@ public class CeTool extends PanBase {
|
||||
}
|
||||
|
||||
private void getDownURL(String shareApiUrl) {
|
||||
clientSession.putAbs(shareApiUrl)
|
||||
// PUT 默认不跟随重定向,但仍统一使用 no-redirect 客户端避免配置漂移
|
||||
clientNoRedirects.putAbs(shareApiUrl)
|
||||
.putHeader("Referer", shareLinkInfo.getShareUrl())
|
||||
.send().onSuccess(res -> {
|
||||
JsonObject jsonObject = asJson(res);
|
||||
|
||||
@@ -1,36 +1,73 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
import cn.qaiu.util.RandomStringGenerator;
|
||||
import cn.qaiu.util.CommonUtils;
|
||||
import cn.qaiu.util.FileSizeConverter;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.client.HttpRequest;
|
||||
import io.vertx.uritemplate.UriTemplate;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* <a href="https://www.ctfile.com">诚通网盘</a>
|
||||
*/
|
||||
public class CtTool extends PanBase {
|
||||
private static final String API_URL_PREFIX = "https://webapi.ctfile.com";
|
||||
private static final String SHARE_FILE_URL_PREFIX = "https://ctfile.com/file/";
|
||||
private static final String AJAX_ACCEPT = "application/json, text/javascript, */*; q=0.01";
|
||||
private static final String BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
|
||||
private static final int FILE_LIST_PAGE_SIZE = 200;
|
||||
private static final int MAX_FILE_LIST_PAGES = 50;
|
||||
|
||||
// https://webapi.ctfile.com/getfile.php?path=f&f=55050874-1246660795-6464f6&
|
||||
// passcode=7548&token=30wiijxs1fzhb6brw0p9m6&r=0.5885881231735761&
|
||||
// ref=&url=https%3A%2F%2F474b.com%2Ff%2F55050874-1246660795-6464f6%3Fp%3D7548
|
||||
// https://webapi.ctfile.com/getfile.php?path=f&f=64115194-17569800420720-06c697&
|
||||
// passcode=7609&r=0.6611183001986635&ref=&url=https%3A%2F%2Furl94.ctfile.com%2Ff%2F64115194-17569800420720-06c697%3Fp%3D7609
|
||||
private static final String API1 = API_URL_PREFIX + "/getfile.php?path={path}" +
|
||||
"&f={shareKey}&passcode={pwd}&token={token}&r={rand}&ref=";
|
||||
"&f={shareKey}&passcode={pwd}&r={rand}&ref=&url={url}";
|
||||
|
||||
//https://webapi.ctfile.com/get_file_url.php?uid=55050874&fid=1246660795&folder_id=0&
|
||||
// file_chk=054bc20461f5c63ff82015b9e69fb7fc&mb=1&token=30wiijxs1fzhb6brw0p9m6&app=0&
|
||||
// acheck=1&verifycode=&rd=0.965929071503574
|
||||
private static final String API2 = API_URL_PREFIX + "/get_file_url.php?" +
|
||||
"uid={uid}&fid={fid}&folder_id=0&file_chk={file_chk}&mb=0&token={token}&app=0&acheck=0&verifycode=" +
|
||||
"&rd={rand}";
|
||||
// https://webapi.ctfile.com/get_down_url.php?uid=64115194&fid=17569800420720&
|
||||
// file_chk=af5c8757a49cbc69a557eb3da59b246c&start_time=1780471868&wait_seconds=0&rd=0.36...
|
||||
private static final String API2 = API_URL_PREFIX + "/get_down_url.php?" +
|
||||
"uid={uid}&fid={fid}&file_chk={file_chk}" +
|
||||
"&start_time={start_time}&wait_seconds={wait_seconds}&rd={rand}";
|
||||
|
||||
// https://webapi.ctfile.com/getdir.php?path=d&d=64115194-164803691-48508c&
|
||||
// folder_id=164803691&fk=decb36&passcode=7609&r=0.23...&ref=&url=https://url94.ctfile.com/d/...
|
||||
private static final String API_GETDIR = API_URL_PREFIX + "/getdir.php?path={path}" +
|
||||
"&d={shareKey}&folder_id={folder_id}&fk={fk}&passcode={pwd}&r={rand}&ref=&url={url}";
|
||||
|
||||
// DataTables参数,用于获取目录文件列表
|
||||
private static final String FILE_LIST_PARAMS_TEMPLATE = "&sEcho=1&iColumns=4&sColumns=%2C%2C%2C" +
|
||||
"&iDisplayStart={start}&iDisplayLength={length}" +
|
||||
"&mDataProp_0=0&sSearch_0=&bRegex_0=false&bSearchable_0=true&bSortable_0=false" +
|
||||
"&mDataProp_1=1&sSearch_1=&bRegex_1=false&bSearchable_1=true&bSortable_1=true" +
|
||||
"&mDataProp_2=2&sSearch_2=&bRegex_2=false&bSearchable_2=true&bSortable_2=true" +
|
||||
"&mDataProp_3=3&sSearch_3=&bRegex_3=false&bSearchable_3=true&bSortable_3=true" +
|
||||
"&sSearch=&bRegex=false" +
|
||||
"&iSortCol_0=3&sSortDir_0=desc&iSortingCols=1";
|
||||
|
||||
// 文件列表HTML解析正则
|
||||
private static final Pattern FILE_ID_PATTERN = Pattern.compile("value=[\"']f(\\d+)[\"']");
|
||||
private static final Pattern FOLDER_ID_PATTERN = Pattern.compile("value=[\"']d(\\d+)[\"']");
|
||||
private static final Pattern FILE_HREF_PATTERN = Pattern.compile("href=[\"']#/f/([^\"']+)[\"']");
|
||||
private static final Pattern FILE_NAME_PATTERN = Pattern.compile("<a\\b[^>]*>([^<]+)</a>", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern FILE_ICON_PATTERN = Pattern.compile("alt=[\"']([^\"']+)[\"']");
|
||||
private static final Pattern SUBDIR_PATTERN = Pattern.compile("load_subdir\\s*\\((\\d+)\\s*,\\s*['\"]([^'\"]+)['\"]\\)");
|
||||
|
||||
/**
|
||||
* 子类重写此构造方法不需要添加额外逻辑
|
||||
@@ -51,62 +88,586 @@ public class CtTool extends PanBase {
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
final String shareKey = shareLinkInfo.getShareKey();
|
||||
if (shareKey.indexOf('-') == -1) {
|
||||
if (shareKey == null || shareKey.indexOf('-') == -1) {
|
||||
fail("shareKey格式不正确找不到'-': {}", shareKey);
|
||||
return promise.future();
|
||||
}
|
||||
String[] split = shareKey.split("-");
|
||||
String uid = split[0], fid = split[1];
|
||||
String token = RandomStringGenerator.generateRandomString();
|
||||
// 获取url path
|
||||
int i1 = shareLinkInfo.getShareUrl().indexOf("com/");
|
||||
int i2 = shareLinkInfo.getShareUrl().lastIndexOf("/");
|
||||
String path = shareLinkInfo.getShareUrl().substring(i1 + 4, i2);
|
||||
if (split.length < 2 || split[0].isBlank() || split[1].isBlank()) {
|
||||
fail("shareKey格式不正确: {}", shareKey);
|
||||
return promise.future();
|
||||
}
|
||||
String fallbackUid = split[0], fallbackFid = split[1];
|
||||
String path = extractPath(shareLinkInfo.getShareUrl());
|
||||
|
||||
HttpRequest<Buffer> bufferHttpRequest1 = clientSession.getAbs(UriTemplate.of(API1))
|
||||
HttpRequest<Buffer> bufferHttpRequest1 = withCtAjaxHeaders(clientSession.getAbs(UriTemplate.of(API1))
|
||||
.setTemplateParam("path", path)
|
||||
.setTemplateParam("shareKey", shareKey)
|
||||
.setTemplateParam("pwd", shareLinkInfo.getSharePassword())
|
||||
.setTemplateParam("token", token)
|
||||
.setTemplateParam("r", Math.random() + "");
|
||||
.setTemplateParam("rand", String.valueOf(Math.random()))
|
||||
.setTemplateParam("url", shareLinkInfo.getShareUrl()), shareLinkInfo.getShareUrl());
|
||||
|
||||
bufferHttpRequest1
|
||||
.send().onSuccess(res -> {
|
||||
var resJson = asJson(res);
|
||||
if (resJson.containsKey("file")) {
|
||||
var fileJson = resJson.getJsonObject("file");
|
||||
if (fileJson.containsKey("file_chk")) {
|
||||
var file_chk = fileJson.getString("file_chk");
|
||||
HttpRequest<Buffer> bufferHttpRequest2 = clientSession.getAbs(UriTemplate.of(API2))
|
||||
.setTemplateParam("uid", uid)
|
||||
.setTemplateParam("fid", fid)
|
||||
.setTemplateParam("file_chk", file_chk)
|
||||
.setTemplateParam("token", token)
|
||||
.setTemplateParam("rd", Math.random() + "");
|
||||
bufferHttpRequest2
|
||||
.send().onSuccess(res2 -> {
|
||||
JsonObject resJson2 = asJson(res2);
|
||||
if (resJson2.containsKey("downurl")) {
|
||||
String downloadUrl = resJson2.getString("downurl");
|
||||
|
||||
// 存储下载元数据,包括必要的请求头
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
headers.put("Referer", shareLinkInfo.getShareUrl());
|
||||
|
||||
// 使用新的 completeWithMeta 方法
|
||||
completeWithMeta(downloadUrl, headers);
|
||||
} else {
|
||||
fail("解析失败, 可能分享已失效: json: {} 字段 {} 不存在", resJson2, "downurl");
|
||||
}
|
||||
}).onFailure(handleFail(bufferHttpRequest1.queryParams().toString()));
|
||||
} else {
|
||||
fail("解析失败, file_chk找不到, 可能分享已失效或者分享密码不对: {}", fileJson);
|
||||
try {
|
||||
var resJson = asJson(res);
|
||||
if (resJson == null || resJson.isEmpty()) {
|
||||
fail("解析失败, 上游返回空响应或非JSON响应");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
fail("解析失败, 文件信息为空, 可能分享已失效");
|
||||
Object fileValue = resJson.getValue("file");
|
||||
if (!(fileValue instanceof JsonObject)) {
|
||||
fail("解析失败, 文件信息为空或格式错误, 可能分享已失效: {}", resJson);
|
||||
return;
|
||||
}
|
||||
var fileJson = (JsonObject) fileValue;
|
||||
String uid = resolveDownloadUid(fileJson, fallbackUid);
|
||||
String fid = resolveDownloadFid(fileJson, fallbackFid);
|
||||
String fileChk = fileJson.getString("file_chk");
|
||||
String startTime = valueToString(fileJson.getValue("start_time"));
|
||||
String waitSeconds = valueToString(fileJson.getValue("wait_seconds"));
|
||||
if (uid.isBlank() || fid.isBlank() || fileChk == null || fileChk.isBlank()
|
||||
|| startTime.isBlank() || waitSeconds.isBlank()) {
|
||||
fail("解析失败, 下载参数不完整, 可能分享已失效或者分享密码不对: {}", fileJson);
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取文件信息并存储
|
||||
FileInfo fileInfo = new FileInfo()
|
||||
.setFileName(fileJson.getString("file_name"))
|
||||
.setFileId(fid)
|
||||
.setSizeStr(fileJson.getString("file_size"))
|
||||
.setCreateTime(fileJson.getString("file_time"))
|
||||
.setCreateBy(fileJson.getString("username"))
|
||||
.setFileType("file")
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||
|
||||
HttpRequest<Buffer> bufferHttpRequest2 = withCtAjaxHeaders(clientSession.getAbs(UriTemplate.of(API2))
|
||||
.setTemplateParam("uid", uid)
|
||||
.setTemplateParam("fid", fid)
|
||||
.setTemplateParam("file_chk", fileChk)
|
||||
.setTemplateParam("start_time", startTime)
|
||||
.setTemplateParam("wait_seconds", waitSeconds)
|
||||
.setTemplateParam("rand", String.valueOf(Math.random())), shareLinkInfo.getShareUrl());
|
||||
bufferHttpRequest2
|
||||
.send().onSuccess(res2 -> handleDownloadUrlResponse(res2))
|
||||
.onFailure(t -> fail("下载链接请求失败: {}", t.getMessage()));
|
||||
} catch (Exception e) {
|
||||
fail("解析失败: {}", e.getMessage());
|
||||
}
|
||||
}).onFailure(handleFail(bufferHttpRequest1.queryParams().toString()));
|
||||
}).onFailure(t -> fail("文件信息请求失败: {}", t.getMessage()));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void handleDownloadUrlResponse(io.vertx.ext.web.client.HttpResponse<Buffer> res) {
|
||||
try {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson == null || resJson.isEmpty()) {
|
||||
fail("解析失败, 下载接口返回空响应或非JSON响应");
|
||||
return;
|
||||
}
|
||||
String downloadUrl = resJson.getString("downurl");
|
||||
if (downloadUrl == null || downloadUrl.isBlank()) {
|
||||
fail("解析失败, 可能分享已失效: json: {} 字段 {} 不存在", resJson, "downurl");
|
||||
return;
|
||||
}
|
||||
|
||||
// 存储下载元数据,包括必要的请求头
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("User-Agent", BROWSER_UA);
|
||||
if (shareLinkInfo.getShareUrl() != null && !shareLinkInfo.getShareUrl().isBlank()) {
|
||||
headers.put("Referer", shareLinkInfo.getShareUrl());
|
||||
}
|
||||
|
||||
// 使用新的 completeWithMeta 方法
|
||||
completeWithMeta(downloadUrl, headers);
|
||||
} catch (Exception e) {
|
||||
fail("解析失败, 下载接口响应处理异常: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> listPromise = Promise.promise();
|
||||
|
||||
final String shareKey = shareLinkInfo.getShareKey();
|
||||
final String shareUrl = shareLinkInfo.getShareUrl();
|
||||
final String pwd = shareLinkInfo.getSharePassword();
|
||||
|
||||
// shareKey格式: uid-folder_id-hash (例如 64115194-164803691-48508c)
|
||||
if (shareKey == null) {
|
||||
listPromise.fail(baseMsg() + " shareKey为空");
|
||||
return listPromise.future();
|
||||
}
|
||||
String[] split = shareKey.split("-");
|
||||
if (split.length < 2) {
|
||||
listPromise.fail(baseMsg() + " shareKey格式不正确: " + shareKey);
|
||||
return listPromise.future();
|
||||
}
|
||||
String path = extractPath(shareUrl);
|
||||
Object dirId = shareLinkInfo.getOtherParam() == null ? null : shareLinkInfo.getOtherParam().get("dirId");
|
||||
DirectoryContext directoryContext = resolveDirectoryContext(shareUrl, dirId);
|
||||
|
||||
HttpRequest<Buffer> getDirRequest = withCtAjaxHeaders(clientSession.getAbs(UriTemplate.of(API_GETDIR))
|
||||
.setTemplateParam("path", path)
|
||||
.setTemplateParam("shareKey", shareKey)
|
||||
.setTemplateParam("folder_id", directoryContext.folderId)
|
||||
.setTemplateParam("fk", directoryContext.folderKey)
|
||||
.setTemplateParam("pwd", pwd != null ? pwd : "")
|
||||
.setTemplateParam("rand", String.valueOf(Math.random()))
|
||||
.setTemplateParam("url", shareUrl), shareUrl);
|
||||
|
||||
getDirRequest.send().onSuccess(res -> {
|
||||
try {
|
||||
var resJson = asJson(res);
|
||||
if (resJson == null || resJson.isEmpty()) {
|
||||
failListPromise(listPromise, baseMsg() + " 目录解析失败: 上游返回空响应或非JSON响应");
|
||||
return;
|
||||
}
|
||||
if (!resJson.containsKey("file")) {
|
||||
failListPromise(listPromise, baseMsg() + " 目录解析失败: " + resJson.encode());
|
||||
return;
|
||||
}
|
||||
Object dirInfoValue = resJson.getValue("file");
|
||||
if (!(dirInfoValue instanceof JsonObject)) {
|
||||
failListPromise(listPromise, baseMsg() + " 目录解析失败: file字段格式错误: " + resJson.encode());
|
||||
return;
|
||||
}
|
||||
JsonObject dirInfo = (JsonObject) dirInfoValue;
|
||||
Object fileListUrlValue = dirInfo.getValue("url");
|
||||
String fileListRelUrl = fileListUrlValue instanceof String ? ((String) fileListUrlValue).trim() : "";
|
||||
if (fileListRelUrl.isBlank()) {
|
||||
failListPromise(listPromise, baseMsg() + " " + buildDirectoryFailureMessage(resJson, dirInfo));
|
||||
return;
|
||||
}
|
||||
|
||||
fetchFileListPage(toCtApiUrl(fileListRelUrl), 0, 0, new ArrayList<>(), listPromise,
|
||||
shareLinkInfo.getType(), getDomainName(), shareUrl, pwd);
|
||||
} catch (Exception e) {
|
||||
failListPromise(listPromise, baseMsg() + " 目录解析失败: " + e.getMessage());
|
||||
}
|
||||
}).onFailure(t -> failListPromise(listPromise, t));
|
||||
|
||||
return listPromise.future();
|
||||
}
|
||||
|
||||
private void fetchFileListPage(String fileListBaseUrl, int start, int pageIndex, List<FileInfo> fileList,
|
||||
Promise<List<FileInfo>> listPromise, String panType, String domainName,
|
||||
String shareUrl, String pwd) {
|
||||
try {
|
||||
if (pageIndex >= MAX_FILE_LIST_PAGES) {
|
||||
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: 分页超过最大限制 " + MAX_FILE_LIST_PAGES
|
||||
+ " (start=" + start + ", length=" + FILE_LIST_PAGE_SIZE + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
String fileListUrl = appendQueryParams(fileListBaseUrl,
|
||||
buildFileListParams(start, FILE_LIST_PAGE_SIZE) + "&_=" + System.currentTimeMillis());
|
||||
withCtAjaxHeaders(clientSession.getAbs(fileListUrl), shareUrl)
|
||||
.send()
|
||||
.onSuccess(res -> handleFileListPageResponse(fileListBaseUrl, start, pageIndex, fileList,
|
||||
listPromise, panType, domainName, shareUrl, pwd, res))
|
||||
.onFailure(t -> failListPromise(listPromise, t));
|
||||
} catch (Exception e) {
|
||||
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: " + e.getMessage()
|
||||
+ " (start=" + start + ", length=" + FILE_LIST_PAGE_SIZE + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFileListPageResponse(String fileListBaseUrl, int start, int pageIndex, List<FileInfo> fileList,
|
||||
Promise<List<FileInfo>> listPromise, String panType, String domainName,
|
||||
String shareUrl, String pwd, io.vertx.ext.web.client.HttpResponse<Buffer> res) {
|
||||
try {
|
||||
var listJson = asJson(res);
|
||||
if (listJson == null || listJson.isEmpty()) {
|
||||
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: 上游返回空响应或非JSON响应"
|
||||
+ " (start=" + start + ", length=" + FILE_LIST_PAGE_SIZE + ")");
|
||||
return;
|
||||
}
|
||||
Object aaDataValue = listJson.getValue("aaData");
|
||||
if (!(aaDataValue instanceof JsonArray)) {
|
||||
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: aaData为空: " + listJson.encode());
|
||||
return;
|
||||
}
|
||||
JsonArray aaData = (JsonArray) aaDataValue;
|
||||
for (int i = 0; i < aaData.size(); i++) {
|
||||
try {
|
||||
Object rowValue = aaData.getValue(i);
|
||||
if (!(rowValue instanceof JsonArray)) {
|
||||
log.warn("城通文件列表行格式错误: {}", rowValue);
|
||||
continue;
|
||||
}
|
||||
FileInfo fileInfo = parseFileListRow((JsonArray) rowValue, panType,
|
||||
domainName, shareUrl, pwd);
|
||||
if (fileInfo != null) {
|
||||
fileList.add(fileInfo);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析文件行失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
int nextStart = start + aaData.size();
|
||||
int total = parseFileListTotal(listJson);
|
||||
if (isUnexpectedEmptyFileListPage(start, aaData.size(), total)) {
|
||||
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: 上游返回空分页"
|
||||
+ " (start=" + start + ", total=" + total + ")");
|
||||
return;
|
||||
}
|
||||
if (shouldFetchNextFileListPage(start, aaData.size(), total)) {
|
||||
fetchFileListPage(fileListBaseUrl, nextStart, pageIndex + 1, fileList,
|
||||
listPromise, panType, domainName, shareUrl, pwd);
|
||||
} else {
|
||||
completeListPromise(listPromise, fileList);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: " + e.getMessage()
|
||||
+ " (start=" + start + ", length=" + FILE_LIST_PAGE_SIZE + ")");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
Object paramValue = shareLinkInfo.getOtherParam().get("paramJson");
|
||||
if (!(paramValue instanceof JsonObject)) {
|
||||
Promise<String> parsePromise = Promise.promise();
|
||||
parsePromise.fail(baseMsg() + " 缺少下载参数paramJson");
|
||||
return parsePromise.future();
|
||||
}
|
||||
JsonObject paramJson = (JsonObject) paramValue;
|
||||
if (!applyFileParam(shareLinkInfo, paramJson)) {
|
||||
Promise<String> parsePromise = Promise.promise();
|
||||
parsePromise.fail(baseMsg() + " 下载参数id为空");
|
||||
return parsePromise.future();
|
||||
}
|
||||
return parse();
|
||||
}
|
||||
|
||||
static boolean applyFileParam(ShareLinkInfo shareLinkInfo, JsonObject paramJson) {
|
||||
String fileShareKey = paramJson.getString("id");
|
||||
if (fileShareKey == null || fileShareKey.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
shareLinkInfo.setSharePassword(paramJson.getString("pwd", ""));
|
||||
shareLinkInfo.setShareKey(fileShareKey);
|
||||
shareLinkInfo.setShareUrl(SHARE_FILE_URL_PREFIX + fileShareKey);
|
||||
shareLinkInfo.setStandardUrl(SHARE_FILE_URL_PREFIX + fileShareKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
static String resolveDownloadUid(JsonObject fileJson, String fallbackUid) {
|
||||
return firstNonBlank(valueToString(fileJson.getValue("userid")), fallbackUid);
|
||||
}
|
||||
|
||||
static String resolveDownloadFid(JsonObject fileJson, String fallbackFid) {
|
||||
return firstNonBlank(valueToString(fileJson.getValue("file_id")), fallbackFid);
|
||||
}
|
||||
|
||||
private HttpRequest<Buffer> withCtAjaxHeaders(HttpRequest<Buffer> request, String shareUrl) {
|
||||
request.putHeader("User-Agent", BROWSER_UA)
|
||||
.putHeader("Accept", AJAX_ACCEPT)
|
||||
.putHeader("X-Requested-With", "XMLHttpRequest");
|
||||
if (shareUrl != null && !shareUrl.isBlank()) {
|
||||
request.putHeader("Referer", shareUrl);
|
||||
}
|
||||
String origin = extractOrigin(shareUrl);
|
||||
if (!origin.isBlank()) {
|
||||
request.putHeader("Origin", origin);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
static FileInfo parseFileListRow(JsonArray row, String panType, String domainName, String shareUrl, String pwd) {
|
||||
if (row == null || row.size() < 2) {
|
||||
return null;
|
||||
}
|
||||
String checkboxHtml = rowString(row, 0);
|
||||
String nameCellHtml = rowString(row, 1);
|
||||
String sizeStr = rowString(row, 2).trim();
|
||||
String dateStr = rowString(row, 3).trim();
|
||||
if (nameCellHtml.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String fileName = matchFirst(FILE_NAME_PATTERN, nameCellHtml);
|
||||
String fileIcon = matchFirst(FILE_ICON_PATTERN, nameCellHtml);
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Matcher subdirMatcher = SUBDIR_PATTERN.matcher(nameCellHtml);
|
||||
boolean hasSubdirCall = subdirMatcher.find();
|
||||
if (hasSubdirCall || "folder".equalsIgnoreCase(fileIcon)) {
|
||||
String folderId = hasSubdirCall ? subdirMatcher.group(1) : null;
|
||||
String folderKey = hasSubdirCall ? subdirMatcher.group(2) : "";
|
||||
if (folderId == null) {
|
||||
folderId = matchFirst(FOLDER_ID_PATTERN, checkboxHtml);
|
||||
}
|
||||
if (folderId == null || folderId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String dirId = folderId + ":" + folderKey;
|
||||
FileInfo fileInfo = new FileInfo()
|
||||
.setFileName(fileName.trim())
|
||||
.setFileId(folderId)
|
||||
.setSize(0L)
|
||||
.setSizeStr(sizeStr.isBlank() ? "0B" : sizeStr)
|
||||
.setFileType("folder")
|
||||
.setFileIcon(fileIcon)
|
||||
.setPanType(panType)
|
||||
.setParserUrl(buildFolderParserUrl(domainName, shareUrl, dirId, pwd));
|
||||
if (!dateStr.isBlank()) {
|
||||
fileInfo.setCreateTime(dateStr).setUpdateTime(dateStr);
|
||||
}
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
String fileShareKey = matchFirst(FILE_HREF_PATTERN, nameCellHtml);
|
||||
if (fileShareKey == null || fileShareKey.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String fileId = matchFirst(FILE_ID_PATTERN, checkboxHtml);
|
||||
JsonObject paramJson = new JsonObject()
|
||||
.put("id", fileShareKey)
|
||||
.put("fileName", fileName.trim())
|
||||
.put("pwd", pwd == null ? "" : pwd);
|
||||
String param = CommonUtils.urlBase64Encode(paramJson.encode());
|
||||
long sizeBytes = 0;
|
||||
try {
|
||||
sizeBytes = sizeStr.isBlank() ? 0 : FileSizeConverter.convertToBytes(sizeStr);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
FileInfo fileInfo = new FileInfo()
|
||||
.setFileName(fileName.trim())
|
||||
.setFileId(fileId)
|
||||
.setSizeStr(sizeStr)
|
||||
.setSize(sizeBytes)
|
||||
.setFileType(fileIcon != null ? fileIcon : "file")
|
||||
.setFileIcon(fileIcon)
|
||||
.setPanType(panType)
|
||||
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
|
||||
domainName, panType, param));
|
||||
if (!dateStr.isBlank()) {
|
||||
fileInfo.setCreateTime(dateStr).setUpdateTime(dateStr);
|
||||
}
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
private static String buildFolderParserUrl(String domainName, String shareUrl, String dirId, String pwd) {
|
||||
String url = String.format("%s/v2/getFileList?url=%s&dirId=%s",
|
||||
domainName, urlEncode(shareUrl), urlEncode(dirId));
|
||||
if (pwd != null && !pwd.isBlank()) {
|
||||
url += "&pwd=" + urlEncode(pwd);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
static String extractQueryParam(String url, String paramName) {
|
||||
if (url == null || paramName == null) return null;
|
||||
int qIdx = url.indexOf('?');
|
||||
if (qIdx < 0) return null;
|
||||
String query = url.substring(qIdx + 1);
|
||||
int fragmentIdx = query.indexOf('#');
|
||||
if (fragmentIdx >= 0) {
|
||||
query = query.substring(0, fragmentIdx);
|
||||
}
|
||||
for (String param : query.split("&")) {
|
||||
int eqIdx = param.indexOf('=');
|
||||
if (eqIdx > 0 && urlDecode(param.substring(0, eqIdx)).equals(paramName)) {
|
||||
return urlDecode(param.substring(eqIdx + 1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String extractPath(String shareUrl) {
|
||||
if (shareUrl == null) {
|
||||
return "";
|
||||
}
|
||||
int comIdx = shareUrl.indexOf("com/");
|
||||
if (comIdx < 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
int pathStart = comIdx + 4;
|
||||
int pathEnd = shareUrl.indexOf('/', pathStart);
|
||||
if (pathEnd < 0) {
|
||||
pathEnd = shareUrl.indexOf('?', pathStart);
|
||||
}
|
||||
if (pathEnd < 0) {
|
||||
pathEnd = shareUrl.length();
|
||||
}
|
||||
return shareUrl.substring(pathStart, pathEnd);
|
||||
}
|
||||
|
||||
static String extractFolderKey(String shareUrl) {
|
||||
return trimToEmpty(extractQueryParam(shareUrl, "fk"));
|
||||
}
|
||||
|
||||
static DirectoryContext resolveDirectoryContext(String shareUrl, Object dirIdObj) {
|
||||
String dirId = dirIdObj == null ? "" : urlDecode(String.valueOf(dirIdObj).trim());
|
||||
if (!dirId.isBlank()) {
|
||||
String[] split = dirId.split(":", 2);
|
||||
return new DirectoryContext(trimToDefault(split[0], "undefined"),
|
||||
split.length > 1 ? trimToEmpty(split[1]) : "");
|
||||
}
|
||||
|
||||
String queryFolderId = firstNonBlank(extractQueryParam(shareUrl, "folder_id"), extractQueryParam(shareUrl, "d"));
|
||||
String queryFk = extractFolderKey(shareUrl);
|
||||
if (!queryFolderId.isBlank() || !queryFk.isBlank()) {
|
||||
return new DirectoryContext(trimToDefault(queryFolderId, "undefined"), queryFk);
|
||||
}
|
||||
return new DirectoryContext("undefined", "");
|
||||
}
|
||||
|
||||
static String buildDirectoryFailureMessage(JsonObject resJson, JsonObject dirInfo) {
|
||||
String code = valueToString(resJson.getValue("code"));
|
||||
String message = valueToString(dirInfo.getValue("message"));
|
||||
if (message != null && !message.isBlank()) {
|
||||
return "目录解析失败: " + message + " (code=" + code + ")";
|
||||
}
|
||||
if ("423".equals(code)) {
|
||||
return "目录解析失败: 需要访问密码或该分享受限 (code=423)";
|
||||
}
|
||||
return "目录解析失败: 文件列表URL为空, 上游响应: " + resJson.encode();
|
||||
}
|
||||
|
||||
static String buildFileListParams(int start, int length) {
|
||||
return FILE_LIST_PARAMS_TEMPLATE
|
||||
.replace("{start}", String.valueOf(Math.max(0, start)))
|
||||
.replace("{length}", String.valueOf(Math.max(1, length)));
|
||||
}
|
||||
|
||||
static int parseFileListTotal(JsonObject listJson) {
|
||||
int displayTotal = parseInteger(listJson.getValue("iTotalDisplayRecords"), -1);
|
||||
return displayTotal >= 0 ? displayTotal : parseInteger(listJson.getValue("iTotalRecords"), -1);
|
||||
}
|
||||
|
||||
static boolean shouldFetchNextFileListPage(int start, int rowCount, int total) {
|
||||
if (rowCount <= 0) {
|
||||
return false;
|
||||
}
|
||||
int fetchedThrough = start + rowCount;
|
||||
return total < 0 ? rowCount >= FILE_LIST_PAGE_SIZE : fetchedThrough < total;
|
||||
}
|
||||
|
||||
static boolean isUnexpectedEmptyFileListPage(int start, int rowCount, int total) {
|
||||
return total >= 0 && start < total && rowCount <= 0;
|
||||
}
|
||||
|
||||
private static int parseInteger(Object value, int defaultValue) {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(value.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static void failListPromise(Promise<List<FileInfo>> listPromise, String message) {
|
||||
if (!listPromise.future().isComplete()) {
|
||||
listPromise.fail(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void failListPromise(Promise<List<FileInfo>> listPromise, Throwable throwable) {
|
||||
if (!listPromise.future().isComplete()) {
|
||||
listPromise.fail(throwable);
|
||||
}
|
||||
}
|
||||
|
||||
private static void completeListPromise(Promise<List<FileInfo>> listPromise, List<FileInfo> fileList) {
|
||||
if (!listPromise.future().isComplete()) {
|
||||
listPromise.complete(fileList);
|
||||
}
|
||||
}
|
||||
|
||||
private static String toCtApiUrl(String url) {
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return url;
|
||||
}
|
||||
return API_URL_PREFIX + url;
|
||||
}
|
||||
|
||||
private static String appendQueryParams(String url, String params) {
|
||||
String normalizedParams = params != null && params.startsWith("&") ? params.substring(1) : params;
|
||||
return url + (url.contains("?") ? "&" : "?") + normalizedParams;
|
||||
}
|
||||
|
||||
private static String rowString(JsonArray row, int index) {
|
||||
if (row == null || index >= row.size()) {
|
||||
return "";
|
||||
}
|
||||
return valueToString(row.getValue(index));
|
||||
}
|
||||
|
||||
private static String valueToString(Object value) {
|
||||
return value == null ? "" : value.toString();
|
||||
}
|
||||
|
||||
private static String matchFirst(Pattern pattern, String text) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
return matcher.find() ? matcher.group(1) : null;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String first, String second) {
|
||||
return !trimToEmpty(first).isBlank() ? trimToEmpty(first) : trimToEmpty(second);
|
||||
}
|
||||
|
||||
private static String trimToDefault(String value, String defaultValue) {
|
||||
String result = trimToEmpty(value);
|
||||
return result.isBlank() ? defaultValue : result;
|
||||
}
|
||||
|
||||
private static String trimToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String urlEncode(String value) {
|
||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String urlDecode(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return URLDecoder.decode(value, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractOrigin(String shareUrl) {
|
||||
try {
|
||||
URI uri = URI.create(shareUrl);
|
||||
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||
return "";
|
||||
}
|
||||
String origin = uri.getScheme() + "://" + uri.getHost();
|
||||
return uri.getPort() > 0 ? origin + ":" + uri.getPort() : origin;
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
static final class DirectoryContext {
|
||||
final String folderId;
|
||||
final String folderKey;
|
||||
|
||||
DirectoryContext(String folderId, String folderKey) {
|
||||
this.folderId = folderId;
|
||||
this.folderKey = folderKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ public class FcTool extends PanBase {
|
||||
private static final String DOWN_REQUEST_URL = "https://v2.fangcloud.cn/apps/files/download?file_id={fid}" +
|
||||
"&scenario=share&unique_name={uname}";
|
||||
|
||||
// 静态编译的正则表达式,避免每次调用都重新编译
|
||||
private static final Pattern REQUEST_TOKEN_PATTERN = Pattern.compile("name=\"requesttoken\"\\s+value=\"([a-zA-Z0-9_+=]+)\"");
|
||||
private static final Pattern TYPED_ID_PATTERN = Pattern.compile("id=\"typed_id\"\\s+value=\"file_(\\d+)\"");
|
||||
|
||||
public FcTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
@@ -41,8 +45,7 @@ public class FcTool extends PanBase {
|
||||
if (StringUtils.isNotEmpty(pwd)) {
|
||||
// 获取requesttoken
|
||||
String html = res.bodyAsString();
|
||||
Pattern compile = Pattern.compile("name=\"requesttoken\"\\s+value=\"([a-zA-Z0-9_+=]+)\"");
|
||||
Matcher matcher = compile.matcher(html);
|
||||
Matcher matcher = REQUEST_TOKEN_PATTERN.matcher(html);
|
||||
if (!matcher.find()) {
|
||||
fail(SHARE_URL_PREFIX + " 未匹配到加密分享的密码输入页面的requesttoken");
|
||||
return;
|
||||
@@ -71,8 +74,7 @@ public class FcTool extends PanBase {
|
||||
WebClientSession sClient) {
|
||||
// 从HTML中找到文件id
|
||||
String html = res.bodyAsString();
|
||||
Pattern compile = Pattern.compile("id=\"typed_id\"\\s+value=\"file_(\\d+)\"");
|
||||
Matcher matcher = compile.matcher(html);
|
||||
Matcher matcher = TYPED_ID_PATTERN.matcher(html);
|
||||
if (!matcher.find()) {
|
||||
fail(SHARE_URL_PREFIX + " 未匹配到文件id(typed_id)");
|
||||
return;
|
||||
|
||||
@@ -3,9 +3,7 @@ package cn.qaiu.parser.impl;
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
import cn.qaiu.util.AESUtils;
|
||||
import cn.qaiu.util.FileSizeConverter;
|
||||
import cn.qaiu.util.UUIDUtil;
|
||||
import cn.qaiu.util.*;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
@@ -13,12 +11,14 @@ import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.client.HttpRequest;
|
||||
import io.vertx.ext.web.client.HttpResponse;
|
||||
import io.vertx.uritemplate.UriTemplate;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 小飞机网盘
|
||||
@@ -26,69 +26,113 @@ import java.util.List;
|
||||
* @version V016_230609
|
||||
*/
|
||||
public class FjTool extends PanBase {
|
||||
public static final String REFERER_URL = "https://share.feijipan.com/";
|
||||
private static final String API_URL_PREFIX = "https://api.feejii.com/ws/";
|
||||
|
||||
public static final String API_URL0 = "https://api.feijipan.com";
|
||||
private static final String API_URL_PREFIX = "https://api.feijipan.com/ws/";
|
||||
|
||||
private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" +
|
||||
"&uuid={uuid}&extra=2×tamp={ts}&shareId={shareId}&type=0&offset=1&limit=60";
|
||||
/// recommend/list?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}&shareId={shareId}&type=0&offset=1&limit=60
|
||||
// recommend/list?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}&shareId=JoUTkZYj&type=0&offset=1&limit=60
|
||||
|
||||
private static final String LOGIN_URL = API_URL_PREFIX +
|
||||
"login?uuid={uuid}&devType=6&devCode={uuid}&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken=&extra=2";
|
||||
|
||||
private static final String TOKEN_VERIFY_URL = API_URL0 +
|
||||
"/app/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2×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}";
|
||||
// https://api.feijipan.com/ws/file/redirect?downloadId={fidEncode}&enable=1&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}
|
||||
|
||||
//https://api.feijipan.com/ws/file/redirect?
|
||||
// downloadId=DBD34FFEDB71708FA5C284527F78E9EC104A9667FFEEA62CB6E00B54A3E0F5BB
|
||||
// &enable=1
|
||||
// &devType=6
|
||||
// &uuid=rTaNVSgmwY5MbEEuiMmQL
|
||||
// ×tamp=839E6B5E19223B8DF730A52F44062D48
|
||||
// &auth=F799422BCD9D05D7CCC5C9C53C1092C7029B420536135C3B4B7E064F49459DCC
|
||||
// &shareId=4wF7grHR
|
||||
private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX +
|
||||
"file/redirect?downloadId={fidEncode}&enable=1&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}";
|
||||
|
||||
|
||||
private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" +
|
||||
"={uuid}&extra=2×tamp={ts}";
|
||||
// https://api.feijipan.com/ws/buy/vip/list?devType=6&devModel=Chrome&uuid=WQAl5yBy1naGudJEILBvE&extra=2×tamp=E2C53155F6D09417A27981561134CB73
|
||||
|
||||
// https://api.feijipan.com/ws/share/list?devType=6&devModel=Chrome&uuid=pwRWqwbk1J-KMTlRZowrn&extra=2×tamp=C5F8A68C53121AB21FA35BA3529E8758&shareId=fmAuOh3m&folderId=28986333&offset=1&limit=60
|
||||
|
||||
private static final String FILE_LIST_URL = API_URL_PREFIX + "/share/list?devType=6&devModel=Chrome&uuid" +
|
||||
"={uuid}&extra=2×tamp={ts}&shareId={shareId}&folderId" +
|
||||
"={folderId}&offset=1&limit=60";
|
||||
|
||||
private static final MultiMap header;
|
||||
|
||||
private static final MultiMap header0;
|
||||
|
||||
long nowTs = System.currentTimeMillis();
|
||||
String tsEncode = AESUtils.encrypt2Hex(Long.toString(nowTs));
|
||||
String uuid = UUIDUtil.fjUuid(); // 也可以使用 UUID.randomUUID().toString()
|
||||
|
||||
static {
|
||||
header = MultiMap.caseInsensitiveMultiMap();
|
||||
header.set("Accept", "application/json, text/plain, */*");
|
||||
header.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
|
||||
header.set("Cache-Control", "no-cache");
|
||||
header.set("Connection", "keep-alive");
|
||||
header.set("Content-Length", "0");
|
||||
header.set("DNT", "1");
|
||||
header.set("Host", "api.feijipan.com");
|
||||
header.set("Origin", "https://www.feijix.com");
|
||||
header.set("Pragma", "no-cache");
|
||||
header.set("Referer", "https://www.feijix.com/");
|
||||
header.set("Sec-Fetch-Dest", "empty");
|
||||
header.set("Sec-Fetch-Mode", "cors");
|
||||
header.set("Sec-Fetch-Site", "cross-site");
|
||||
header.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
|
||||
header.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"");
|
||||
header.set("sec-ch-ua-mobile", "?0");
|
||||
header.set("sec-ch-ua-platform", "\"Windows\"");
|
||||
header0 = MultiMap.caseInsensitiveMultiMap();
|
||||
header0.set("Accept-Encoding", "gzip, deflate");
|
||||
header0.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
|
||||
header0.set("Cache-Control", "no-cache");
|
||||
header0.set("Connection", "keep-alive");
|
||||
header0.set("Content-Length", "0");
|
||||
header0.set("DNT", "1");
|
||||
header0.set("Pragma", "no-cache");
|
||||
header0.set("Referer", "https://www.feijipan.com/");
|
||||
header0.set("Sec-Fetch-Dest", "empty");
|
||||
header0.set("Sec-Fetch-Mode", "cors");
|
||||
header0.set("Sec-Fetch-Site", "cross-site");
|
||||
header0.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
|
||||
header0.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"");
|
||||
header0.set("sec-ch-ua-mobile", "?0");
|
||||
header0.set("sec-ch-ua-platform", "\"Windows\"");
|
||||
|
||||
header = HeaderUtils.parseHeaders("""
|
||||
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
|
||||
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
|
||||
Cache-Control: no-cache
|
||||
Connection: keep-alive
|
||||
DNT: 1
|
||||
Pragma: no-cache
|
||||
Referer: https://www.feijix.com/
|
||||
Sec-Fetch-Dest: document
|
||||
Sec-Fetch-Mode: navigate
|
||||
Sec-Fetch-Site: cross-site
|
||||
Sec-Fetch-User: ?1
|
||||
Upgrade-Insecure-Requests: 1
|
||||
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0
|
||||
sec-ch-ua: "Microsoft Edge";v="135", "Not-A.Brand";v="8", "Chromium";v="135"
|
||||
sec-ch-ua-mobile: ?0
|
||||
sec-ch-ua-platform: "Windows"
|
||||
""");
|
||||
}
|
||||
|
||||
// String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
||||
|
||||
static volatile String token = null;
|
||||
static volatile String userId = null;
|
||||
public static volatile boolean authFlag = true;
|
||||
|
||||
public FjTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
|
||||
// 240530 此处shareId又改为了原始的shareId
|
||||
// String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||
final String shareId = shareLinkInfo.getShareKey();
|
||||
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||
long nowTs = System.currentTimeMillis();
|
||||
String tsEncode = AESUtils.encrypt2Hex(Long.toString(nowTs));
|
||||
|
||||
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
// 获取用户id
|
||||
if (auths.contains("userId")) {
|
||||
FjTool.userId = auths.get("userId");
|
||||
log.info("已配置用户ID: {}", FjTool.userId);
|
||||
} else {
|
||||
log.warn("未配置用户ID, 可能会导致解析失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口
|
||||
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
|
||||
@@ -98,120 +142,375 @@ public class FjTool extends PanBase {
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(r0 -> { // 忽略res
|
||||
|
||||
// 第一次请求 获取文件信息
|
||||
// POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60
|
||||
client.postAbs(UriTemplate.of(url))
|
||||
.putHeaders(header)
|
||||
.putHeaders(header0)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(res -> {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson.getInteger("code") != 200) {
|
||||
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
|
||||
|
||||
JsonObject resJson;
|
||||
try {
|
||||
resJson = asJson(res);
|
||||
} catch (Exception e) {
|
||||
log.error("获取文件信息失败: {}", res.bodyAsString());
|
||||
return;
|
||||
}
|
||||
if (resJson.getJsonArray("list").isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
||||
if (resJson.getInteger("code") != 200) {
|
||||
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件Id
|
||||
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
|
||||
// 如果是目录返回目录ID
|
||||
if (!fileInfo.containsKey("fileList") || fileInfo.getJsonArray("fileList").isEmpty()) {
|
||||
JsonArray fileListArray = fileInfo.getJsonArray("fileList");
|
||||
if (fileListArray == null || fileListArray.isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo);
|
||||
return;
|
||||
}
|
||||
JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0);
|
||||
// 如果是目录返回目录ID
|
||||
JsonObject fileList = fileListArray.getJsonObject(0);
|
||||
if (fileList.getInteger("fileType") == 2) {
|
||||
promise.complete(fileList.getInteger("folderId").toString());
|
||||
return;
|
||||
}
|
||||
// 提取文件信息
|
||||
extractFileInfo(fileList, fileInfo);
|
||||
getDownURL(resJson);
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
String fileId = fileInfo.getString("fileIds");
|
||||
String userId = fileInfo.getString("userId");
|
||||
// 其他参数
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + userId);
|
||||
String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2);
|
||||
|
||||
// 第二次请求
|
||||
HttpRequest<Buffer> httpRequest =
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", shareId);
|
||||
// System.out.println(httpRequest.toString());
|
||||
httpRequest.send().onSuccess(res2 -> {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location")) {
|
||||
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res.headers());
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}).onFailure(handleFail(SECOND_REQUEST_URL));
|
||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
||||
|
||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void getDownURL(JsonObject resJson) {
|
||||
String dataKey = shareLinkInfo.getShareKey();
|
||||
// 文件Id
|
||||
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
|
||||
String fileId = fileInfo.getString("fileIds");
|
||||
String userId = fileInfo.getString("userId");
|
||||
// 其他参数
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId);
|
||||
String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2);
|
||||
|
||||
// 检查是否有认证信息
|
||||
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
// 检查是否为临时认证(临时认证每次都尝试登录)
|
||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||
// 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证
|
||||
if (isTempAuth || authFlag) {
|
||||
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
|
||||
HttpRequest<Buffer> httpRequest =
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", dataKey)
|
||||
;
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
if (token == null) {
|
||||
// 执行登录
|
||||
login(tsEncode2, auths).onFailure(failRes-> {
|
||||
log.warn("登录失败: {}", failRes.getMessage());
|
||||
fail(failRes.getMessage());
|
||||
}).onSuccess(r-> {
|
||||
httpRequest.setTemplateParam("fidEncode", AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
});
|
||||
} else {
|
||||
// 验证token
|
||||
client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(header0).send().onSuccess(res -> {
|
||||
if (asJson(res).getInteger("code") != 200) {
|
||||
login(tsEncode2, auths).onFailure(failRes -> {
|
||||
log.warn("重新登录失败: {}", failRes.getMessage());
|
||||
fail(failRes.getMessage());
|
||||
}).onSuccess(r-> {
|
||||
httpRequest
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
});
|
||||
} else {
|
||||
httpRequest
|
||||
.setTemplateParam("fidEncode", AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
}).onFailure(handleFail("Token验证"));
|
||||
}
|
||||
} else {
|
||||
// authFlag 为 false,使用免登录解析
|
||||
log.debug("authFlag=false,使用免登录解析");
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", dataKey).send()
|
||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
} else {
|
||||
// 没有认证信息,使用免登录解析
|
||||
log.debug("无认证信息,使用免登录解析");
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", dataKey).send()
|
||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private Future<Void> login(String tsEncode2, MultiMap auths) {
|
||||
Promise<Void> promise1 = Promise.promise();
|
||||
// 如果配置了用户ID 则不登录
|
||||
if (FjTool.userId != null) {
|
||||
promise1.complete();
|
||||
return promise1.future();
|
||||
}
|
||||
client.postAbs(UriTemplate.of(LOGIN_URL))
|
||||
.setTemplateParam("uuid",uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(header0)
|
||||
.sendJsonObject(JsonObject.of("loginName", auths.get("username"), "loginPwd", auths.get("password")))
|
||||
.onSuccess(res2->{
|
||||
JsonObject json = asJson(res2);
|
||||
if (json.getInteger("code") == 200) {
|
||||
token = json.getJsonObject("data").getString("appToken");
|
||||
MultiMap h0 = MultiMap.caseInsensitiveMultiMap();
|
||||
h0.addAll(header0);
|
||||
h0.set("appToken", token);
|
||||
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
|
||||
client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(h0).send().onSuccess(res -> {
|
||||
if (asJson(res).getInteger("code") == 200) {
|
||||
if (FjTool.userId == null) {
|
||||
FjTool.userId = asJson(res).getJsonObject("map").getString("userId");
|
||||
}
|
||||
log.info("验证成功 userId: {}", FjTool.userId);
|
||||
promise1.complete();
|
||||
} else {
|
||||
promise1.fail("验证失败: " + res.bodyAsString());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 检查是否为临时认证
|
||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||
if (isTempAuth) {
|
||||
// 临时认证失败,直接返回错误,不影响后台配置的认证
|
||||
log.warn("临时认证失败: {}", json.getString("msg"));
|
||||
promise1.fail("临时认证失败: " + json.getString("msg"));
|
||||
} else {
|
||||
// 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析
|
||||
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
|
||||
authFlag = false;
|
||||
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
|
||||
}
|
||||
}
|
||||
}).onFailure(err -> {
|
||||
log.error("登录请求异常: {}", err.getMessage());
|
||||
promise1.fail("登录请求异常: " + err.getMessage());
|
||||
});
|
||||
return promise1.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从接口返回数据中提取文件信息
|
||||
*/
|
||||
private void extractFileInfo(JsonObject fileList, JsonObject shareInfo) {
|
||||
try {
|
||||
// 文件名
|
||||
String fileName = fileList.getString("fileName");
|
||||
shareLinkInfo.getOtherParam().put("fileName", fileName);
|
||||
|
||||
// 文件大小 (KB -> Bytes)
|
||||
Long fileSize = fileList.getLong("fileSize", 0L) * 1024;
|
||||
shareLinkInfo.getOtherParam().put("fileSize", fileSize);
|
||||
shareLinkInfo.getOtherParam().put("fileSizeFormat", FileSizeConverter.convertToReadableSize(fileSize));
|
||||
|
||||
// 文件图标
|
||||
String fileIcon = fileList.getString("fileIcon");
|
||||
if (StringUtils.isNotBlank(fileIcon)) {
|
||||
shareLinkInfo.getOtherParam().put("fileIcon", fileIcon);
|
||||
}
|
||||
|
||||
// 文件ID
|
||||
Long fileId = fileList.getLong("fileId");
|
||||
if (fileId != null) {
|
||||
shareLinkInfo.getOtherParam().put("fileId", fileId.toString());
|
||||
}
|
||||
|
||||
// 文件类型 (1=文件, 2=目录)
|
||||
Integer fileType = fileList.getInteger("fileType", 1);
|
||||
shareLinkInfo.getOtherParam().put("fileType", fileType == 1 ? "file" : "folder");
|
||||
|
||||
// 下载次数
|
||||
Integer downloads = fileList.getInteger("fileDownloads", 0);
|
||||
shareLinkInfo.getOtherParam().put("downloadCount", downloads);
|
||||
|
||||
// 点赞数
|
||||
Integer likes = fileList.getInteger("fileLikes", 0);
|
||||
shareLinkInfo.getOtherParam().put("likeCount", likes);
|
||||
|
||||
// 评论数
|
||||
Integer comments = fileList.getInteger("fileComments", 0);
|
||||
shareLinkInfo.getOtherParam().put("commentCount", comments);
|
||||
|
||||
// 评分
|
||||
Double stars = fileList.getDouble("fileStars", 0.0);
|
||||
shareLinkInfo.getOtherParam().put("stars", stars);
|
||||
|
||||
// 更新时间
|
||||
String updateTime = fileList.getString("updTime");
|
||||
if (StringUtils.isNotBlank(updateTime)) {
|
||||
shareLinkInfo.getOtherParam().put("updateTime", updateTime);
|
||||
}
|
||||
|
||||
// 创建时间
|
||||
String createTime = null;
|
||||
|
||||
// 分享信息
|
||||
if (shareInfo != null) {
|
||||
// 分享ID
|
||||
Integer shareId = shareInfo.getInteger("shareId");
|
||||
if (shareId != null) {
|
||||
shareLinkInfo.getOtherParam().put("shareId", shareId.toString());
|
||||
}
|
||||
|
||||
// 上传时间
|
||||
String addTime = shareInfo.getString("addTime");
|
||||
if (StringUtils.isNotBlank(addTime)) {
|
||||
shareLinkInfo.getOtherParam().put("createTime", addTime);
|
||||
createTime = addTime;
|
||||
}
|
||||
|
||||
// 预览次数
|
||||
Integer previewNum = shareInfo.getInteger("previewNum", 0);
|
||||
shareLinkInfo.getOtherParam().put("previewCount", previewNum);
|
||||
|
||||
// 用户信息
|
||||
JsonObject userMap = shareInfo.getJsonObject("map");
|
||||
if (userMap != null) {
|
||||
String userName = userMap.getString("userName");
|
||||
if (StringUtils.isNotBlank(userName)) {
|
||||
shareLinkInfo.getOtherParam().put("userName", userName);
|
||||
}
|
||||
|
||||
// VIP信息
|
||||
Integer isVip = userMap.getInteger("isVip", 0);
|
||||
shareLinkInfo.getOtherParam().put("isVip", isVip == 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 FileInfo 对象并存入 otherParam
|
||||
FileInfo fileInfoObj = new FileInfo()
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setFileName(fileName)
|
||||
.setFileId(fileId != null ? fileId.toString() : null)
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setFileType(fileType == 1 ? "file" : "folder")
|
||||
.setFileIcon(fileIcon)
|
||||
.setDownloadCount(downloads)
|
||||
.setCreateTime(createTime)
|
||||
.setUpdateTime(updateTime);
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfoObj);
|
||||
|
||||
log.debug("提取文件信息成功: fileName={}, fileSize={}, downloads={}",
|
||||
fileName, fileSize, downloads);
|
||||
} catch (Exception e) {
|
||||
log.warn("提取文件信息失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void down(HttpResponse<Buffer> res2) {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location") || headers.get("Location") == null) {
|
||||
fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误: " + res2.bodyAsString());
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}
|
||||
|
||||
// 目录解析
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
Promise<List<FileInfo>> promise0 = Promise.promise();
|
||||
|
||||
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||
|
||||
// 如果参数里的目录ID不为空,则直接解析目录
|
||||
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
|
||||
if (dirId != null && !dirId.isEmpty()) {
|
||||
uuid = shareLinkInfo.getOtherParam().get("uuid").toString();
|
||||
parserDir(dirId, shareId, promise);
|
||||
return promise.future();
|
||||
Object uuidObj = shareLinkInfo.getOtherParam().get("uuid");
|
||||
if (uuidObj != null) {
|
||||
uuid = uuidObj.toString();
|
||||
}
|
||||
parserDir(dirId, shareId, promise0);
|
||||
return promise0.future();
|
||||
}
|
||||
parse().onSuccess(id -> {
|
||||
if (id != null && id.matches("^[a-zA-Z0-9]+$")) {
|
||||
parserDir(id, shareId, promise);
|
||||
} else {
|
||||
promise.fail("解析目录ID失败");
|
||||
}
|
||||
parserDir(id, shareId, promise0);
|
||||
}).onFailure(failRes -> {
|
||||
log.error("解析目录失败: {}", failRes.getMessage());
|
||||
promise.fail(failRes);
|
||||
promise0.fail(failRes);
|
||||
});
|
||||
return promise.future();
|
||||
return promise0.future();
|
||||
}
|
||||
|
||||
private void parserDir(String id, String shareId, Promise<List<FileInfo>> promise) {
|
||||
// id以http开头直接返回 封装数组返回
|
||||
if (id != null && (id.startsWith("http://") || id.startsWith("https://"))) {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setFileName(id)
|
||||
.setFileId(id)
|
||||
.setFileType("file")
|
||||
.setParserUrl(id)
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
result.add(fileInfo);
|
||||
promise.complete(result);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode);
|
||||
// 开始解析目录: 164312216, shareId: bPMsbg5K, uuid: 0fmVWTx2Ea4zFwkpd7KXf, ts: 20865d7b7f00828279f437cd1f097860
|
||||
// 拿到目录ID
|
||||
client.postAbs(UriTemplate.of(FILE_LIST_URL))
|
||||
.putHeaders(header)
|
||||
.putHeaders(header0)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.setTemplateParam("folderId", id)
|
||||
.send().onSuccess(res -> {
|
||||
JsonObject jsonObject;
|
||||
JsonArray list;
|
||||
try {
|
||||
jsonObject = asJson(res);
|
||||
JsonObject jsonObject = asJson(res);
|
||||
log.debug("目录列表: {}", jsonObject.encodePrettily());
|
||||
list = jsonObject.getJsonArray("list");
|
||||
} catch (Exception e) {
|
||||
promise.fail(FIRST_REQUEST_URL + " 解析JSON失败: " + res.bodyAsString());
|
||||
log.error("解析目录失败: {}", res.bodyAsString());
|
||||
return;
|
||||
}
|
||||
// System.out.println(jsonObject.encodePrettily());
|
||||
JsonArray list = jsonObject.getJsonArray("list");
|
||||
ArrayList<FileInfo> result = new ArrayList<>();
|
||||
list.forEach(item->{
|
||||
JsonObject fileJson = (JsonObject) item;
|
||||
@@ -224,7 +523,7 @@ public class FjTool extends PanBase {
|
||||
// 其他参数
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + userId);
|
||||
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId);
|
||||
String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2);
|
||||
|
||||
// 回传用到的参数
|
||||
@@ -239,8 +538,7 @@ public class FjTool extends PanBase {
|
||||
"ts", tsEncode2,
|
||||
"auth", auth,
|
||||
"shareId", shareId);
|
||||
byte[] encode = Base64.getEncoder().encode(entries.encode().getBytes());
|
||||
String param = new String(encode);
|
||||
String param = CommonUtils.urlBase64Encode(entries.encode());
|
||||
|
||||
if (fileJson.getInteger("fileType") == 2) {
|
||||
// 如果是目录
|
||||
@@ -280,17 +578,19 @@ public class FjTool extends PanBase {
|
||||
result.add(fileInfo);
|
||||
});
|
||||
promise.complete(result);
|
||||
}).onFailure(failRes -> {
|
||||
log.error("解析目录请求失败: {}", failRes.getMessage());
|
||||
promise.fail(failRes);
|
||||
});;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
|
||||
// 第二次请求
|
||||
JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson");
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
if (paramJson == null) {
|
||||
promise.fail("缺少 paramJson 参数");
|
||||
return promise.future();
|
||||
}
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
|
||||
.setTemplateParam("uuid", paramJson.getString("uuid"))
|
||||
.setTemplateParam("ts", paramJson.getString("ts"))
|
||||
@@ -299,11 +599,16 @@ public class FjTool extends PanBase {
|
||||
.putHeaders(header).send().onSuccess(res2 -> {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location")) {
|
||||
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res2.headers());
|
||||
fail(SECOND_REQUEST_URL_VIP + " 未找到重定向URL: \n" + res2.headers());
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}).onFailure(handleFail(SECOND_REQUEST_URL));
|
||||
}).onFailure(handleFail(SECOND_REQUEST_URL_VIP));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
public static void resetToken() {
|
||||
token = null;
|
||||
authFlag = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
import cn.qaiu.util.CommonUtils;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* <a href="https://www.feishu.cn/">飞书云盘</a>
|
||||
* <p>
|
||||
* 支持飞书公开分享文件和文件夹的解析。
|
||||
* <ul>
|
||||
* <li>文件链接: https://xxx.feishu.cn/file/{token}</li>
|
||||
* <li>文件夹链接: https://xxx.feishu.cn/drive/folder/{token}</li>
|
||||
* </ul>
|
||||
* 飞书下载需要先获取匿名会话Cookie,然后使用Cookie请求下载接口。
|
||||
* </p>
|
||||
*/
|
||||
public class FsTool extends PanBase {
|
||||
|
||||
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
||||
|
||||
/**
|
||||
* 飞书 obj_type: type=12 表示上传文件可下载
|
||||
*/
|
||||
private static final int OBJ_TYPE_FILE = 12;
|
||||
|
||||
/**
|
||||
* v3 列表 API 支持的 obj_type
|
||||
*/
|
||||
private static final int[] LIST_OBJ_TYPES = {
|
||||
0, 2, 22, 44, 3, 30, 8, 11, 12, 84, 123, 124
|
||||
};
|
||||
|
||||
/** 每页返回条目数 */
|
||||
private static final int PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* 从分享链接中提取 tenant 的正则
|
||||
*/
|
||||
private static final Pattern TENANT_PATTERN =
|
||||
Pattern.compile("https://([^.]+)\\.feishu\\.cn/");
|
||||
|
||||
/** 解析 Content-Disposition: filename*=UTF-8''xxx */
|
||||
private static final Pattern CD_FILENAME_STAR_PATTERN =
|
||||
Pattern.compile("filename\\*=UTF-8''(.+?)(?:;|$)");
|
||||
|
||||
/** 解析 Content-Disposition: filename="xxx" 或 filename=xxx */
|
||||
private static final Pattern CD_FILENAME_PATTERN =
|
||||
Pattern.compile("filename=\"?([^\";]+)\"?");
|
||||
|
||||
/** 解析 Content-Range 中的总大小 */
|
||||
private static final Pattern CONTENT_RANGE_SIZE_PATTERN =
|
||||
Pattern.compile("/(\\d+)");
|
||||
|
||||
public FsTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
String shareUrl = shareLinkInfo.getShareUrl();
|
||||
String tenant = extractTenant(shareUrl);
|
||||
String token = shareLinkInfo.getShareKey();
|
||||
|
||||
if (tenant == null || token == null) {
|
||||
fail("无法从链接中提取tenant或token: {}", shareUrl);
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
boolean isFolder = shareUrl.contains("/drive/folder/");
|
||||
if (isFolder) {
|
||||
fetchSessionAndParseFolder(tenant, token, shareUrl);
|
||||
} else {
|
||||
fetchSessionAndParseFile(tenant, token, shareUrl);
|
||||
}
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取匿名session后解析文件
|
||||
*/
|
||||
private void fetchSessionAndParseFile(String tenant, String token, String shareUrl) {
|
||||
clientSession.getAbs(shareUrl)
|
||||
.putHeader("User-Agent", UA)
|
||||
.putHeader("Accept", "text/html,*/*")
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
String dlUrl = buildDownloadUrl(tenant, token);
|
||||
|
||||
// Range探测获取文件名和大小
|
||||
clientSession.getAbs(dlUrl)
|
||||
.putHeader("User-Agent", UA)
|
||||
.putHeader("Referer", shareUrl)
|
||||
.putHeader("Range", "bytes=0-0")
|
||||
.send()
|
||||
.onSuccess(probeRes -> {
|
||||
String fileName = parseFileNameFromContentDisposition(
|
||||
probeRes.getHeader("Content-Disposition"));
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("Referer", shareUrl);
|
||||
headers.put("User-Agent", UA);
|
||||
|
||||
String cookies = extractCookiesFromResponse(probeRes);
|
||||
if (cookies != null && !cookies.isEmpty()) {
|
||||
headers.put("Cookie", cookies);
|
||||
}
|
||||
|
||||
if (fileName != null) {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setFileName(fileName);
|
||||
fileInfo.setFileId(token);
|
||||
fileInfo.setFileType("file");
|
||||
fileInfo.setPanType(shareLinkInfo.getType());
|
||||
fileInfo.setParserUrl(buildRedirectUrl(shareUrl, token));
|
||||
parseSizeFromContentRange(
|
||||
probeRes.getHeader("Content-Range"), fileInfo);
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||
}
|
||||
|
||||
completeWithMeta(dlUrl, headers);
|
||||
})
|
||||
.onFailure(handleFail("探测文件信息失败"));
|
||||
})
|
||||
.onFailure(handleFail("获取匿名会话失败"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取匿名session后解析文件夹(取第一个可下载文件)
|
||||
*/
|
||||
private void fetchSessionAndParseFolder(String tenant, String folderToken,
|
||||
String shareUrl) {
|
||||
clientSession.getAbs(shareUrl)
|
||||
.putHeader("User-Agent", UA)
|
||||
.putHeader("Accept", "text/html,*/*")
|
||||
.send()
|
||||
.onSuccess(res ->
|
||||
listFolderAll(tenant, folderToken, "").onSuccess(items -> {
|
||||
if (items.isEmpty()) {
|
||||
fail("文件夹中没有可下载的文件");
|
||||
return;
|
||||
}
|
||||
FileInfo first = items.get(0);
|
||||
String objToken = first.getFileId();
|
||||
String dlUrl = buildDownloadUrl(tenant, objToken);
|
||||
String referer = "https://" + tenant
|
||||
+ ".feishu.cn/drive/folder/" + folderToken;
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("Referer", referer);
|
||||
headers.put("User-Agent", UA);
|
||||
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", first);
|
||||
completeWithMeta(dlUrl, headers);
|
||||
}).onFailure(t -> fail("列出文件夹内容失败: {}", t.getMessage())))
|
||||
.onFailure(handleFail("获取匿名会话失败"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> listPromise = Promise.promise();
|
||||
String shareUrl = shareLinkInfo.getShareUrl();
|
||||
String tenant = extractTenant(shareUrl);
|
||||
String token = shareLinkInfo.getShareKey();
|
||||
|
||||
if (tenant == null || token == null) {
|
||||
listPromise.fail("无法从链接中提取tenant或token: " + shareUrl);
|
||||
return listPromise.future();
|
||||
}
|
||||
|
||||
boolean isFolder = shareUrl.contains("/drive/folder/");
|
||||
|
||||
clientSession.getAbs(shareUrl)
|
||||
.putHeader("User-Agent", UA)
|
||||
.putHeader("Accept", "text/html,*/*")
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
if (isFolder) {
|
||||
listFolderAll(tenant, token, "")
|
||||
.onSuccess(listPromise::complete)
|
||||
.onFailure(listPromise::fail);
|
||||
} else {
|
||||
probeSingleFile(tenant, token, shareUrl)
|
||||
.onSuccess(fileInfo -> {
|
||||
List<FileInfo> list = new ArrayList<>();
|
||||
list.add(fileInfo);
|
||||
listPromise.complete(list);
|
||||
})
|
||||
.onFailure(listPromise::fail);
|
||||
}
|
||||
})
|
||||
.onFailure(t -> listPromise.fail("获取匿名会话失败: " + t.getMessage()));
|
||||
|
||||
return listPromise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取文件夹所有可下载文件
|
||||
*/
|
||||
private Future<List<FileInfo>> listFolderAll(String tenant, String folderToken,
|
||||
String pageLabel) {
|
||||
Promise<List<FileInfo>> p = Promise.promise();
|
||||
|
||||
listFolderPage(tenant, folderToken, pageLabel).onSuccess(pageResult -> {
|
||||
List<FileInfo> items = new ArrayList<>(pageResult.items);
|
||||
if (pageResult.hasMore) {
|
||||
listFolderAll(tenant, folderToken, pageResult.nextLabel)
|
||||
.onSuccess(moreItems -> {
|
||||
items.addAll(moreItems);
|
||||
p.complete(items);
|
||||
})
|
||||
.onFailure(p::fail);
|
||||
} else {
|
||||
p.complete(items);
|
||||
}
|
||||
}).onFailure(p::fail);
|
||||
|
||||
return p.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出文件夹内容(单页)
|
||||
*/
|
||||
private Future<FolderPageResult> listFolderPage(String tenant, String folderToken,
|
||||
String pageLabel) {
|
||||
Promise<FolderPageResult> p = Promise.promise();
|
||||
String baseUrl = "https://" + tenant + ".feishu.cn";
|
||||
|
||||
StringBuilder urlBuilder = new StringBuilder();
|
||||
urlBuilder.append(baseUrl)
|
||||
.append("/space/api/explorer/v3/children/list/")
|
||||
.append("?length=").append(PAGE_SIZE)
|
||||
.append("&asc=1&rank=5&token=").append(folderToken);
|
||||
|
||||
for (int type : LIST_OBJ_TYPES) {
|
||||
urlBuilder.append("&obj_type=").append(type);
|
||||
}
|
||||
|
||||
if (pageLabel != null && !pageLabel.isEmpty()) {
|
||||
urlBuilder.append("&last_label=").append(pageLabel);
|
||||
}
|
||||
|
||||
String url = urlBuilder.toString();
|
||||
String referer = baseUrl + "/drive/folder/" + folderToken;
|
||||
|
||||
clientSession.getAbs(url)
|
||||
.putHeader("User-Agent", UA)
|
||||
.putHeader("Accept", "application/json, text/plain, */*")
|
||||
.putHeader("Referer", referer)
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
try {
|
||||
JsonObject json = asJson(res);
|
||||
int code = json.getInteger("code", -1);
|
||||
if (code != 0) {
|
||||
p.fail("飞书API错误: " + json.getString("msg"));
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject data = json.getJsonObject("data");
|
||||
JsonObject entities = data.getJsonObject("entities",
|
||||
new JsonObject());
|
||||
JsonObject nodes = entities.getJsonObject("nodes",
|
||||
new JsonObject());
|
||||
JsonArray nodeList = data.getJsonArray("node_list",
|
||||
new JsonArray());
|
||||
|
||||
List<FileInfo> items = new ArrayList<>();
|
||||
for (int i = 0; i < nodeList.size(); i++) {
|
||||
String nid = nodeList.getString(i);
|
||||
JsonObject node = nodes.getJsonObject(nid,
|
||||
new JsonObject());
|
||||
int objType = node.getInteger("type", -1);
|
||||
String objToken = node.getString("obj_token", "");
|
||||
String name = node.getString("name", "unknown");
|
||||
|
||||
// 排除文件夹自身节点
|
||||
if (objToken.equals(folderToken)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只返回可下载的文件(type=12)
|
||||
if (objType == OBJ_TYPE_FILE) {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setFileName(name);
|
||||
fileInfo.setFileId(objToken);
|
||||
fileInfo.setPanType(shareLinkInfo.getType());
|
||||
fileInfo.setFileType("file");
|
||||
|
||||
JsonObject extra = node.getJsonObject("extra",
|
||||
new JsonObject());
|
||||
try {
|
||||
long size = Long.parseLong(
|
||||
extra.getString("size", "0"));
|
||||
fileInfo.setSize(size);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("无法解析文件大小: {}", extra.getString("size"), e);
|
||||
}
|
||||
|
||||
fileInfo.setParserUrl(buildRedirectUrl(
|
||||
shareLinkInfo.getShareUrl(), objToken));
|
||||
|
||||
// 添加下载所需的请求头到extParameters
|
||||
Map<String, Object> extParams = new HashMap<>();
|
||||
Map<String, String> downloadHeaders = new HashMap<>();
|
||||
downloadHeaders.put("Referer", referer);
|
||||
downloadHeaders.put("User-Agent", UA);
|
||||
extParams.put("downloadHeaders", downloadHeaders);
|
||||
fileInfo.setExtParameters(extParams);
|
||||
|
||||
items.add(fileInfo);
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasMore = data.getBoolean("has_more", false);
|
||||
String nextLabel = data.getString("last_label", "");
|
||||
|
||||
p.complete(new FolderPageResult(items, hasMore, nextLabel));
|
||||
} catch (Exception e) {
|
||||
p.fail("解析文件列表响应失败: " + e.getMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(t -> p.fail("请求文件列表失败: " + t.getMessage()));
|
||||
|
||||
return p.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测单个文件信息
|
||||
*/
|
||||
private Future<FileInfo> probeSingleFile(String tenant, String token,
|
||||
String referer) {
|
||||
Promise<FileInfo> p = Promise.promise();
|
||||
String dlUrl = buildDownloadUrl(tenant, token);
|
||||
|
||||
clientSession.getAbs(dlUrl)
|
||||
.putHeader("User-Agent", UA)
|
||||
.putHeader("Referer", referer)
|
||||
.putHeader("Range", "bytes=0-0")
|
||||
.send()
|
||||
.onSuccess(probeRes -> {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
String fileName = parseFileNameFromContentDisposition(
|
||||
probeRes.getHeader("Content-Disposition"));
|
||||
if (fileName != null) {
|
||||
fileInfo.setFileName(fileName);
|
||||
}
|
||||
parseSizeFromContentRange(
|
||||
probeRes.getHeader("Content-Range"), fileInfo);
|
||||
fileInfo.setFileId(token);
|
||||
fileInfo.setPanType(shareLinkInfo.getType());
|
||||
fileInfo.setFileType("file");
|
||||
fileInfo.setParserUrl(buildRedirectUrl(referer, token));
|
||||
|
||||
// 添加下载所需的请求头到extParameters
|
||||
Map<String, Object> extParams = new HashMap<>();
|
||||
Map<String, String> downloadHeaders = new HashMap<>();
|
||||
downloadHeaders.put("Referer", referer);
|
||||
downloadHeaders.put("User-Agent", UA);
|
||||
extParams.put("downloadHeaders", downloadHeaders);
|
||||
fileInfo.setExtParameters(extParams);
|
||||
|
||||
p.complete(fileInfo);
|
||||
})
|
||||
.onFailure(t -> p.fail("探测文件失败: " + t.getMessage()));
|
||||
|
||||
return p.future();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
Promise<String> parsePromise = Promise.promise();
|
||||
|
||||
try {
|
||||
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
|
||||
if (paramJson == null) {
|
||||
parsePromise.fail("缺少 paramJson 参数");
|
||||
return parsePromise.future();
|
||||
}
|
||||
String shareUrl = paramJson.getString("shareUrl");
|
||||
String objToken = paramJson.getString("objToken");
|
||||
String tenant = extractTenant(shareUrl);
|
||||
|
||||
if (shareUrl == null || objToken == null || tenant == null) {
|
||||
parsePromise.fail("飞书目录文件下载参数不完整");
|
||||
return parsePromise.future();
|
||||
}
|
||||
|
||||
parsePromise.complete(buildDownloadUrl(tenant, objToken));
|
||||
} catch (Exception e) {
|
||||
parsePromise.fail("解析飞书目录文件参数失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return parsePromise.future();
|
||||
}
|
||||
|
||||
// ─── 工具方法 ────────────────────────────────────────
|
||||
|
||||
private String buildRedirectUrl(String shareUrl, String objToken) {
|
||||
JsonObject paramJson = new JsonObject()
|
||||
.put("shareUrl", shareUrl)
|
||||
.put("objToken", objToken);
|
||||
return String.format("%s/v2/redirectUrl/%s/%s",
|
||||
getDomainName(),
|
||||
shareLinkInfo.getType(),
|
||||
CommonUtils.urlBase64Encode(paramJson.encode()));
|
||||
}
|
||||
|
||||
private String buildDownloadUrl(String tenant, String objToken) {
|
||||
return "https://" + tenant
|
||||
+ ".feishu.cn/space/api/box/stream/download/all/" + objToken;
|
||||
}
|
||||
|
||||
private String extractTenant(String url) {
|
||||
if (url == null) return null;
|
||||
Matcher m = TENANT_PATTERN.matcher(url);
|
||||
if (m.find()) {
|
||||
return m.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Content-Disposition头解析文件名。
|
||||
* 支持 filename*=UTF-8''xxx 和 filename="xxx" 两种格式。
|
||||
*/
|
||||
private String parseFileNameFromContentDisposition(String cd) {
|
||||
if (cd == null || cd.isEmpty()) return null;
|
||||
|
||||
// 优先解析 filename*=UTF-8''xxx
|
||||
Matcher m1 = CD_FILENAME_STAR_PATTERN.matcher(cd);
|
||||
if (m1.find()) {
|
||||
try {
|
||||
return URLDecoder.decode(m1.group(1).trim(), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
// 降级解析 filename="xxx" 或 filename=xxx
|
||||
Matcher m2 = CD_FILENAME_PATTERN.matcher(cd);
|
||||
if (m2.find()) {
|
||||
try {
|
||||
return URLDecoder.decode(m2.group(1).trim(), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void parseSizeFromContentRange(String cr, FileInfo fileInfo) {
|
||||
if (cr != null) {
|
||||
Matcher m = CONTENT_RANGE_SIZE_PATTERN.matcher(cr);
|
||||
if (m.find()) {
|
||||
fileInfo.setSize(Long.parseLong(m.group(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String extractCookiesFromResponse(
|
||||
io.vertx.ext.web.client.HttpResponse<?> response) {
|
||||
List<String> setCookies = response.cookies();
|
||||
if (setCookies == null || setCookies.isEmpty()) return null;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String cookie : setCookies) {
|
||||
String nameValue = cookie.split(";")[0].trim();
|
||||
if (!sb.isEmpty()) sb.append("; ");
|
||||
sb.append(nameValue);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹分页结果
|
||||
*/
|
||||
private record FolderPageResult(List<FileInfo> items, boolean hasMore,
|
||||
String nextLabel) {
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,12 @@ public class GenShortUrl extends PanBase {
|
||||
private static final String WRAPPER_URL = "https://www.so.com/link?m=ewgUSYiFWXIoTybC3fJH8YoJy8y10iRquo6cazgINwWjTn3HvVJ92TrCJu0PmMUR0RMDfOAucP3wa4G8j64SrhNH9Z0Cr0PEyn9ASuvpkUGmAjjUEGJkO5%2BIDGWVrEkPHsL7UsoKO6%2BlT%2BD6r&ccc=";
|
||||
private static final String MID = "5095144728824883"; // 微博的mid
|
||||
|
||||
private static final Pattern SHORT_URL_PATTERN = Pattern.compile("(https?)://t.cn/\\w+");
|
||||
private static final Pattern COMMENT_ID_PATTERN = Pattern.compile("comment_id=\"(\\d+)\"");
|
||||
|
||||
private static final MultiMap HEADER = HeadersMultiMap.headers()
|
||||
.add("Content-Type", "application/x-www-form-urlencoded")
|
||||
.add("Referer", "https://www.weibo.com")
|
||||
.add("Content-Type", "application/x-www-form-urlencoded")
|
||||
.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36");
|
||||
|
||||
Cookie cookie = new DefaultCookie("SUB", "_2A25KJE5vDeRhGeRJ6lsR9SjJzDuIHXVpWM-nrDV8PUJbkNAbLVPlkW1NUmJm3GjYtRHBsHdMUKafkdTL_YheMEmu");
|
||||
@@ -64,11 +66,12 @@ public class GenShortUrl extends PanBase {
|
||||
String shortUrl = extractShortUrl(comment);
|
||||
if (shortUrl != null) {
|
||||
log.info("生成的短链:{}", shortUrl);
|
||||
// 先完成 promise,返回短链
|
||||
promise.complete(shortUrl);
|
||||
// 异步清理评论(best-effort,不影响结果)
|
||||
String commentId = extractCommentId(comment);
|
||||
if (commentId != null) {
|
||||
deleteComment(commentId);
|
||||
} else {
|
||||
promise.fail("未能提取评论ID");
|
||||
}
|
||||
} else {
|
||||
promise.fail("未能生成短链");
|
||||
@@ -103,8 +106,7 @@ public class GenShortUrl extends PanBase {
|
||||
}
|
||||
|
||||
private String extractShortUrl(String comment) {
|
||||
Pattern pattern = Pattern.compile("(https?)://t.cn/\\w+");
|
||||
Matcher matcher = pattern.matcher(comment);
|
||||
Matcher matcher = SHORT_URL_PATTERN.matcher(comment);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(0);
|
||||
}
|
||||
@@ -112,8 +114,7 @@ public class GenShortUrl extends PanBase {
|
||||
}
|
||||
|
||||
private String extractCommentId(String comment) {
|
||||
Pattern pattern = Pattern.compile("comment_id=\"(\\d+)\"");
|
||||
Matcher matcher = pattern.matcher(comment);
|
||||
Matcher matcher = COMMENT_ID_PATTERN.matcher(comment);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShareLinkInfo getShareLinkInfo() {
|
||||
return selectedTool.getShareLinkInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
IPanTool.closeQuietly(selectedTool);
|
||||
}
|
||||
}
|
||||
@@ -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,309 @@ 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();
|
||||
String location = headers.get("Location");
|
||||
if (StringUtils.isBlank(location)) {
|
||||
fail("{}", buildMissingLocationMessage(res2));
|
||||
return;
|
||||
}
|
||||
promise.complete(location);
|
||||
}
|
||||
|
||||
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
|
||||
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
|
||||
message.append(", HTTP ").append(response.statusCode());
|
||||
|
||||
String body = null;
|
||||
try {
|
||||
body = asText(response);
|
||||
} catch (Exception e) {
|
||||
body = "<响应体读取失败: " + e.getMessage() + ">";
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(body)) {
|
||||
try {
|
||||
JsonObject json = new JsonObject(body);
|
||||
String upstreamMsg = json.getString("msg");
|
||||
Object code = json.getValue("code");
|
||||
if (StringUtils.isNotBlank(upstreamMsg)) {
|
||||
message.append(", 上游返回: ").append(upstreamMsg);
|
||||
if (code != null) {
|
||||
message.append(" (code=").append(code).append(")");
|
||||
}
|
||||
} else {
|
||||
message.append(", 响应体: ").append(previewBody(body));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
message.append(", 响应体: ").append(previewBody(body));
|
||||
}
|
||||
} else {
|
||||
message.append(", 响应体为空");
|
||||
}
|
||||
|
||||
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
|
||||
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
|
||||
if (fileName != null) {
|
||||
message.append(", 文件: ").append(fileName);
|
||||
}
|
||||
if (fileSize != null) {
|
||||
message.append(", 大小: ").append(fileSize);
|
||||
}
|
||||
if (!hasConfiguredAuth()) {
|
||||
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
|
||||
}
|
||||
return message.toString();
|
||||
}
|
||||
|
||||
private boolean hasConfiguredAuth() {
|
||||
Object authObj = shareLinkInfo.getOtherParam().get("auths");
|
||||
if (!(authObj instanceof MultiMap auths)) {
|
||||
return false;
|
||||
}
|
||||
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
|
||||
}
|
||||
|
||||
private String previewBody(String body) {
|
||||
int maxLength = 500;
|
||||
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
// 目录解析
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
@@ -200,16 +521,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 +538,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 +564,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
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();
|
||||
String location = headers.get("Location");
|
||||
if (StringUtils.isBlank(location)) {
|
||||
fail("{}", buildMissingLocationMessage(res2));
|
||||
return;
|
||||
}
|
||||
promise.complete(location);
|
||||
}
|
||||
|
||||
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
|
||||
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
|
||||
message.append(", HTTP ").append(response.statusCode());
|
||||
|
||||
String body = null;
|
||||
try {
|
||||
body = asText(response);
|
||||
} catch (Exception e) {
|
||||
body = "<响应体读取失败: " + e.getMessage() + ">";
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(body)) {
|
||||
try {
|
||||
JsonObject json = new JsonObject(body);
|
||||
String upstreamMsg = json.getString("msg");
|
||||
Object code = json.getValue("code");
|
||||
if (StringUtils.isNotBlank(upstreamMsg)) {
|
||||
message.append(", 上游返回: ").append(upstreamMsg);
|
||||
if (code != null) {
|
||||
message.append(" (code=").append(code).append(")");
|
||||
}
|
||||
} else {
|
||||
message.append(", 响应体: ").append(previewBody(body));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
message.append(", 响应体: ").append(previewBody(body));
|
||||
}
|
||||
} else {
|
||||
message.append(", 响应体为空");
|
||||
}
|
||||
|
||||
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
|
||||
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
|
||||
if (fileName != null) {
|
||||
message.append(", 文件: ").append(fileName);
|
||||
}
|
||||
if (fileSize != null) {
|
||||
message.append(", 大小: ").append(fileSize);
|
||||
}
|
||||
if (!hasConfiguredAuth()) {
|
||||
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
|
||||
}
|
||||
return message.toString();
|
||||
}
|
||||
|
||||
private boolean hasConfiguredAuth() {
|
||||
Object authObj = shareLinkInfo.getOtherParam().get("auths");
|
||||
if (!(authObj instanceof MultiMap auths)) {
|
||||
return false;
|
||||
}
|
||||
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
|
||||
}
|
||||
|
||||
private String previewBody(String body) {
|
||||
int maxLength = 500;
|
||||
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
// 目录解析
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import java.net.URLEncoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ public class LeTool extends PanBase {
|
||||
private static final String API_URL_PREFIX = "https://lecloud.lenovo.com/mshare/api/clouddiskapi/share/public/v1/";
|
||||
private static final String DEFAULT_FILE_TYPE = "file";
|
||||
private static final int FILE_TYPE_DIRECTORY = 0; // 目录类型
|
||||
private static final Random RANDOM = new Random();
|
||||
|
||||
private static final MultiMap HEADERS;
|
||||
|
||||
@@ -100,8 +102,8 @@ public class LeTool extends PanBase {
|
||||
}
|
||||
|
||||
String fileId = fileInfoJson.getString("fileId");
|
||||
// 根据文件ID获取跳转链接
|
||||
getDownURL(dataKey, fileId);
|
||||
// 根据文件ID获取跳转链接(随机选择方式,失败自动fallback)
|
||||
getDownURLWithFallback(dataKey, fileId);
|
||||
}
|
||||
} else {
|
||||
fail("{}: {}", resJson.getString("errcode"), resJson.getString("errmsg"));
|
||||
@@ -260,8 +262,8 @@ public class LeTool extends PanBase {
|
||||
String shareId = paramJson.getString("shareId");
|
||||
String fileId = paramJson.getString("fileId");
|
||||
|
||||
// 调用获取下载链接
|
||||
getDownURLForById(shareId, fileId, parsePromise);
|
||||
// 调用获取下载链接(随机选择方式,失败自动fallback)
|
||||
getDownURLWithFallbackForById(shareId, fileId, parsePromise);
|
||||
|
||||
} catch (Exception e) {
|
||||
parsePromise.fail("解析参数失败: " + e.getMessage());
|
||||
@@ -304,14 +306,22 @@ public class LeTool extends PanBase {
|
||||
}).onFailure(err -> promise.fail(err));
|
||||
}
|
||||
|
||||
private void getDownURL(String key, String fileId) {
|
||||
/**
|
||||
* 通过 packageDownloadWithFileIds 接口获取下载链接
|
||||
* 需要两步:先获取 downloadUrl,再请求 302 跳转
|
||||
*
|
||||
* @param shareId 分享ID
|
||||
* @param fileId 文件ID
|
||||
* @param promise 完成时会写入此 promise
|
||||
*/
|
||||
private void getDownURL(String shareId, String fileId, Promise<String> promise) {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
JsonArray fileIds = JsonArray.of(fileId);
|
||||
String apiUrl2 = API_URL_PREFIX + "packageDownloadWithFileIds";
|
||||
String apiUrl = API_URL_PREFIX + "packageDownloadWithFileIds";
|
||||
// {"fileIds":[123],"shareId":"xxx","browserId":"uuid"}
|
||||
client.postAbs(apiUrl2)
|
||||
client.postAbs(apiUrl)
|
||||
.putHeaders(HEADERS)
|
||||
.sendJsonObject(JsonObject.of("fileIds", fileIds, "shareId", key, "browserId", uuid))
|
||||
.sendJsonObject(JsonObject.of("fileIds", fileIds, "shareId", shareId, "browserId", uuid))
|
||||
.onSuccess(res -> {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson.containsKey("result")) {
|
||||
@@ -320,20 +330,107 @@ public class LeTool extends PanBase {
|
||||
// 获取重定向链接跳转链接
|
||||
String downloadUrl = dataJson.getString("downloadUrl");
|
||||
if (downloadUrl == null) {
|
||||
fail("Result JSON数据异常: downloadUrl不存在");
|
||||
promise.fail("Result JSON数据异常: downloadUrl不存在");
|
||||
return;
|
||||
}
|
||||
// 获取重定向链接跳转链接
|
||||
clientNoRedirects.getAbs(downloadUrl).send()
|
||||
.onSuccess(res2 -> promise.complete(res2.headers().get("Location")))
|
||||
.onFailure(handleFail(downloadUrl));
|
||||
.onFailure(err -> promise.fail(err));
|
||||
} else {
|
||||
fail("{}: {}", resJson.getString("errcode"), resJson.getString("errmsg"));
|
||||
promise.fail(resJson.getString("errcode") + ": " + resJson.getString("errmsg"));
|
||||
}
|
||||
} else {
|
||||
fail("Result JSON数据异常: result字段不存在");
|
||||
promise.fail("Result JSON数据异常: result字段不存在");
|
||||
}
|
||||
}).onFailure(handleFail(apiUrl2));
|
||||
}).onFailure(err -> promise.fail(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 directDownload 接口获取下载链接
|
||||
* 相比 packageDownloadWithFileIds 少一次请求,直接返回302
|
||||
*
|
||||
* @param shareId 分享ID
|
||||
* @param fileId 文件ID
|
||||
* @param promise 完成时会写入此 promise
|
||||
*/
|
||||
private void getDownURLDirect(String shareId, String fileId, Promise<String> promise) {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
String apiUrl = API_URL_PREFIX + "directDownload"
|
||||
+ "?shareId=" + shareId
|
||||
+ "&fileId=" + fileId
|
||||
+ "&browserId=" + uuid;
|
||||
|
||||
clientNoRedirects.getAbs(apiUrl)
|
||||
.putHeaders(HEADERS)
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
String location = res.headers().get("Location");
|
||||
if (location != null && !location.isEmpty()) {
|
||||
promise.complete(location);
|
||||
} else {
|
||||
log.warn("directDownload 返回非302响应: shareId={}, fileId={}, statusCode={}", shareId, fileId, res.statusCode());
|
||||
promise.fail("directDownload 未返回有效的 Location, statusCode=" + res.statusCode());
|
||||
}
|
||||
})
|
||||
.onFailure(err -> {
|
||||
log.warn("directDownload 请求失败: shareId={}, fileId={}, error={}", shareId, fileId, err.getMessage());
|
||||
promise.fail(err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机选择下载方式并带 fallback(用于 parse)
|
||||
* 先随机选择 directDownload 或 packageDownloadWithFileIds,失败则尝试另一个
|
||||
*/
|
||||
private void getDownURLWithFallback(String shareId, String fileId) {
|
||||
boolean useDirect = RANDOM.nextBoolean();
|
||||
log.info("乐云下载方式选择: shareId={}, fileId={}, method={}", shareId, fileId, useDirect ? "directDownload" : "packageDownloadWithFileIds");
|
||||
|
||||
Promise<String> fallbackPromise = Promise.promise();
|
||||
fallbackPromise.future().onSuccess(url -> {
|
||||
promise.complete(url);
|
||||
}).onFailure(err -> {
|
||||
log.warn("乐云第一种下载方式失败,尝试另一种: {}", err.getMessage());
|
||||
if (useDirect) {
|
||||
getDownURL(shareId, fileId, promise);
|
||||
} else {
|
||||
getDownURLDirect(shareId, fileId, promise);
|
||||
}
|
||||
});
|
||||
|
||||
if (useDirect) {
|
||||
getDownURLDirect(shareId, fileId, fallbackPromise);
|
||||
} else {
|
||||
getDownURL(shareId, fileId, fallbackPromise);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机选择下载方式并带 fallback(用于 parseById)
|
||||
* 先随机选择 directDownload 或 packageDownloadWithFileIds,失败则尝试另一个
|
||||
*/
|
||||
private void getDownURLWithFallbackForById(String shareId, String fileId, Promise<String> promise) {
|
||||
boolean useDirect = RANDOM.nextBoolean();
|
||||
log.info("乐云下载方式选择(parseById): shareId={}, fileId={}, method={}", shareId, fileId, useDirect ? "directDownload" : "packageDownloadWithFileIds");
|
||||
|
||||
Promise<String> fallbackPromise = Promise.promise();
|
||||
fallbackPromise.future().onSuccess(url -> {
|
||||
promise.complete(url);
|
||||
}).onFailure(err -> {
|
||||
log.warn("乐云第一种下载方式失败,尝试另一种: {}", err.getMessage());
|
||||
if (useDirect) {
|
||||
getDownURLForById(shareId, fileId, promise);
|
||||
} else {
|
||||
getDownURLDirect(shareId, fileId, promise);
|
||||
}
|
||||
});
|
||||
|
||||
if (useDirect) {
|
||||
getDownURLDirect(shareId, fileId, fallbackPromise);
|
||||
} else {
|
||||
getDownURLForById(shareId, fileId, fallbackPromise);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,9 @@ import io.vertx.ext.web.client.WebClientSession;
|
||||
import org.openjdk.nashorn.api.scripting.ScriptObjectMirror;
|
||||
|
||||
import javax.script.ScriptException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -30,6 +33,20 @@ public class LzTool extends PanBase {
|
||||
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
|
||||
public static final String SHARE_URL_PREFIX = "https://w1.lanzn.com/";
|
||||
|
||||
// 静态编译的正则表达式,避免每次调用都重新编译
|
||||
private static final Pattern FILE_NAME_PATTERN = Pattern.compile("padding: 56px 0px 20px 0px;\">(.*?)<|filenajax\">(.*?)<");
|
||||
private static final Pattern FILE_SIZE_PATTERN = Pattern.compile(">文件大小:</span>(.*?)<br>|\"n_filesize\">大小:(.*?)</div>");
|
||||
private static final Pattern SHARE_USER_PATTERN = Pattern.compile(">分享用户:</span><font>(.*?)</font>|获取<span>(.*?)</span>的文件|\"user-name\">(.*?)</");
|
||||
private static final Pattern DESCRIPTION_PATTERN = Pattern.compile("(?s)文件描述:</span><br>(.*?)</td>|class=\"n_box_des\">(.*?)</div>");
|
||||
private static final Pattern FILE_ID_PATTERN = Pattern.compile("\\?f=(.*?)&|fid = (.*?);");
|
||||
private static final Pattern CREATE_TIME_PATTERN = Pattern.compile(">上传时间:</span>(.*?)<");
|
||||
private static final Pattern URL_DATE_PATTERN = Pattern.compile("(\\d{4}/\\d{1,2}/\\d{1,2})");
|
||||
private static final Pattern ARG1_PATTERN = Pattern.compile("var arg1='([^']+)'");
|
||||
private static final Pattern IFRAME_SRC_PATTERN = Pattern.compile("src=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\"");
|
||||
private static final Pattern RELATIVE_TIME_PATTERN = Pattern.compile("^(\\d+|几)\\s*(分钟|小时)前$");
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile("^(\\d{4})\\s*[-/年]\\s*(\\d{1,2})\\s*[-/月]\\s*(\\d{1,2})\\s*日?$");
|
||||
private static final Pattern MONTH_DAY_PATTERN = Pattern.compile("^(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*日?$");
|
||||
MultiMap headers0 = HeaderUtils.parseHeaders("""
|
||||
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
|
||||
Accept-Encoding: gzip, deflate
|
||||
@@ -61,19 +78,30 @@ public class LzTool extends PanBase {
|
||||
client.getAbs(sUrl)
|
||||
.putHeaders(headers0)
|
||||
.send().onSuccess(res -> {
|
||||
String html = asText(res);
|
||||
if (html.contains("var arg1='")) {
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
setCookie(html);
|
||||
webClientSession.getAbs(sUrl)
|
||||
.putHeaders(headers0)
|
||||
.send().onSuccess(res2 -> {
|
||||
String html2 = asText(res2);
|
||||
doParser(html2, pwd, sUrl);
|
||||
});
|
||||
try {
|
||||
String html = asText(res);
|
||||
if (hasAcwArg1(html)) {
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
if (!setCookie(html, sUrl)) {
|
||||
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
|
||||
return;
|
||||
}
|
||||
webClientSession.getAbs(sUrl)
|
||||
.putHeaders(headers0)
|
||||
.send().onSuccess(res2 -> {
|
||||
try {
|
||||
String html2 = asText(res2);
|
||||
doParser(html2, pwd, sUrl);
|
||||
} catch (Exception e) {
|
||||
fail("蓝奏云页面响应处理异常: {}", e.getMessage());
|
||||
}
|
||||
}).onFailure(handleFail(sUrl));
|
||||
|
||||
} else {
|
||||
doParser(html, pwd, sUrl);
|
||||
} else {
|
||||
doParser(html, pwd, sUrl);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail("蓝奏云页面响应处理异常: {}", e.getMessage());
|
||||
}
|
||||
|
||||
}).onFailure(handleFail(sUrl));
|
||||
@@ -81,14 +109,63 @@ public class LzTool extends PanBase {
|
||||
}
|
||||
|
||||
private void doParser(String html, String pwd, String sUrl) {
|
||||
if (html == null || html.isBlank()) {
|
||||
fail("蓝奏云页面响应为空");
|
||||
return;
|
||||
}
|
||||
if (isShareCancelledPage(html)) {
|
||||
fail("分享已失效或文件已取消分享");
|
||||
return;
|
||||
}
|
||||
// 检测是否为目录分享链接 (含 /s/、/b/ 路径段或 b 开头的路径段)
|
||||
if (sUrl.matches(".*/(s|b)/[^/]+.*") || sUrl.matches(".*/b[^/]+.*")) {
|
||||
fail("该链接为蓝奏云目录分享,请使用目录解析接口");
|
||||
return;
|
||||
}
|
||||
// 若仍是校验页 (parse()中cookie域名与实际URL不匹配时会出现), 重试一次
|
||||
if (hasAcwArg1(html)) {
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
if (!setCookie(html, sUrl)) {
|
||||
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
|
||||
return;
|
||||
}
|
||||
webClientSession.getAbs(sUrl).putHeaders(headers0).send().onSuccess(res -> {
|
||||
try {
|
||||
String html2 = asText(res);
|
||||
if (isShareCancelledPage(html2)) {
|
||||
fail("分享已失效或文件已取消分享");
|
||||
return;
|
||||
}
|
||||
if (hasAcwArg1(html2)) {
|
||||
fail("蓝奏云反爬校验失败,请稍后重试");
|
||||
return;
|
||||
}
|
||||
doParserInternal(html2, pwd, sUrl);
|
||||
} catch (Exception e) {
|
||||
fail("蓝奏云页面响应处理异常: {}", e.getMessage());
|
||||
}
|
||||
}).onFailure(handleFail(sUrl));
|
||||
return;
|
||||
}
|
||||
doParserInternal(html, pwd, sUrl);
|
||||
}
|
||||
|
||||
private void doParserInternal(String html, String pwd, String sUrl) {
|
||||
if (html == null || html.isBlank()) {
|
||||
fail("蓝奏云页面响应为空");
|
||||
return;
|
||||
}
|
||||
if (isShareCancelledPage(html)) {
|
||||
fail("分享已失效或文件已取消分享");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setFileInfo(html, shareLinkInfo);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
log.error("文件信息解析异常", e);
|
||||
}
|
||||
// 匹配iframe
|
||||
Pattern compile = Pattern.compile("src=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\"");
|
||||
Matcher matcher = compile.matcher(html);
|
||||
Matcher matcher = IFRAME_SRC_PATTERN.matcher(html);
|
||||
// 没有Iframe说明是加密分享, 匹配sign通过密码请求下载页面
|
||||
if (!matcher.find()) {
|
||||
try {
|
||||
@@ -98,57 +175,87 @@ 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 jsText = getJsText(html2);
|
||||
if (jsText == null) {
|
||||
headers0.add("Referer", absoluteURI);
|
||||
setCookie(html2);
|
||||
webClientSession.getAbs(absoluteURI).send().onSuccess(res3 -> {
|
||||
String html3= asText(res3);
|
||||
String jsText3 = getJsText(html3);
|
||||
if (jsText3 != null) {
|
||||
try {
|
||||
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText3, null);
|
||||
getDownURL(sUrl, scriptObjectMirror);
|
||||
} catch (ScriptException | NoSuchMethodException e) {
|
||||
fail(e, "引擎执行失败");
|
||||
}
|
||||
} else {
|
||||
|
||||
fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效");
|
||||
// 创建局部副本,避免修改实例字段导致累积
|
||||
MultiMap headersCopy = MultiMap.caseInsensitiveMultiMap().addAll(headers0);
|
||||
headersCopy.add("Referer", absoluteURI);
|
||||
webClientSession.getAbs(absoluteURI).putHeaders(headersCopy).send().onSuccess(res2 -> {
|
||||
try {
|
||||
String html2 = asText(res2);
|
||||
if (isShareCancelledPage(html2)) {
|
||||
fail("分享已失效或文件已取消分享");
|
||||
return;
|
||||
}
|
||||
String jsText = getJsText(html2);
|
||||
if (jsText == null) {
|
||||
if (!setCookie(html2, absoluteURI)) {
|
||||
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
|
||||
return;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, null);
|
||||
getDownURL(sUrl, scriptObjectMirror);
|
||||
} catch (ScriptException | NoSuchMethodException e) {
|
||||
fail(e, "js引擎执行失败");
|
||||
webClientSession.getAbs(absoluteURI).send().onSuccess(res3 -> {
|
||||
try {
|
||||
String html3 = asText(res3);
|
||||
if (isShareCancelledPage(html3)) {
|
||||
fail("分享已失效或文件已取消分享");
|
||||
return;
|
||||
}
|
||||
String jsText3 = getJsText(html3);
|
||||
if (jsText3 != null) {
|
||||
try {
|
||||
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText3, null);
|
||||
getDownURL(sUrl, scriptObjectMirror);
|
||||
} catch (ScriptException | NoSuchMethodException e) {
|
||||
fail(e, "引擎执行失败");
|
||||
}
|
||||
} else {
|
||||
fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail("蓝奏云 iframe 响应处理异常: {}", e.getMessage());
|
||||
}
|
||||
}).onFailure(handleFail(absoluteURI));
|
||||
} else {
|
||||
try {
|
||||
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, null);
|
||||
getDownURL(sUrl, scriptObjectMirror);
|
||||
} catch (ScriptException | NoSuchMethodException e) {
|
||||
fail(e, "js引擎执行失败");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail("蓝奏云 iframe 响应处理异常: {}", e.getMessage());
|
||||
}
|
||||
}).onFailure(handleFail(SHARE_URL_PREFIX));
|
||||
}
|
||||
}
|
||||
|
||||
private void setCookie(String html2) {
|
||||
int beginIndex = html2.indexOf("arg1='") + 6;
|
||||
String arg1 = html2.substring(beginIndex, html2.indexOf("';", beginIndex));
|
||||
private boolean setCookie(String html, String url) {
|
||||
String arg1 = extractAcwArg1(html);
|
||||
if (arg1 == null) {
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
private String getJsByPwd(String pwd, String html, String subText) {
|
||||
@@ -166,6 +273,9 @@ public class LzTool extends PanBase {
|
||||
}
|
||||
|
||||
private String getJsText(String html) {
|
||||
if (html == null) {
|
||||
return null;
|
||||
}
|
||||
String jsTagStart = "<script type=\"text/javascript\">";
|
||||
String jsTagEnd = "</script>";
|
||||
int index = html.lastIndexOf(jsTagStart);
|
||||
@@ -174,23 +284,52 @@ public class LzTool extends PanBase {
|
||||
}
|
||||
int startPos = index + jsTagStart.length();
|
||||
int endPos = html.indexOf(jsTagEnd, startPos);
|
||||
if (endPos <= startPos) {
|
||||
return null;
|
||||
}
|
||||
return html.substring(startPos, endPos).replaceAll("<!--.*-->", "");
|
||||
}
|
||||
|
||||
static String extractAcwArg1(String html) {
|
||||
if (html == null) {
|
||||
return null;
|
||||
}
|
||||
int beginIndex = html.indexOf("arg1='");
|
||||
if (beginIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
beginIndex += 6;
|
||||
int endIndex = html.indexOf("';", beginIndex);
|
||||
if (endIndex <= beginIndex) {
|
||||
return null;
|
||||
}
|
||||
return html.substring(beginIndex, endIndex);
|
||||
}
|
||||
|
||||
static boolean isShareCancelledPage(String html) {
|
||||
return html != null
|
||||
&& ((html.contains("来晚啦") && html.contains("取消分享"))
|
||||
|| (html.contains("class=\"off\"") && html.contains("取消分享")));
|
||||
}
|
||||
|
||||
private static boolean hasAcwArg1(String html) {
|
||||
return html != null && html.contains("var arg1='");
|
||||
}
|
||||
|
||||
private void getDownURL(String key, Map<String, ?> obj) {
|
||||
if (obj == null) {
|
||||
fail("需要访问密码");
|
||||
return;
|
||||
}
|
||||
Map<?, ?> signMap = (Map<?, ?>)obj.get("data");
|
||||
String url0 = obj.get("url").toString();
|
||||
String url0 = String.valueOf(obj.get("url"));
|
||||
MultiMap map = MultiMap.caseInsensitiveMultiMap();
|
||||
signMap.forEach((k, v) -> {
|
||||
map.add((String) k, v.toString());
|
||||
});
|
||||
MultiMap headers = HeaderUtils.parseHeaders("""
|
||||
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
|
||||
Accept-Encoding: gzip, deflate, br, zstd
|
||||
Accept-Encoding: gzip, deflate, br
|
||||
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
|
||||
Cache-Control: no-cache
|
||||
Connection: keep-alive
|
||||
@@ -218,7 +357,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);
|
||||
}
|
||||
|
||||
@@ -226,34 +365,57 @@ public class LzTool extends PanBase {
|
||||
headers.remove("Referer");
|
||||
webClientSession.getAbs(downUrl).putHeaders(headers).send()
|
||||
.onSuccess(res3 -> {
|
||||
String location = res3.headers().get("Location");
|
||||
if (location == null) {
|
||||
String text = asText(res3);
|
||||
// 使用cookie 再请求一次
|
||||
headers.add("Referer", downUrl);
|
||||
int beginIndex = text.indexOf("arg1='") + 6;
|
||||
String arg1 = text.substring(beginIndex, text.indexOf("';", beginIndex));
|
||||
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
|
||||
// 创建一个 Cookie 并放入 CookieStore
|
||||
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
|
||||
nettyCookie.setDomain(".lanrar.com"); // 设置域名
|
||||
nettyCookie.setPath("/"); // 设置路径
|
||||
nettyCookie.setSecure(false);
|
||||
nettyCookie.setHttpOnly(false);
|
||||
WebClientSession webClientSession2 = WebClientSession.create(clientNoRedirects);
|
||||
webClientSession2.cookieStore().put(nettyCookie);
|
||||
webClientSession2.getAbs(downUrl).putHeaders(headers).send()
|
||||
.onSuccess(res4 -> {
|
||||
String location0 = res4.headers().get("Location");
|
||||
if (location0 == null) {
|
||||
fail(downUrl + " -> 直链获取失败2, 可能分享已失效");
|
||||
} else {
|
||||
setDateAndComplate(location0);
|
||||
}
|
||||
}).onFailure(handleFail(downUrl));
|
||||
return;
|
||||
try {
|
||||
String location = res3.headers().get("Location");
|
||||
if (location == null) {
|
||||
String text = asText(res3);
|
||||
if (isShareCancelledPage(text)) {
|
||||
fail(downUrl + " -> 分享已失效或文件已取消分享");
|
||||
return;
|
||||
}
|
||||
// 使用cookie 再请求一次
|
||||
headers.add("Referer", downUrl);
|
||||
String arg1 = extractAcwArg1(text);
|
||||
if (arg1 == null) {
|
||||
fail(downUrl + " -> 蓝奏云反爬 arg1 Cookie 解析失败,可能分享已失效");
|
||||
return;
|
||||
}
|
||||
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(downDomain);
|
||||
nettyCookie.setPath("/");
|
||||
nettyCookie.setSecure(false);
|
||||
nettyCookie.setHttpOnly(false);
|
||||
WebClientSession webClientSession2 = WebClientSession.create(clientNoRedirects);
|
||||
webClientSession2.cookieStore().put(nettyCookie);
|
||||
webClientSession2.getAbs(downUrl).putHeaders(headers).send()
|
||||
.onSuccess(res4 -> {
|
||||
try {
|
||||
String location0 = res4.headers().get("Location");
|
||||
if (location0 == null) {
|
||||
fail(downUrl + " -> 直链获取失败2, 可能分享已失效");
|
||||
} else {
|
||||
setDateAndComplete(location0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail("蓝奏云直链二次响应处理异常: {}", e.getMessage());
|
||||
}
|
||||
}).onFailure(handleFail(downUrl));
|
||||
return;
|
||||
}
|
||||
setDateAndComplete(location);
|
||||
} catch (Exception e) {
|
||||
fail("蓝奏云直链响应处理异常: {}", e.getMessage());
|
||||
}
|
||||
setDateAndComplate(location);
|
||||
})
|
||||
.onFailure(handleFail(downUrl));
|
||||
} catch (Exception e) {
|
||||
@@ -262,12 +424,11 @@ 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);
|
||||
Matcher matcher = URL_DATE_PATTERN.matcher(location0);
|
||||
if (matcher.find()) {
|
||||
String dateStr = matcher.group().replace("/", "-");
|
||||
String dateStr = parseLanzouFileTime(matcher.group());
|
||||
((FileInfo)shareLinkInfo.getOtherParam().get("fileInfo")).setCreateTime(dateStr);
|
||||
}
|
||||
promise.complete(location0);
|
||||
@@ -295,23 +456,47 @@ public class LzTool extends PanBase {
|
||||
String pwd = shareLinkInfo.getSharePassword();
|
||||
|
||||
webClientSession.getAbs(sUrl).send().onSuccess(res -> {
|
||||
String html = res.bodyAsString();
|
||||
// 检查是否需要 cookie 验证
|
||||
if (html.contains("var arg1='")) {
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
setCookie(html);
|
||||
// 重新请求
|
||||
webClientSession.getAbs(sUrl).send().onSuccess(res2 -> {
|
||||
handleFileListParse(res2.bodyAsString(), pwd, sUrl, promise);
|
||||
}).onFailure(err -> promise.fail(err));
|
||||
return;
|
||||
try {
|
||||
String html = asText(res);
|
||||
// 检查是否需要 cookie 验证
|
||||
if (hasAcwArg1(html)) {
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
if (!setCookie(html, sUrl)) {
|
||||
promise.tryFail(baseMsg() + "蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
|
||||
return;
|
||||
}
|
||||
// 重新请求
|
||||
webClientSession.getAbs(sUrl).send().onSuccess(res2 -> {
|
||||
try {
|
||||
handleFileListParse(asText(res2), pwd, sUrl, promise);
|
||||
} catch (Exception e) {
|
||||
promise.tryFail(e);
|
||||
}
|
||||
}).onFailure(promise::tryFail);
|
||||
return;
|
||||
}
|
||||
handleFileListParse(html, pwd, sUrl, promise);
|
||||
} catch (Exception e) {
|
||||
promise.tryFail(e);
|
||||
}
|
||||
handleFileListParse(html, pwd, sUrl, promise);
|
||||
}).onFailure(err -> promise.fail(err));
|
||||
}).onFailure(promise::tryFail);
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void handleFileListParse(String html, String pwd, String sUrl, Promise<List<FileInfo>> promise) {
|
||||
if (html == null || html.isBlank()) {
|
||||
promise.tryFail(baseMsg() + "蓝奏云页面响应为空");
|
||||
return;
|
||||
}
|
||||
if (isShareCancelledPage(html)) {
|
||||
promise.tryFail(baseMsg() + "分享已失效或文件已取消分享");
|
||||
return;
|
||||
}
|
||||
// 检测是否为文件分享链接 (不含 /s/、/b/ 路径段且不含 b 开头的路径段)
|
||||
if (!sUrl.matches(".*/(s|b)/[^/]+.*") && !sUrl.matches(".*/b[^/]+.*")) {
|
||||
promise.tryFail(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String jsText = getJsByPwd(pwd, html, "var urls =window.location.href");
|
||||
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "file");
|
||||
@@ -321,30 +506,45 @@ 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);
|
||||
// 重新请求
|
||||
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res3 -> {
|
||||
handleFileListResponse(asText(res3), promise);
|
||||
}).onFailure(err -> promise.fail(err));
|
||||
return;
|
||||
try {
|
||||
String resBody = asText(res2);
|
||||
// 再次检查是否需要 cookie 验证
|
||||
if (hasAcwArg1(resBody)) {
|
||||
if (!setCookie(resBody, url)) {
|
||||
promise.tryFail(baseMsg() + "蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
|
||||
return;
|
||||
}
|
||||
// 重新请求
|
||||
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res3 -> {
|
||||
try {
|
||||
handleFileListResponse(asText(res3), promise);
|
||||
} catch (Exception e) {
|
||||
promise.tryFail(e);
|
||||
}
|
||||
}).onFailure(promise::tryFail);
|
||||
return;
|
||||
}
|
||||
handleFileListResponse(resBody, promise);
|
||||
} catch (Exception e) {
|
||||
promise.tryFail(e);
|
||||
}
|
||||
handleFileListResponse(resBody, promise);
|
||||
}).onFailure(err -> promise.fail(err));
|
||||
} catch (ScriptException | NoSuchMethodException e) {
|
||||
promise.fail(e);
|
||||
}).onFailure(promise::tryFail);
|
||||
} catch (ScriptException | NoSuchMethodException | RuntimeException e) {
|
||||
promise.tryFail(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFileListResponse(String responseBody, Promise<List<FileInfo>> promise) {
|
||||
try {
|
||||
if (responseBody == null || responseBody.isBlank()) {
|
||||
promise.tryFail(baseMsg() + "蓝奏云文件列表响应为空");
|
||||
return;
|
||||
}
|
||||
JsonObject fileListJson = new JsonObject(responseBody);
|
||||
if (fileListJson.getInteger("zt") != 1) {
|
||||
promise.fail(baseMsg() + fileListJson.getString("info"));
|
||||
promise.tryFail(baseMsg() + fileListJson.getString("info"));
|
||||
return;
|
||||
}
|
||||
List<FileInfo> list = new ArrayList<>();
|
||||
@@ -367,14 +567,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"))
|
||||
.setCreateTime(parseLanzouFileTime(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);
|
||||
@@ -382,34 +588,80 @@ public class LzTool extends PanBase {
|
||||
});
|
||||
promise.complete(list);
|
||||
} catch (Exception e) {
|
||||
promise.fail(e);
|
||||
promise.tryFail(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String parseLanzouFileTime(String timeText) {
|
||||
if (timeText == null || timeText.isBlank()) {
|
||||
return timeText;
|
||||
}
|
||||
String normalized = timeText.trim().replaceAll("\\s+", " ");
|
||||
Matcher matcher = RELATIVE_TIME_PATTERN.matcher(normalized);
|
||||
if (matcher.matches()) {
|
||||
int amount = "几".equals(matcher.group(1)) ? 1 : Integer.parseInt(matcher.group(1));
|
||||
String unit = matcher.group(2);
|
||||
LocalDateTime time = LocalDateTime.now();
|
||||
if ("小时".equals(unit)) {
|
||||
time = time.minusHours(amount);
|
||||
} else {
|
||||
time = time.minusMinutes(amount);
|
||||
}
|
||||
return time.toLocalDate().toString();
|
||||
}
|
||||
matcher = DATE_PATTERN.matcher(normalized);
|
||||
if (matcher.matches()) {
|
||||
return LocalDate.of(
|
||||
Integer.parseInt(matcher.group(1)),
|
||||
Integer.parseInt(matcher.group(2)),
|
||||
Integer.parseInt(matcher.group(3))
|
||||
).toString();
|
||||
}
|
||||
matcher = MONTH_DAY_PATTERN.matcher(normalized);
|
||||
if (matcher.matches()) {
|
||||
return LocalDate.of(
|
||||
LocalDate.now().getYear(),
|
||||
Integer.parseInt(matcher.group(1)),
|
||||
Integer.parseInt(matcher.group(2))
|
||||
).toString();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@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();
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||
try {
|
||||
// 提取文件名
|
||||
String fileName = CommonUtils.extract(html, Pattern.compile("padding: 56px 0px 20px 0px;\">(.*?)<|filenajax\">(.*?)<"));
|
||||
String sizeStr = CommonUtils.extract(html, Pattern.compile(">文件大小:</span>(.*?)<br>|\"n_filesize\">大小:(.*?)</div>"));
|
||||
String createBy = CommonUtils.extract(html, Pattern.compile(">分享用户:</span><font>(.*?)</font>|获取<span>(.*?)</span>的文件|\"user-name\">(.*?)</"));
|
||||
String description = CommonUtils.extract(html, Pattern.compile("(?s)文件描述:</span><br>(.*?)</td>|class=\"n_box_des\">(.*?)</div>"));
|
||||
String fileName = CommonUtils.extract(html, FILE_NAME_PATTERN);
|
||||
String sizeStr = CommonUtils.extract(html, FILE_SIZE_PATTERN);
|
||||
String createBy = CommonUtils.extract(html, SHARE_USER_PATTERN);
|
||||
String description = CommonUtils.extract(html, DESCRIPTION_PATTERN);
|
||||
// String icon = CommonUtils.extract(html, Pattern.compile("class=\"n_file_icon\" src=\"(.*?)\""));
|
||||
String fileId = CommonUtils.extract(html, Pattern.compile("\\?f=(.*?)&|fid = (.*?);"));
|
||||
String createTime = CommonUtils.extract(html, Pattern.compile(">上传时间:</span>(.*?)<"));
|
||||
String fileId = CommonUtils.extract(html, FILE_ID_PATTERN);
|
||||
String createTime = CommonUtils.extract(html, CREATE_TIME_PATTERN);
|
||||
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);
|
||||
.setCreateTime(parseLanzouFileTime(createTime));
|
||||
if (sizeStr != null && !sizeStr.isBlank()) {
|
||||
long bytes = FileSizeConverter.convertToBytes(sizeStr);
|
||||
fileInfo.setSize(bytes).setSizeStr(FileSizeConverter.convertToReadableSize(bytes));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("文件信息解析异常", e);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public class MkgsTool extends PanBase {
|
||||
|
||||
public static final String API_URL = "https://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash={hash}";
|
||||
|
||||
private static final Pattern HASH_PATTERN = Pattern.compile("\"hash\"\\s*:\\s*\"([A-F0-9]+)\"");
|
||||
|
||||
private static final MultiMap headers = MultiMap.caseInsensitiveMultiMap();
|
||||
static {
|
||||
// 设置 User-Agent
|
||||
@@ -78,18 +80,15 @@ public class MkgsTool extends PanBase {
|
||||
protected void downUrl(String locationURL) {
|
||||
client.getAbs(locationURL).putHeaders(headers).send().onSuccess(res2->{
|
||||
String body = res2.bodyAsString();
|
||||
// 正则表达式匹配 hash 字段
|
||||
String regex = "\"hash\"\s*:\s*\"([A-F0-9]+)\"";
|
||||
Pattern pattern = Pattern.compile(regex);
|
||||
Matcher matcher = pattern.matcher(body);
|
||||
Matcher matcher = HASH_PATTERN.matcher(body);
|
||||
|
||||
// 查找并输出 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 {
|
||||
|
||||
@@ -19,6 +19,8 @@ public class MkwTool extends PanBase {
|
||||
|
||||
public static final String API_URL = "https://www.kuwo.cn/api/v1/www/music/playUrl?mid={mid}&type=music&httpsStatus=1&reqId=&plat=web_www&from=";
|
||||
|
||||
private static final Pattern COOKIE_PATTERN = Pattern.compile("([A-Za-z0-9_]+)=([A-Za-z0-9]+)");
|
||||
|
||||
|
||||
public MkwTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
@@ -29,39 +31,41 @@ public class MkwTool extends PanBase {
|
||||
clientSession.getAbs(shareUrl).send().onSuccess(result -> {
|
||||
String cookie = result.headers().get("set-cookie");
|
||||
|
||||
if (!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));
|
||||
|
||||
var key = matcher.group(1);
|
||||
var token = matcher.group(2);
|
||||
String sign = JsExecUtils.getKwSign(token, key);
|
||||
System.out.println(sign);
|
||||
clientSession.getAbs(UriTemplate.of(API_URL)).setTemplateParam("mid", shareLinkInfo.getShareKey())
|
||||
.putHeader("Secret", sign).send().onSuccess(res -> {
|
||||
JsonObject json = asJson(res);
|
||||
log.debug(json.encodePrettily());
|
||||
try {
|
||||
if (json.getInteger("code") == 200) {
|
||||
complete(json.getJsonObject("data").getString("url"));
|
||||
} else {
|
||||
fail("链接已失效/需要VIP");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
fail("解析失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (cookie == null || cookie.isEmpty()) {
|
||||
fail("未获取到 cookie,无法继续解析");
|
||||
return;
|
||||
}
|
||||
|
||||
});
|
||||
Matcher matcher = COOKIE_PATTERN.matcher(cookie);
|
||||
if (!matcher.find()) {
|
||||
fail("cookie 格式不匹配");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
log.debug("sign: {}", sign);
|
||||
clientSession.getAbs(UriTemplate.of(API_URL)).setTemplateParam("mid", shareLinkInfo.getShareKey())
|
||||
.putHeader("Secret", sign).send().onSuccess(res -> {
|
||||
JsonObject json = asJson(res);
|
||||
log.debug(json.encodePrettily());
|
||||
try {
|
||||
if (json.getInteger("code") == 200) {
|
||||
complete(json.getJsonObject("data").getString("url"));
|
||||
} else {
|
||||
fail("链接已失效/需要VIP");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("解析失败", e);
|
||||
fail("解析失败");
|
||||
}
|
||||
}).onFailure(handleFail("获取下载链接失败"));
|
||||
}).onFailure(handleFail("请求分享页面失败"));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import java.util.regex.Pattern;
|
||||
* 下载链接需要Referer: https://link.yunpan.com/
|
||||
*/
|
||||
public class P360Tool extends PanBase {
|
||||
|
||||
private static final Pattern NID_PATTERN = Pattern.compile("\"nid\": \"([^\"]+)\"");
|
||||
|
||||
public P360Tool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
@@ -43,9 +46,7 @@ public class P360Tool extends PanBase {
|
||||
clientSession.getAbs(url)
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
// find "nid": "17402043311959599"
|
||||
Pattern compile = Pattern.compile("\"nid\": \"([^\"]+)\"");
|
||||
Matcher matcher = compile.matcher(res.bodyAsString());
|
||||
Matcher matcher = NID_PATTERN.matcher(res.bodyAsString());
|
||||
AtomicReference<String> nid = new AtomicReference<>();
|
||||
if (matcher.find()) {
|
||||
nid.set(matcher.group(1));
|
||||
@@ -69,7 +70,7 @@ public class P360Tool extends PanBase {
|
||||
clientSession.getAbs(url)
|
||||
.send()
|
||||
.onSuccess(res3 -> {
|
||||
Matcher matcher1 = compile.matcher(res3.bodyAsString());
|
||||
Matcher matcher1 = NID_PATTERN.matcher(res3.bodyAsString());
|
||||
if (matcher1.find()) {
|
||||
nid.set(matcher1.group(1));
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,25 @@ import java.util.regex.Pattern;
|
||||
*/
|
||||
public class PcxTool extends PanBase {
|
||||
|
||||
private static final Pattern TITLE_PATTERN =
|
||||
Pattern.compile("<title>([^<]+)</title>");
|
||||
private static final Pattern FILENAME_INPUT_PATTERN =
|
||||
Pattern.compile("<input id=\"filename\" type=\"hidden\" value=\"([^\"]+)\"");
|
||||
private static final Pattern FILESIZE_PATTERN =
|
||||
Pattern.compile("['\"]filesize['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
private static final Pattern SUFFIX_PATTERN =
|
||||
Pattern.compile("['\"]suffix['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
private static final Pattern OBJECT_ID_PATTERN =
|
||||
Pattern.compile("['\"]objectId['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
private static final Pattern CREATOR_PATTERN =
|
||||
Pattern.compile("['\"]creator['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
private static final Pattern UPLOAD_DATE_PATTERN =
|
||||
Pattern.compile("['\"]uploadDate['\"]\\s*:\\s*(\\d+)");
|
||||
private static final Pattern THUMBNAIL_PATTERN =
|
||||
Pattern.compile("['\"]thumbnail['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
private static final Pattern DOWNLOAD_PATTERN =
|
||||
Pattern.compile("['\"]download['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
|
||||
public PcxTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
@@ -44,9 +63,7 @@ public class PcxTool extends PanBase {
|
||||
* 从HTML中提取download链接
|
||||
*/
|
||||
private String extractDownloadUrl(String html) {
|
||||
// 匹配 'download': 'https://xxx' 或 "download": "https://xxx"
|
||||
Pattern pattern = Pattern.compile("['\"]download['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
Matcher matcher = pattern.matcher(html);
|
||||
Matcher matcher = DOWNLOAD_PATTERN.matcher(html);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
@@ -61,13 +78,13 @@ public class PcxTool extends PanBase {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
|
||||
// 提取文件名:从<title>标签或文件名input
|
||||
String fileName = extractByRegex(html, "<title>([^<]+)</title>");
|
||||
String fileName = extractByRegex(html, TITLE_PATTERN);
|
||||
if (fileName == null) {
|
||||
fileName = extractByRegex(html, "<input id=\"filename\" type=\"hidden\" value=\"([^\"]+)\"");
|
||||
fileName = extractByRegex(html, FILENAME_INPUT_PATTERN);
|
||||
}
|
||||
|
||||
// 提取文件大小:'filesize': 'xxx' 或 "filesize": "xxx"
|
||||
String fileSizeStr = extractByRegex(html, "['\"]filesize['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
String fileSizeStr = extractByRegex(html, FILESIZE_PATTERN);
|
||||
Long fileSize = null;
|
||||
if (fileSizeStr != null) {
|
||||
try {
|
||||
@@ -76,19 +93,19 @@ public class PcxTool extends PanBase {
|
||||
}
|
||||
|
||||
// 提取文件类型/后缀:'suffix': 'xxx' 或 "suffix": "xxx"
|
||||
String suffix = extractByRegex(html, "['\"]suffix['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
String suffix = extractByRegex(html, SUFFIX_PATTERN);
|
||||
|
||||
// 提取objectId(文件ID):'objectId': 'xxx' 或 "objectId": "xxx"
|
||||
String objectId = extractByRegex(html, "['\"]objectId['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
String objectId = extractByRegex(html, OBJECT_ID_PATTERN);
|
||||
|
||||
// 提取创建者:'creator': 'xxx' 或 "creator": "xxx"
|
||||
String creator = extractByRegex(html, "['\"]creator['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
String creator = extractByRegex(html, CREATOR_PATTERN);
|
||||
|
||||
// 提取上传时间:'uploadDate': timestamp
|
||||
String uploadDate = extractByRegex(html, "['\"]uploadDate['\"]\\s*:\\s*(\\d+)");
|
||||
String uploadDate = extractByRegex(html, UPLOAD_DATE_PATTERN);
|
||||
|
||||
// 提取缩略图:'thumbnail': 'xxx' 或 "thumbnail": "xxx"
|
||||
String thumbnail = extractByRegex(html, "['\"]thumbnail['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
||||
String thumbnail = extractByRegex(html, THUMBNAIL_PATTERN);
|
||||
|
||||
// 设置文件信息
|
||||
if (fileName != null) {
|
||||
@@ -141,8 +158,7 @@ public class PcxTool extends PanBase {
|
||||
/**
|
||||
* 使用正则表达式提取内容
|
||||
*/
|
||||
private String extractByRegex(String text, String regex) {
|
||||
Pattern pattern = Pattern.compile(regex);
|
||||
private String extractByRegex(String text, Pattern pattern) {
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
|
||||
@@ -28,6 +28,7 @@ public class PdbTool extends PanBase implements IPanTool {
|
||||
private static final String API_URL =
|
||||
"https://www.dropbox.com/sharing/fetch_user_content_link";
|
||||
static final String COOKIE_KEY = "__Host-js_csrf=";
|
||||
private static final Pattern CSRF_TOKEN_PATTERN = Pattern.compile(COOKIE_KEY + "([\\w-]+);");
|
||||
|
||||
public PdbTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
@@ -47,7 +48,7 @@ public class PdbTool extends PanBase implements IPanTool {
|
||||
fail("cookie未找到");
|
||||
return;
|
||||
}
|
||||
Matcher matcher = Pattern.compile(COOKIE_KEY + "([\\w-]+);").matcher(collect.get(0));
|
||||
Matcher matcher = CSRF_TOKEN_PATTERN.matcher(collect.get(0));
|
||||
String _t;
|
||||
if (matcher.find()) {
|
||||
_t = matcher.group(1);
|
||||
@@ -85,7 +86,7 @@ public class PdbTool extends PanBase implements IPanTool {
|
||||
})
|
||||
.onFailure(handleFail());
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
log.error("URL编码异常", e);
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
@@ -23,6 +23,16 @@ import java.util.regex.Pattern;
|
||||
*/
|
||||
public class PodTool extends PanBase {
|
||||
|
||||
private static final int MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
|
||||
private static final java.time.Duration REQUEST_TIMEOUT = java.time.Duration.ofSeconds(30);
|
||||
|
||||
// 静态共享的 JDK HttpClient 实例,避免每次调用创建新实例
|
||||
private static final HttpClient SHARED_HTTP_CLIENT = HttpClient.newBuilder()
|
||||
.connectTimeout(java.time.Duration.ofSeconds(10))
|
||||
.build();
|
||||
private static volatile WorkerExecutor SHARED_WORKER_EXECUTOR;
|
||||
private static volatile boolean workerExecutorShutdown = false;
|
||||
|
||||
/*
|
||||
* https://1drv.ms/w/s!Alg0feQmCv2rnRFd60DQOmMa-Oh_?e=buaRtp --302->
|
||||
* https://api.onedrive.com/v1.0/drives/abfd0a26e47d3458/items/ABFD0A26E47D3458!3729?authkey=!AF3rQNA6Yxr46H8
|
||||
@@ -45,6 +55,13 @@ public class PodTool extends PanBase {
|
||||
private static final Pattern redirectUrlRegex =
|
||||
Pattern.compile("resid=(?<cid1>[^!]+)!(?<cid2>[^&]+).+&redeem=(?<redeem>.+).*");
|
||||
|
||||
private static final Pattern DOWNLOAD_URL_IN_RESPONSE_PATTERN =
|
||||
Pattern.compile("\"downloadUrl\":\"(?<url>https?://[^\\s\"]+)");
|
||||
private static final Pattern ACTION_URL_PATTERN =
|
||||
Pattern.compile("'action'.+(?<url>https://.+)'\\)");
|
||||
private static final Pattern TOKEN_PATTERN =
|
||||
Pattern.compile("inputElem\\.value\\s*=\\s*'([^']+)'");
|
||||
|
||||
public PodTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
@@ -97,9 +114,10 @@ public class PodTool extends PanBase {
|
||||
|
||||
sendHttpRequest(url, token).onSuccess(body -> {
|
||||
Matcher matcher1 =
|
||||
Pattern.compile("\"downloadUrl\":\"(?<url>https?://[^\s\"]+)").matcher(body);
|
||||
DOWNLOAD_URL_IN_RESPONSE_PATTERN.matcher(body);
|
||||
if (matcher1.find()) {
|
||||
complete(matcher1.group("url"));
|
||||
// 响应体是 JSON 文本,URL 中的 '&' 被转义为 \u0026,需要反转义
|
||||
complete(unescapeJsonUnicode(matcher1.group("url")));
|
||||
} else {
|
||||
fail();
|
||||
}
|
||||
@@ -120,30 +138,51 @@ public class PodTool extends PanBase {
|
||||
}
|
||||
|
||||
private String matcherUrl(String html) {
|
||||
|
||||
// 正则表达式来匹配 URL
|
||||
String urlRegex = "'action'.+(?<url>https://.+)'\\)";
|
||||
Pattern urlPattern = Pattern.compile(urlRegex);
|
||||
Matcher urlMatcher = urlPattern.matcher(html);
|
||||
Matcher urlMatcher = ACTION_URL_PATTERN.matcher(html);
|
||||
|
||||
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
|
||||
String tokenRegex = "inputElem\\.value\\s*=\\s*'([^']+)'";
|
||||
Pattern tokenPattern = Pattern.compile(tokenRegex);
|
||||
Matcher tokenMatcher = tokenPattern.matcher(html);
|
||||
Matcher tokenMatcher = TOKEN_PATTERN.matcher(html);
|
||||
|
||||
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匹配失败");
|
||||
@@ -151,11 +190,8 @@ public class PodTool extends PanBase {
|
||||
|
||||
public Future<String> sendHttpRequest2(String token, String redeem) {
|
||||
Promise<String> promise = Promise.promise();
|
||||
// 构造 HttpClient
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
|
||||
// 构造请求的 URI 和头部信息
|
||||
// https://onedrive.live.com/redir?cid=abfd0a26e47d3458&resid=ABFD0A26E47D3458!4465&ithint=file%2cxlsx&e=Ao2uSU&migratedtospo=true&redeem=aHR0cHM6Ly8xZHJ2Lm1zL3gvYy9hYmZkMGEyNmU0N2QzNDU4L0VWZzBmZVFtQ3YwZ2dLdHhFUUFBQUFBQlRQRWVDMTZfZk1EYk5FTjhEdTRta1E_ZT1BbzJ1U1U
|
||||
String url = ("https://my.microsoftpersonalcontent.com/_api/v2.0/shares/u!%s/driveItem?$select=content" +
|
||||
".downloadUrl").formatted(redeem);
|
||||
String authorizationHeader = "Badger " + token;
|
||||
@@ -163,15 +199,20 @@ public class PodTool extends PanBase {
|
||||
// 构建请求
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("Authorization", authorizationHeader)
|
||||
.build();
|
||||
|
||||
// 发送请求并处理响应
|
||||
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
// 发送请求并处理响应(使用共享的 HttpClient)
|
||||
SHARED_HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray())
|
||||
.thenApply(response -> {
|
||||
System.out.println("Response Status Code: " + response.statusCode());
|
||||
System.out.println("Response Body: " + response.body());
|
||||
promise.complete(response.body());
|
||||
log.debug("Response Status Code: {}", response.statusCode());
|
||||
promise.complete(toLimitedString(response.body()));
|
||||
return null;
|
||||
})
|
||||
.exceptionally(e -> {
|
||||
log.error("sendHttpRequest2 请求失败: {}", e.getMessage());
|
||||
promise.fail(e);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -179,18 +220,13 @@ public class PodTool extends PanBase {
|
||||
}
|
||||
|
||||
public Future<String> sendHttpRequest(String url, String token) {
|
||||
// 创建一个 WorkerExecutor 用于异步执行阻塞的 HTTP 请求
|
||||
WorkerExecutor executor = WebClientVertxInit.get().createSharedWorkerExecutor("http-client-worker");
|
||||
|
||||
Promise<String> promise = Promise.promise();
|
||||
executor.executeBlocking(() -> {
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpRequest request = null;
|
||||
|
||||
getWorkerExecutor().executeBlocking(() -> {
|
||||
try {
|
||||
// 构造请求
|
||||
request = HttpRequest.newBuilder()
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(new URI(url))
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9," +
|
||||
"image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;" +
|
||||
"v=b3;q=0.7")
|
||||
@@ -215,17 +251,49 @@ public class PodTool extends PanBase {
|
||||
.POST(HttpRequest.BodyPublishers.ofString("badger_token=" + token))
|
||||
.build();
|
||||
|
||||
// 发起请求并获取响应
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
// 发起请求并获取响应(使用共享的 HttpClient)
|
||||
HttpResponse<byte[]> response = SHARED_HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
// 返回响应体
|
||||
promise.complete(response.body());
|
||||
promise.complete(toLimitedString(response.body()));
|
||||
return null;
|
||||
} catch (URISyntaxException | IOException | InterruptedException e) {
|
||||
if (e instanceof InterruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}).onFailure(promise::fail);
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private static String toLimitedString(byte[] body) {
|
||||
if (body.length > MAX_RESPONSE_BODY_BYTES) {
|
||||
throw new IllegalArgumentException("OneDrive响应体过大: " + body.length + " bytes");
|
||||
}
|
||||
return new String(body, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static WorkerExecutor getWorkerExecutor() {
|
||||
synchronized (PodTool.class) {
|
||||
if (workerExecutorShutdown) {
|
||||
throw new IllegalStateException("OneDrive WorkerExecutor 已关闭");
|
||||
}
|
||||
if (SHARED_WORKER_EXECUTOR == null) {
|
||||
SHARED_WORKER_EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("http-client-worker", 8);
|
||||
}
|
||||
return SHARED_WORKER_EXECUTOR;
|
||||
}
|
||||
}
|
||||
|
||||
public static void shutdownWorkerExecutor() {
|
||||
synchronized (PodTool.class) {
|
||||
workerExecutorShutdown = true;
|
||||
if (SHARED_WORKER_EXECUTOR != null) {
|
||||
SHARED_WORKER_EXECUTOR.close();
|
||||
SHARED_WORKER_EXECUTOR = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.WebClientVertxInit;
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
@@ -11,7 +10,6 @@ import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.core.json.pointer.JsonPointer;
|
||||
import io.vertx.ext.web.client.WebClient;
|
||||
import io.vertx.uritemplate.UriTemplate;
|
||||
|
||||
import java.util.List;
|
||||
@@ -53,9 +51,8 @@ public class PvyyTool extends PanBase {
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
// 请求downcode
|
||||
WebClient.create(WebClientVertxInit.get())
|
||||
.getAbs(api + shareLinkInfo.getShareKey())
|
||||
// 请求downcode - 使用父类的共享 WebClient 而非创建新实例
|
||||
client.getAbs(api + shareLinkInfo.getShareKey())
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
if (res.statusCode() == 200) {
|
||||
|
||||
@@ -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,262 @@ 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"}
|
||||
""");
|
||||
|
||||
private static final Pattern FILESET_ID_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})");
|
||||
|
||||
private static final Pattern TITLE_PATTERN = Pattern.compile("<title>(.*?)</title>");
|
||||
|
||||
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 导致并发污染
|
||||
MultiMap headers = MultiMap.caseInsensitiveMultiMap().addAll(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字符)
|
||||
Matcher matcher = FILESET_ID_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 +324,22 @@ 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);
|
||||
Matcher matcher = TITLE_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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user