mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-08-30 05:22:01 +00:00
Compare commits
619
Commits
cb6d4811d2
..
v0.3.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fde421169 | ||
|
|
3494271150 | ||
|
|
675d930f86 | ||
|
|
5e20ed5050 | ||
|
|
91f4fcaa76 | ||
|
|
0754fe20f1 | ||
|
|
034fd20d3b | ||
|
|
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 | ||
|
|
c19601b209 | ||
|
|
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 |
+219
-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
|
name: Java CI(Maven 构建 + Docker 镜像 + 原生环境打包)
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven
|
|
||||||
|
|
||||||
# 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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*' # 只有推送tag时才会触发构建
|
- '*'
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- '*' # 排除所有分支的提交
|
- '*'
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'bin/**'
|
- 'bin/**'
|
||||||
- '.github/**'
|
- '.github/**'
|
||||||
@@ -32,70 +24,260 @@ on:
|
|||||||
- "main"
|
- "main"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ================================================================
|
||||||
|
# 阶段一:构建前端 + Maven 打包(只执行一次,产物共享)
|
||||||
|
# ================================================================
|
||||||
build:
|
build:
|
||||||
|
name: 编译构建
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
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:
|
with:
|
||||||
node-version: '18'
|
node-version: '18'
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: 设置 JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
cache: maven
|
cache: maven
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: 构建前端
|
||||||
run: cd web-front && yarn install && yarn run build
|
run: cd web-front && yarn install && yarn run build
|
||||||
|
|
||||||
- name: Build with Maven
|
- name: Maven 编译打包
|
||||||
run: mvn -B package -DskipTests --file pom.xml
|
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: 更新依赖图谱
|
||||||
- name: Update dependency graph
|
|
||||||
uses: advanced-security/maven-dependency-submission-action@v3
|
uses: advanced-security/maven-dependency-submission-action@v3
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
ignore-maven-wrapper: true
|
ignore-maven-wrapper: true
|
||||||
|
|
||||||
# - uses: release-drafter/release-drafter@v5
|
- name: 分享应用打包目录(供原生包和 Docker 复用)
|
||||||
# env:
|
if: github.event_name != 'pull_request'
|
||||||
# GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Upload Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
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
|
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
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Extract git tag
|
- name: 生成 Docker 标签
|
||||||
id: tag
|
id: docker_tag
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
GIT_TAG=$(git tag --points-at HEAD | head -n 1)
|
tag="$(printf '%s' "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_.-]+/-/g; s/^-+//; s/-+$//')"
|
||||||
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
|
if [ -z "$tag" ]; then
|
||||||
|
tag="snapshot"
|
||||||
|
fi
|
||||||
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: 构建并推送 Docker 镜像
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/qaiu/netdisk-fast-download:${{ steps.tag.outputs.tag }}
|
ghcr.io/${{ github.repository }}:${{ steps.docker_tag.outputs.tag }}
|
||||||
ghcr.io/qaiu/netdisk-fast-download:latest
|
ghcr.io/${{ github.repository }}:latest
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 阶段二-B:原生环境打包 Linux + Windows(并行)
|
||||||
|
# ================================================================
|
||||||
|
native-package:
|
||||||
|
name: 原生环境打包 → ${{ matrix.artifact-name }}
|
||||||
|
needs: build
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
artifact-name: netdisk-fast-download-linux-amd64
|
||||||
|
- os: windows-latest
|
||||||
|
artifact-name: netdisk-fast-download-windows-amd64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 设置 JDK 17
|
||||||
|
uses: actions/setup-java@v3
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: 下载 Maven 构建产物
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-package
|
||||||
|
path: web-service/target/package
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# jdeps 分析 → 确定所需 JDK 模块
|
||||||
|
# ============================================================
|
||||||
|
- name: 分析所需 JDK 模块(jdeps)
|
||||||
|
run: |
|
||||||
|
MAIN_JAR="web-service/target/package/netdisk-fast-download.jar"
|
||||||
|
LIB_DIR="web-service/target/package/lib"
|
||||||
|
|
||||||
|
CP=""
|
||||||
|
for jar in "$LIB_DIR"/*.jar; do
|
||||||
|
CP="$CP${CP:+:}$jar"
|
||||||
|
done
|
||||||
|
|
||||||
|
RAW_MODULES=$(jdeps --print-module-deps --ignore-missing-deps --multi-release 17 \
|
||||||
|
--class-path "$CP" "$MAIN_JAR" 2>/dev/null | head -n 1 | tr -d '\r\n' || true)
|
||||||
|
|
||||||
|
if [ -z "$RAW_MODULES" ] || [[ "$RAW_MODULES" == *"Missing"* ]] || [[ "$RAW_MODULES" == *"Error"* ]]; then
|
||||||
|
# ⚠️ 回退列表:若项目新增了需要 java.* / jdk.* 模块的依赖,需同步更新此处
|
||||||
|
RAW_MODULES="java.base,java.logging,java.sql,java.naming,java.management,java.xml,jdk.unsupported,java.net.http,java.instrument,java.security.jgss,java.security.sasl,java.desktop,jdk.crypto.ec"
|
||||||
|
echo "jdeps 分析失败,使用回退模块列表"
|
||||||
|
else
|
||||||
|
# 补上 jdeps 无法检测的反射/SPI依赖
|
||||||
|
RAW_MODULES="$RAW_MODULES,java.desktop,jdk.crypto.ec"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "detected modules: $RAW_MODULES"
|
||||||
|
printf 'JDK_MODULES=%s\n' "$RAW_MODULES" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# jlink 生成精简 JRE
|
||||||
|
# ============================================================
|
||||||
|
- name: 生成精简 JRE(jlink)
|
||||||
|
run: |
|
||||||
|
jlink \
|
||||||
|
--module-path "$JAVA_HOME/jmods" \
|
||||||
|
--add-modules "$JDK_MODULES" \
|
||||||
|
--output "native-package/netdisk-fast-download/jre" \
|
||||||
|
--strip-debug \
|
||||||
|
--compress=2 \
|
||||||
|
--no-header-files \
|
||||||
|
--no-man-pages
|
||||||
|
|
||||||
|
echo "JRE size:"
|
||||||
|
du -sh native-package/netdisk-fast-download/jre || true
|
||||||
|
|
||||||
|
# Windows: 确保 MSVC 运行时 DLL 到位
|
||||||
|
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||||
|
JRE_BIN="native-package/netdisk-fast-download/jre/bin"
|
||||||
|
for dll in vcruntime140.dll msvcp140.dll vcruntime140_1.dll; do
|
||||||
|
if [ ! -f "$JRE_BIN/$dll" ] && [ -f "$JAVA_HOME/bin/$dll" ]; then
|
||||||
|
echo "jlink 未包含 $dll,从 JDK 补拷"
|
||||||
|
cp "$JAVA_HOME/bin/$dll" "$JRE_BIN/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "=== JRE bin 目录 DLL 清单 ==="
|
||||||
|
ls -la "$JRE_BIN"/*.dll 2>/dev/null || echo "(无 .dll 文件)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 组装包目录
|
||||||
|
# ============================================================
|
||||||
|
- name: 组装包目录
|
||||||
|
run: |
|
||||||
|
PKG="native-package/netdisk-fast-download"
|
||||||
|
SRC="web-service/target/package"
|
||||||
|
|
||||||
|
cp "$SRC/netdisk-fast-download.jar" "$PKG/"
|
||||||
|
cp -r "$SRC/lib" "$PKG/"
|
||||||
|
cp -r "$SRC/resources" "$PKG/"
|
||||||
|
cp -r "$SRC/webroot" "$PKG/"
|
||||||
|
|
||||||
|
mkdir -p "$PKG/db"
|
||||||
|
mkdir -p "$PKG/logs"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 生成启动脚本
|
||||||
|
# ============================================================
|
||||||
|
- name: 生成启动脚本(Linux)
|
||||||
|
run: |
|
||||||
|
PKG="native-package/netdisk-fast-download"
|
||||||
|
echo '#!/bin/bash' > "$PKG/run.sh"
|
||||||
|
echo 'DIR="$(cd "$(dirname "$0")" && pwd)"' >> "$PKG/run.sh"
|
||||||
|
echo 'cd "$DIR" || exit 1' >> "$PKG/run.sh"
|
||||||
|
echo 'exec "$DIR/jre/bin/java" -Xmx512M -Dfile.encoding=utf-8 -jar "$DIR/netdisk-fast-download.jar" "$@"' >> "$PKG/run.sh"
|
||||||
|
chmod +x "$PKG/run.sh"
|
||||||
|
|
||||||
|
- name: 生成启动脚本(Windows)
|
||||||
|
run: |
|
||||||
|
PKG="native-package/netdisk-fast-download"
|
||||||
|
echo '@echo off' > "$PKG/run.bat"
|
||||||
|
echo 'chcp 65001 > nul' >> "$PKG/run.bat"
|
||||||
|
echo 'pushd %~dp0' >> "$PKG/run.bat"
|
||||||
|
echo '"%~dp0jre\bin\java.exe" -Xmx512M -Dfile.encoding=utf-8 -jar "%~dp0netdisk-fast-download.jar" %*' >> "$PKG/run.bat"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 打包为 zip
|
||||||
|
# ============================================================
|
||||||
|
- name: 打包 ZIP(Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
cd native-package
|
||||||
|
zip -r "../${{ matrix.artifact-name }}.zip" netdisk-fast-download/
|
||||||
|
|
||||||
|
- name: 打包 ZIP(Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
Compress-Archive -Path native-package/netdisk-fast-download -DestinationPath "${{ matrix.artifact-name }}.zip"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 上传产物
|
||||||
|
# ============================================================
|
||||||
|
- name: 上传原生安装包
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.artifact-name }}
|
||||||
|
path: ${{ matrix.artifact-name }}.zip
|
||||||
|
|
||||||
|
- name: 上传到 Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: ${{ matrix.artifact-name }}.zip
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
generate_release_notes: true
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ target/
|
|||||||
sdkTest.log
|
sdkTest.log
|
||||||
app.yml
|
app.yml
|
||||||
app-local.yml
|
app-local.yml
|
||||||
|
secret.yml
|
||||||
|
|
||||||
|
|
||||||
#some local files
|
#some local files
|
||||||
@@ -41,7 +42,9 @@ gradlew.bat
|
|||||||
unused.txt
|
unused.txt
|
||||||
/web-service/src/main/generated/
|
/web-service/src/main/generated/
|
||||||
/db
|
/db
|
||||||
|
/netdisk-fast-download/
|
||||||
/webroot/nfd-front/
|
/webroot/nfd-front/
|
||||||
|
/netdisk-fast-download/webroot/nfd-front/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
# Maven generated files
|
# Maven generated files
|
||||||
@@ -89,3 +92,4 @@ yarn-error.log*
|
|||||||
**/${project.build.directory}/
|
**/${project.build.directory}/
|
||||||
**/${project.basedir}/target/
|
**/${project.basedir}/target/
|
||||||
**/${basedir}/target/
|
**/${basedir}/target/
|
||||||
|
.spec-workflow/
|
||||||
|
|||||||
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"java.compile.nullAnalysis.mode": "automatic",
|
"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 && \
|
RUN unzip netdisk-fast-download-bin.zip && \
|
||||||
mv netdisk-fast-download/* ./ && \
|
mv netdisk-fast-download/* ./ && \
|
||||||
rm netdisk-fast-download-bin.zip && \
|
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">
|
<p align="center">
|
||||||
<img src="https://github.com/user-attachments/assets/87401aae-b0b6-4ffb-bbeb-44756404d26f" alt="项目预览图" />
|
<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>
|
||||||
</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://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></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://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/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>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
# netdisk-fast-download 网盘分享链接云解析服务
|
|
||||||
QQ交流群:1017480890
|
|
||||||
|
|
||||||
netdisk-fast-download网盘直链云解析(nfd云解析)能把网盘分享下载链接转化为直链,支持多款云盘,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
## 介绍
|
||||||
|
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||||
|
|
||||||
|
[官方文档](https://nfd-parser.github.io/)
|
||||||
|
[API接入](https://nfdparser.apifox.cn/)
|
||||||
|
[公益解析,lz站](https://lz.qaiu.top)
|
||||||
|
[公益解析,lz0站](https://lz0.qaiu.top)
|
||||||
|
[专业版](https://189.qaiu.top)
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
命令行下载分享文件:
|
命令行下载分享文件:
|
||||||
```shell
|
```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:
|
或者使用wget:
|
||||||
```shell
|
```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)
|
**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)
|
**注意⚠️小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
||||||
**0.1.8及以上版本json接口格式有调整 参考json返回数据格式示例**
|
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
|
||||||
**小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
**注意⚠️请不要过度依赖 lz.qaiu.top,建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制,遇到解析失败的分享链接不要着急提issues,请先检查分享是否有效。**
|
||||||
**注意: 请不要过度依赖lz.qaiu.top预览地址服务,建议本地搭建或者云服务器自行搭建。解析次数过多IP会被部分网盘厂商限制,不推荐做公共解析。**
|
|
||||||
|
|
||||||
## 网盘支持情况:
|
## 网盘支持情况:
|
||||||
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
|
||||||
> 20230824 123云盘解析大文件(>100MB)失效,需要登录
|
> 20230824 123云盘解析大文件(>100MB)失效,需要登录
|
||||||
> 20230722 UC网盘解析失效,需要登录
|
> 20230722 UC网盘解析失效,需要登录
|
||||||
|
|
||||||
@@ -61,13 +66,11 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/
|
|||||||
|
|
||||||
- [蓝奏云-lz](https://pc.woozooo.com/)
|
- [蓝奏云-lz](https://pc.woozooo.com/)
|
||||||
- [蓝奏云优享-iz](https://www.ilanzou.com/)
|
- [蓝奏云优享-iz](https://www.ilanzou.com/)
|
||||||
- [奶牛快传-cow](https://cowtransfer.com/)
|
|
||||||
- [移动云云空间-ec](https://www.ecpan.cn/web)
|
- [移动云云空间-ec](https://www.ecpan.cn/web)
|
||||||
- [小飞机网盘-fj](https://www.feijipan.com/)
|
- [小飞机网盘-fj](https://www.feijipan.com/)
|
||||||
- [亿方云-fc](https://www.fangcloud.com/)
|
- [亿方云-fc](https://www.fangcloud.com/)
|
||||||
- [123云盘-ye](https://www.123pan.com/)
|
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用,需登录认证,公共/云端服务器风控严格建议自行部署(Windows 可直接用 run.bat 一键运行)
|
||||||
- ~[115网盘(失效)-p115](https://115.com/)~
|
- ~[115网盘(失效)-p115](https://115.com/)~
|
||||||
- ~[118网盘(已停服)-p118](https://www.118pan.com/)~
|
|
||||||
- [文叔叔-ws](https://www.wenshushu.cn/)
|
- [文叔叔-ws](https://www.wenshushu.cn/)
|
||||||
- [联想乐云-le](https://lecloud.lenovo.com/)
|
- [联想乐云-le](https://lecloud.lenovo.com/)
|
||||||
- [QQ邮箱云盘-qqw](https://mail.qq.com/)
|
- [QQ邮箱云盘-qqw](https://mail.qq.com/)
|
||||||
@@ -80,15 +83,16 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/
|
|||||||
- [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
|
- [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
|
||||||
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
|
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
|
||||||
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
|
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
|
||||||
|
- [飞书云盘-fs](https://www.feishu.cn/)
|
||||||
- [WPS云文档-pwps](https://www.kdocs.cn/)
|
- [WPS云文档-pwps](https://www.kdocs.cn/)
|
||||||
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
|
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
|
||||||
- [咪咕音乐-migu](https://music.migu.cn/)
|
- [咪咕音乐-migu](https://music.migu.cn/)
|
||||||
- [一刻相册-baidu_photo](https://photo.baidu.com/)
|
|
||||||
- Google云盘-pgd
|
- Google云盘-pgd
|
||||||
- Onedrive-pod
|
- Onedrive-pod
|
||||||
- Dropbox-pdp
|
- Dropbox-pdp
|
||||||
- iCloud-pic
|
- iCloud-pic
|
||||||
### 专属版提供
|
### 专业版提供
|
||||||
|
- 迅雷网盘-xl
|
||||||
- [夸克云盘-qk](https://pan.quark.cn/)
|
- [夸克云盘-qk](https://pan.quark.cn/)
|
||||||
- [UC云盘-uc](https://fast.uc.cn/)
|
- [UC云盘-uc](https://fast.uc.cn/)
|
||||||
- [移动云盘-p139](https://yun.139.com/)
|
- [移动云盘-p139](https://yun.139.com/)
|
||||||
@@ -143,7 +147,7 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
|
|||||||
- `your_host` 替换为您的域名或 IP
|
- `your_host` 替换为您的域名或 IP
|
||||||
|
|
||||||
### 认证参数(v0.2.1+)
|
### 认证参数(v0.2.1+)
|
||||||
|
[可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
||||||
部分网盘(如夸克、UC)需要登录后的 Cookie 才能解析和下载。可通过 `auth` 参数传递认证信息:
|
部分网盘(如夸克、UC)需要登录后的 Cookie 才能解析和下载。可通过 `auth` 参数传递认证信息:
|
||||||
|
|
||||||
**参数格式**:`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码
|
**参数格式**:`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码
|
||||||
@@ -172,6 +176,7 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
|
|||||||
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
|
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
|
||||||
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
|
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
|
||||||
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
|
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
|
||||||
|
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
|
||||||
|
|
||||||
**使用示例**:
|
**使用示例**:
|
||||||
```
|
```
|
||||||
@@ -179,6 +184,50 @@ GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
|
|||||||
```
|
```
|
||||||
|
|
||||||
> 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。
|
> 💡 提示: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`),无需重启服务,仅本次请求生效。
|
||||||
|
|
||||||
### 特殊说明
|
### 特殊说明
|
||||||
|
|
||||||
@@ -313,15 +362,15 @@ json返回数据格式示例:
|
|||||||
| 网盘名称 | 免登陆下载分享 | 加密分享 | 初始网盘空间 | 单文件大小限制 |
|
| 网盘名称 | 免登陆下载分享 | 加密分享 | 初始网盘空间 | 单文件大小限制 |
|
||||||
|-------------|---------|----------|-----------|-----------------|
|
|-------------|---------|----------|-----------|-----------------|
|
||||||
| 蓝奏云 | √ | √ | 不限空间 | 100M |
|
| 蓝奏云 | √ | √ | 不限空间 | 100M |
|
||||||
| 奶牛快传 | √ | X | 10G | 不限大小 |
|
|
||||||
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
|
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
|
||||||
| 小飞机网盘 | √ | √(密码可忽略) | 10G | 不限大小 |
|
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
|
||||||
| 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 |
|
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
|
||||||
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
|
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
|
||||||
| 文叔叔 | √ | √ | 10G | 5GB |
|
| 文叔叔 | √ | √ | 10G | 5GB |
|
||||||
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
||||||
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
||||||
| UC网盘 | x | √ | 10G | 不限大小 |
|
| UC网盘 | x | √ | 10G | 不限大小 |
|
||||||
|
| 飞书云盘 | √ | X | 15G | 不限大小 |
|
||||||
|
|
||||||
# 打包部署
|
# 打包部署
|
||||||
|
|
||||||
@@ -401,7 +450,7 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
|
|||||||
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
|
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
|
||||||
```shell
|
```shell
|
||||||
cd ~
|
cd ~
|
||||||
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.1.9b7/netdisk-fast-download-bin.zip
|
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v3.0.2/netdisk-fast-download-bin.zip
|
||||||
unzip netdisk-fast-download-bin.zip
|
unzip netdisk-fast-download-bin.zip
|
||||||
cd netdisk-fast-download
|
cd netdisk-fast-download
|
||||||
bash service-install.sh
|
bash service-install.sh
|
||||||
@@ -474,23 +523,6 @@ auths:
|
|||||||
|
|
||||||
**注意:** 目前仅支持 123(ye)的认证配置。
|
**注意:** 目前仅支持 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
|
Jdk17+Vert.x4
|
||||||
@@ -515,20 +547,5 @@ Core模块集成Vert.x实现类似spring的注解式路由API
|
|||||||
</p>
|
</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
|
#!/bin/bash
|
||||||
# set -x
|
# set -x
|
||||||
LAUNCH_JAR="netdisk-fast-download.jar"
|
LAUNCH_JAR="netdisk-fast-download.jar"
|
||||||
nohup java -Xmx512M -jar "$LAUNCH_JAR" "$@" >startup.log 2>&1 &
|
exec java -Xmx${JVM_XMX:-512M} ${JVM_OPTS} -jar "$LAUNCH_JAR" "$@"
|
||||||
tail -f startup.log
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<version>42.7.3</version>
|
<version>42.7.11</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class CreateDatabase {
|
|||||||
stmt.executeUpdate("CREATE DATABASE IF NOT EXISTS " + dbName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
stmt.executeUpdate("CREATE DATABASE IF NOT EXISTS " + dbName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
LOGGER.info(">>>>>>>>>>> 数据库'{}'创建成功 <<<<<<<<<<<<", dbName);
|
LOGGER.info(">>>>>>>>>>> 数据库'{}'创建成功 <<<<<<<<<<<<", dbName);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
e.printStackTrace();
|
LOGGER.error("创建数据库失败", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,35 +24,39 @@ import java.util.*;
|
|||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
*/
|
*/
|
||||||
public class CreateTable {
|
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类型的映射
|
// Java类型到SQL类型的映射
|
||||||
put(Integer.class, "INT");
|
map.put(Integer.class, "INT");
|
||||||
put(Short.class, "SMALLINT");
|
map.put(Short.class, "SMALLINT");
|
||||||
put(Byte.class, "TINYINT");
|
map.put(Byte.class, "TINYINT");
|
||||||
put(Long.class, "BIGINT");
|
map.put(Long.class, "BIGINT");
|
||||||
put(java.math.BigDecimal.class, "DECIMAL");
|
map.put(java.math.BigDecimal.class, "DECIMAL");
|
||||||
put(Double.class, "DOUBLE");
|
map.put(Double.class, "DOUBLE");
|
||||||
put(Float.class, "REAL");
|
map.put(Float.class, "REAL");
|
||||||
put(Boolean.class, "BOOLEAN");
|
map.put(Boolean.class, "BOOLEAN");
|
||||||
put(String.class, "VARCHAR");
|
map.put(String.class, "VARCHAR");
|
||||||
put(Date.class, "TIMESTAMP");
|
map.put(Date.class, "TIMESTAMP");
|
||||||
put(java.time.LocalDateTime.class, "TIMESTAMP");
|
map.put(java.time.LocalDateTime.class, "TIMESTAMP");
|
||||||
put(java.sql.Timestamp.class, "TIMESTAMP");
|
map.put(java.sql.Timestamp.class, "TIMESTAMP");
|
||||||
put(java.sql.Date.class, "DATE");
|
map.put(java.sql.Date.class, "DATE");
|
||||||
put(java.sql.Time.class, "TIME");
|
map.put(java.sql.Time.class, "TIME");
|
||||||
|
|
||||||
// 基本数据类型
|
// 基本数据类型
|
||||||
put(int.class, "INT");
|
map.put(int.class, "INT");
|
||||||
put(short.class, "SMALLINT");
|
map.put(short.class, "SMALLINT");
|
||||||
put(byte.class, "TINYINT");
|
map.put(byte.class, "TINYINT");
|
||||||
put(long.class, "BIGINT");
|
map.put(long.class, "BIGINT");
|
||||||
put(double.class, "DOUBLE");
|
map.put(double.class, "DOUBLE");
|
||||||
put(float.class, "REAL");
|
map.put(float.class, "REAL");
|
||||||
put(boolean.class, "BOOLEAN");
|
map.put(boolean.class, "BOOLEAN");
|
||||||
}};
|
|
||||||
|
javaProperty2SqlColumnMap = Collections.unmodifiableMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(CreateTable.class);
|
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) {
|
private static Case getCase(Class<?> clz) {
|
||||||
return switch (clz.getName()) {
|
return switch (clz.getName()) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
*
|
*
|
||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
* @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);
|
private static final Logger LOGGER = LoggerFactory.getLogger(JDBCPoolInit.class);
|
||||||
|
|
||||||
@@ -101,4 +101,16 @@ public class JDBCPoolInit {
|
|||||||
synchronized public JDBCPool getPool() {
|
synchronized public JDBCPool getPool() {
|
||||||
return pool;
|
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>
|
<version>${jackson.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,26 @@ package cn.qaiu.vx.core;
|
|||||||
import cn.qaiu.vx.core.util.CommonUtil;
|
import cn.qaiu.vx.core.util.CommonUtil;
|
||||||
import cn.qaiu.vx.core.util.ConfigUtil;
|
import cn.qaiu.vx.core.util.ConfigUtil;
|
||||||
import cn.qaiu.vx.core.util.VertxHolder;
|
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.ReverseProxyVerticle;
|
||||||
import cn.qaiu.vx.core.verticle.RouterVerticle;
|
import cn.qaiu.vx.core.verticle.RouterVerticle;
|
||||||
import cn.qaiu.vx.core.verticle.ServiceVerticle;
|
import cn.qaiu.vx.core.verticle.ServiceVerticle;
|
||||||
import io.vertx.core.*;
|
import io.vertx.core.*;
|
||||||
import io.vertx.core.dns.AddressResolverOptions;
|
import io.vertx.core.dns.AddressResolverOptions;
|
||||||
import io.vertx.core.impl.launcher.commands.VersionCommand;
|
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.core.shareddata.LocalMap;
|
import io.vertx.core.shareddata.LocalMap;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.lang.management.ManagementFactory;
|
import java.lang.management.ManagementFactory;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.Calendar;
|
import java.util.Calendar;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.locks.LockSupport;
|
import java.util.concurrent.locks.LockSupport;
|
||||||
|
|
||||||
import static cn.qaiu.vx.core.util.ConfigConstant.*;
|
import static cn.qaiu.vx.core.util.ConfigConstant.*;
|
||||||
@@ -41,11 +47,25 @@ public final class Deploy {
|
|||||||
private Handler<JsonObject> handle;
|
private Handler<JsonObject> handle;
|
||||||
|
|
||||||
private Thread mainThread;
|
private Thread mainThread;
|
||||||
|
private final List<Runnable> preShutdownTasks = new CopyOnWriteArrayList<>();
|
||||||
|
private final List<Runnable> postShutdownTasks = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
public static Deploy instance() {
|
public static Deploy instance() {
|
||||||
return 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 启动参数
|
* @param args 启动参数
|
||||||
@@ -54,15 +74,25 @@ public final class Deploy {
|
|||||||
public void start(String[] args, Handler<JsonObject> handle) {
|
public void start(String[] args, Handler<JsonObject> handle) {
|
||||||
this.mainThread = Thread.currentThread();
|
this.mainThread = Thread.currentThread();
|
||||||
this.handle = handle;
|
this.handle = handle;
|
||||||
|
|
||||||
if (args.length > 0 && args[0].startsWith("app-")) {
|
if (args.length > 0 && args[0].startsWith("app-")) {
|
||||||
// 启动参数dev或者prod
|
// 启动参数dev或者prod
|
||||||
path.append("-").append(args[0].replace("app-",""));
|
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)
|
ConfigUtil.readYamlConfig(path.toString(), tempVertx)
|
||||||
.onSuccess(this::readConf)
|
.onSuccess(this::readConf)
|
||||||
.onFailure(Throwable::printStackTrace);
|
.onFailure(err -> {
|
||||||
|
LOGGER.error("读取配置文件失败: {}", err.getMessage(), err);
|
||||||
|
LockSupport.unpark(mainThread);
|
||||||
|
System.exit(-1);
|
||||||
|
});
|
||||||
LockSupport.park();
|
LockSupport.park();
|
||||||
deployVerticle();
|
deployVerticle();
|
||||||
}
|
}
|
||||||
@@ -104,7 +134,7 @@ public final class Deploy {
|
|||||||
|
|
||||||
System.out.printf(logoTemplate,
|
System.out.printf(logoTemplate,
|
||||||
CommonUtil.getAppVersion(),
|
CommonUtil.getAppVersion(),
|
||||||
VersionCommand.getVersion(),
|
"4x",
|
||||||
conf.getString("copyright"),
|
conf.getString("copyright"),
|
||||||
year
|
year
|
||||||
);
|
);
|
||||||
@@ -119,44 +149,112 @@ public final class Deploy {
|
|||||||
customConfig = globalConfig.getJsonObject(CUSTOM);
|
customConfig = globalConfig.getJsonObject(CUSTOM);
|
||||||
|
|
||||||
JsonObject vertxConfig = globalConfig.getJsonObject(VERTX);
|
JsonObject vertxConfig = globalConfig.getJsonObject(VERTX);
|
||||||
Integer vertxConfigELPS = vertxConfig.getInteger(EVENT_LOOP_POOL_SIZE);
|
JsonObject vertxOptionsConfig = vertxConfig.copy();
|
||||||
var vertxOptions = vertxConfigELPS == 0 ?
|
if (vertxOptionsConfig.getInteger(EVENT_LOOP_POOL_SIZE, 0) == 0) {
|
||||||
new VertxOptions() : new VertxOptions(vertxConfig);
|
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(
|
// vertxOptions.setAddressResolverOptions(
|
||||||
new AddressResolverOptions().
|
// new AddressResolverOptions().
|
||||||
addServer("114.114.114.114").
|
// addServer("114.114.114.114").
|
||||||
addServer("114.114.115.115").
|
// addServer("114.114.115.115").
|
||||||
addServer("8.8.8.8").
|
// addServer("8.8.8.8").
|
||||||
addServer("8.8.4.4"));
|
// addServer("8.8.4.4"));
|
||||||
LOGGER.info("vertxConfigEventLoopPoolSize: {}, eventLoopPoolSize: {}, workerPoolSize: {}", vertxConfigELPS,
|
LOGGER.info("vertxConfigEventLoopPoolSize: {}, eventLoopPoolSize: {}, workerPoolSize: {}", vertxConfigELPS,
|
||||||
vertxOptions.getEventLoopPoolSize(),
|
vertxOptions.getEventLoopPoolSize(),
|
||||||
vertxOptions.getWorkerPoolSize());
|
vertxOptions.getWorkerPoolSize());
|
||||||
var vertx = Vertx.vertx(vertxOptions);
|
var vertx = Vertx.vertx(vertxOptions);
|
||||||
VertxHolder.init(vertx);
|
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();
|
var sharedData = vertx.sharedData();
|
||||||
LocalMap<String, Object> localMap = sharedData.getLocalMap(LOCAL);
|
LocalMap<String, Object> localMap = sharedData.getLocalMap(LOCAL);
|
||||||
localMap.put(GLOBAL_CONFIG, globalConfig);
|
localMap.put(GLOBAL_CONFIG, globalConfig);
|
||||||
localMap.put(CUSTOM_CONFIG, customConfig);
|
localMap.put(CUSTOM_CONFIG, customConfig);
|
||||||
localMap.put(SERVER, globalConfig.getJsonObject(SERVER));
|
localMap.put(SERVER, globalConfig.getJsonObject(SERVER));
|
||||||
var future0 = vertx.createSharedWorkerExecutor("other-handle")
|
WorkerExecutor otherHandleExecutor = vertx.createSharedWorkerExecutor("other-handle");
|
||||||
.executeBlocking(() -> {
|
var future0 = otherHandleExecutor.executeBlocking(() -> {
|
||||||
handle.handle(globalConfig);
|
handle.handle(globalConfig);
|
||||||
return "Other handle complete";
|
return "Other handle complete";
|
||||||
});
|
});
|
||||||
|
|
||||||
future0.onSuccess(res -> {
|
future0.onSuccess(res -> {
|
||||||
|
otherHandleExecutor.close();
|
||||||
LOGGER.info(res);
|
LOGGER.info(res);
|
||||||
// 部署 路由、异步service、反向代理 服务
|
// 部署 路由、异步service、反向代理 服务
|
||||||
var future1 = vertx.deployVerticle(RouterVerticle.class, getWorkDeploymentOptions("Router"));
|
var future1 = vertx.deployVerticle(RouterVerticle.class, getWorkDeploymentOptions("Router"));
|
||||||
var future2 = vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"));
|
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)
|
JsonObject jsonObject = ((JsonObject) localMap.get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
|
||||||
.onFailure(this::deployVerticalFailed);
|
if (jsonObject != null) {
|
||||||
}).onFailure(e -> LOGGER.error("Other handle error", e));
|
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 t1 = ((double) (System.currentTimeMillis() - startTime)) / 1000;
|
||||||
var t2 = ((double) System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
|
var t2 = ((double) System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
|
||||||
LOGGER.info("web服务启动成功 -> 用时: {}s, jvm启动用时: {}s", t1, t2);
|
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 {
|
public @interface HandleSortFilter {
|
||||||
/**
|
/**
|
||||||
* 注册顺序,数字越大越先注册<br>
|
* 注册顺序,数字越大越先注册<br>
|
||||||
|
* 前置拦截器会先执行后注册即数字小的, 后置拦截器会先执行先注册的即数字大的<br>
|
||||||
* 值<0时会过滤掉该处理器
|
* 值<0时会过滤掉该处理器
|
||||||
*/
|
*/
|
||||||
int value() default 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;
|
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.interceptor.AfterInterceptor;
|
||||||
import cn.qaiu.vx.core.model.JsonResult;
|
import cn.qaiu.vx.core.model.JsonResult;
|
||||||
import cn.qaiu.vx.core.util.CommonUtil;
|
|
||||||
import cn.qaiu.vx.core.util.ReflectionUtil;
|
import cn.qaiu.vx.core.util.ReflectionUtil;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.ext.web.RoutingContext;
|
import io.vertx.ext.web.RoutingContext;
|
||||||
import org.reflections.Reflections;
|
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.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static cn.qaiu.vx.core.util.ResponseUtil.*;
|
import static cn.qaiu.vx.core.util.ResponseUtil.*;
|
||||||
|
|
||||||
@@ -22,9 +28,10 @@ public interface BaseHttpApi {
|
|||||||
|
|
||||||
// 需要扫描注册的Router路径
|
// 需要扫描注册的Router路径
|
||||||
Reflections reflections = ReflectionUtil.getReflections();
|
Reflections reflections = ReflectionUtil.getReflections();
|
||||||
|
Logger LOGGER = LoggerFactory.getLogger(BaseHttpApi.class);
|
||||||
|
|
||||||
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
|
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
|
||||||
if (!ctx.response().ended()) {
|
if (!isResponseDone(ctx)) {
|
||||||
fireJsonObjectResponse(ctx, jsonObject);
|
fireJsonObjectResponse(ctx, jsonObject);
|
||||||
}
|
}
|
||||||
handleAfterInterceptor(ctx, jsonObject);
|
handleAfterInterceptor(ctx, jsonObject);
|
||||||
@@ -32,31 +39,93 @@ public interface BaseHttpApi {
|
|||||||
|
|
||||||
|
|
||||||
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult) {
|
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult) {
|
||||||
if (!ctx.response().ended()) {
|
if (!isResponseDone(ctx)) {
|
||||||
fireJsonResultResponse(ctx, jsonResult);
|
fireJsonResultResponse(ctx, jsonResult);
|
||||||
}
|
}
|
||||||
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
|
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() {
|
default Set<AfterInterceptor> getAfterInterceptor() {
|
||||||
|
return AfterInterceptorHolder.INSTANCES;
|
||||||
|
}
|
||||||
|
|
||||||
Set<Class<? extends AfterInterceptor>> afterInterceptorClassSet =
|
class AfterInterceptorHolder {
|
||||||
reflections.getSubTypesOf(AfterInterceptor.class);
|
private static final Set<AfterInterceptor> INSTANCES = loadAfterInterceptors();
|
||||||
if (afterInterceptorClassSet == null) {
|
|
||||||
return null;
|
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) {
|
default void handleAfterInterceptor(RoutingContext ctx, JsonObject jsonObject) {
|
||||||
Set<AfterInterceptor> afterInterceptor = getAfterInterceptor();
|
if (ctx.response().closed()) {
|
||||||
if (afterInterceptor != null) {
|
return;
|
||||||
afterInterceptor.forEach(ai -> ai.handle(ctx, jsonObject));
|
|
||||||
}
|
}
|
||||||
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");
|
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.*;
|
||||||
import io.vertx.ext.web.handler.sockjs.SockJSHandler;
|
import io.vertx.ext.web.handler.sockjs.SockJSHandler;
|
||||||
import io.vertx.ext.web.handler.sockjs.SockJSHandlerOptions;
|
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 javassist.CtClass;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.commons.lang3.tuple.Pair;
|
import org.apache.commons.lang3.tuple.Pair;
|
||||||
@@ -36,6 +34,7 @@ import java.lang.reflect.InvocationTargetException;
|
|||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@@ -76,15 +75,15 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
// 主路由
|
// 主路由
|
||||||
Router mainRouter = Router.router(VertxHolder.getVertxInstance());
|
Router mainRouter = Router.router(VertxHolder.getVertxInstance());
|
||||||
mainRouter.route().handler(ctx -> {
|
mainRouter.route().handler(ctx -> {
|
||||||
String realPath = ctx.request().uri();;
|
String realPath = ctx.request().uri();
|
||||||
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
|
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
|
||||||
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
||||||
String rePath = realPath.substring(REROUTE_PATH_PREFIX.length());
|
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
|
||||||
ctx.reroute(rePath);
|
ctx.reroute(rePath);
|
||||||
return;
|
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.request().path(), ctx.request().absoluteURI(), ctx.request().method());
|
||||||
ctx.response().headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
ctx.response().headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
||||||
ctx.response().headers().add(DATE, LocalDateTime.now().format(ISO_LOCAL_DATE_TIME));
|
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(CorsHandler.create().addRelativeOrigin(".*").allowCredentials(true).allowedMethods(httpMethods));
|
||||||
|
|
||||||
// 配置文件上传路径
|
// 配置文件上传路径
|
||||||
mainRouter.route().handler(BodyHandler.create().setUploadsDirectory("uploads"));
|
mainRouter.route().handler(BodyHandler.create()
|
||||||
|
.setUploadsDirectory("uploads")
|
||||||
// 配置Session管理 - 用于演练场登录状态持久化
|
.setBodyLimit(2L * 1024 * 1024));
|
||||||
// 30天过期时间(毫秒)
|
|
||||||
SessionStore sessionStore = LocalSessionStore.create(VertxHolder.getVertxInstance());
|
|
||||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
|
||||||
.setSessionTimeout(30L * 24 * 60 * 60 * 1000) // 30天
|
|
||||||
.setSessionCookieName("SESSIONID") // Cookie名称
|
|
||||||
.setCookieHttpOnlyFlag(true) // 防止XSS攻击
|
|
||||||
.setCookieSecureFlag(false); // 非HTTPS环境设置为false
|
|
||||||
mainRouter.route().handler(sessionHandler);
|
|
||||||
|
|
||||||
// 拦截器
|
// 拦截器
|
||||||
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
|
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
|
||||||
@@ -139,8 +130,9 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
// 错误请求处理
|
// 错误请求处理
|
||||||
mainRouter.errorHandler(405, ctx -> doFireJsonResultResponse(ctx, JsonResult
|
mainRouter.errorHandler(405, ctx -> doFireJsonResultResponse(ctx, JsonResult
|
||||||
.error("Method Not Allowed", 405)));
|
.error("Method Not Allowed", 405)));
|
||||||
mainRouter.errorHandler(404, ctx -> ctx.response().setStatusCode(404).setChunked(true)
|
mainRouter.errorHandler(404, ctx -> {
|
||||||
.end("Internal server error: 404 not found"));
|
ctx.response().setStatusCode(404).end("404 not found");
|
||||||
|
});
|
||||||
|
|
||||||
return mainRouter;
|
return mainRouter;
|
||||||
}
|
}
|
||||||
@@ -186,13 +178,14 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
route.handler(TimeoutHandler.create(SharedDataUtil.getCustomConfig().getInteger(ROUTE_TIME_OUT)));
|
route.handler(TimeoutHandler.create(SharedDataUtil.getCustomConfig().getInteger(ROUTE_TIME_OUT)));
|
||||||
route.handler(ResponseTimeHandler.create());
|
route.handler(ResponseTimeHandler.create());
|
||||||
route.handler(ctx -> handlerMethod(instance, method, ctx)).failureHandler(ctx -> {
|
route.handler(ctx -> handlerMethod(instance, method, ctx)).failureHandler(ctx -> {
|
||||||
if (ctx.response().ended()) return;
|
if (isResponseDone(ctx)) return;
|
||||||
// 超时处理器状态码503
|
// 超时处理器状态码503
|
||||||
if (ctx.statusCode() == 503 || ctx.failure() == null) {
|
if (ctx.statusCode() == 503 || ctx.failure() == null) {
|
||||||
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员", 500));
|
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员"), 503);
|
||||||
} else {
|
} else {
|
||||||
ctx.failure().printStackTrace();
|
LOGGER.error("路由处理失败", ctx.failure());
|
||||||
doFireJsonResultResponse(ctx, JsonResult.error(ctx.failure().getMessage(), 500));
|
String msg = ctx.failure() != null ? ctx.failure().getMessage() : "未知异常";
|
||||||
|
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (method.isAnnotationPresent(SockRouteMapper.class)) {
|
} else if (method.isAnnotationPresent(SockRouteMapper.class)) {
|
||||||
@@ -210,7 +203,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
try {
|
try {
|
||||||
ReflectionUtil.invokeWithArguments(method, instance, sock);
|
ReflectionUtil.invokeWithArguments(method, instance, sock);
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
e.printStackTrace();
|
LOGGER.error("WebSocket处理异常", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (url.endsWith("*")) {
|
if (url.endsWith("*")) {
|
||||||
@@ -246,7 +239,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
*/
|
*/
|
||||||
private Set<Handler<RoutingContext>> getInterceptorSet() {
|
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();
|
final MultiMap queryParams = ctx.queryParams();
|
||||||
// 解析body-json参数
|
// 解析body-json参数
|
||||||
// 只处理POST/PUT/PATCH等有body的请求方法,避免GET请求读取body导致"Request has already been read"错误
|
if (HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())) {
|
||||||
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) {
|
|
||||||
JsonObject body = ctx.body().asJsonObject();
|
JsonObject body = ctx.body().asJsonObject();
|
||||||
if (body != null) {
|
if (body != null) {
|
||||||
methodParametersTemp.forEach((k, v) -> {
|
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 {
|
try {
|
||||||
Class<?> aClass = Class.forName(v.getRight().getName());
|
Class<?> aClass = Class.forName(typeName);
|
||||||
JsonObject data = CommonUtil.getSubJsonForEntity(body, aClass);
|
JsonObject data = CommonUtil.getSubJsonForEntity(body, aClass);
|
||||||
if (!data.isEmpty()) {
|
if (!data.isEmpty()) {
|
||||||
Object entity = data.mapTo(aClass);
|
Object entity = data.mapTo(aClass);
|
||||||
parameterValueList.put(k, entity);
|
parameterValueList.put(k, entity);
|
||||||
}
|
}
|
||||||
} catch (ClassNotFoundException e) {
|
} 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))
|
} else if (ctx.body() != null) {
|
||||||
&& ctx.body() != null) {
|
|
||||||
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
|
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +370,21 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
Object entity = ParamUtil.multiMapToEntity(queryParams, aClass);
|
Object entity = ParamUtil.multiMapToEntity(queryParams, aClass);
|
||||||
parameterValueList.put(k, entity);
|
parameterValueList.put(k, entity);
|
||||||
} catch (Exception e) {
|
} 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);
|
Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray);
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
|
|
||||||
if (data instanceof JsonResult) {
|
if (data instanceof JsonResult jsonResult) {
|
||||||
doFireJsonResultResponse(ctx, (JsonResult<?>) data);
|
doFireJsonResultResponse(ctx, (JsonResult<?>) data, jsonResult.getCode());
|
||||||
}
|
} else if (data instanceof JsonObject) {
|
||||||
if (data instanceof JsonObject) {
|
|
||||||
doFireJsonObjectResponse(ctx, ((JsonObject) data));
|
doFireJsonObjectResponse(ctx, ((JsonObject) data));
|
||||||
} else if (data instanceof Future) { // 处理异步响应
|
} else if (data instanceof Future) { // 处理异步响应
|
||||||
((Future<?>) data).onSuccess(res -> {
|
Future<?> responseFuture = (Future<?>) data;
|
||||||
if (res instanceof JsonResult) {
|
AtomicReference<RoutingContext> ctxRef = new AtomicReference<>(ctx);
|
||||||
doFireJsonResultResponse(ctx, (JsonResult<?>) res);
|
ctx.addEndHandler(v -> ctxRef.set(null));
|
||||||
|
responseFuture.onComplete(ar -> {
|
||||||
|
RoutingContext responseCtx = ctxRef.getAndSet(null);
|
||||||
|
if (responseCtx == null || isResponseDone(responseCtx)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (res instanceof JsonObject) {
|
if (ar.succeeded()) {
|
||||||
doFireJsonObjectResponse(ctx, ((JsonObject) res));
|
Object res = ar.result();
|
||||||
} else if (res != null) {
|
if (res instanceof JsonResult jsonResult) {
|
||||||
doFireJsonResultResponse(ctx, JsonResult.data(res));
|
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 {
|
} 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 {
|
} else {
|
||||||
doFireJsonResultResponse(ctx, JsonResult.data(data));
|
doFireJsonResultResponse(ctx, JsonResult.data(data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
e.printStackTrace();
|
LOGGER.error("请求处理异常", e);
|
||||||
String err = e.getMessage();
|
String msg = e.getMessage() != null ? e.getMessage() : "服务器内部错误";
|
||||||
if (e.getCause() != null) {
|
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
|
||||||
if (e.getCause() instanceof InvocationTargetException) {
|
|
||||||
err = ((InvocationTargetException) e.getCause()).getTargetException().getMessage();
|
|
||||||
} else {
|
|
||||||
err = e.getCause().getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
doFireJsonResultResponse(ctx, JsonResult.error(err));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package cn.qaiu.vx.core.interceptor;
|
|||||||
import io.vertx.core.Handler;
|
import io.vertx.core.Handler;
|
||||||
import io.vertx.ext.web.RoutingContext;
|
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>
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
*/
|
*/
|
||||||
@@ -14,28 +16,25 @@ public interface BeforeInterceptor extends Handler<RoutingContext> {
|
|||||||
String IS_NEXT = "RoutingContextIsNext";
|
String IS_NEXT = "RoutingContextIsNext";
|
||||||
|
|
||||||
default Handler<RoutingContext> doHandle() {
|
default Handler<RoutingContext> doHandle() {
|
||||||
|
|
||||||
return ctx -> {
|
return ctx -> {
|
||||||
// 加同步锁
|
// 【优化】移除synchronized锁,Vert.x的RoutingContext本身就是线程安全的
|
||||||
synchronized (BeforeInterceptor.class) {
|
// 每个请求都有独立的RoutingContext,不需要额外加锁
|
||||||
ctx.put(IS_NEXT, false);
|
ctx.put(IS_NEXT, false);
|
||||||
BeforeInterceptor.this.handle(ctx);
|
handle(ctx); // 调用具体的处理逻辑
|
||||||
if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
|
// 确保如果没有调用doNext()并且响应未结束,则返回错误
|
||||||
sendError(ctx, 403);
|
// if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
|
||||||
}
|
// sendError(ctx, 403);
|
||||||
}
|
// }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
default void doNext(RoutingContext context) {
|
default void doNext(RoutingContext context) {
|
||||||
// 设置上下文状态为可以继续执行
|
// 【优化】移除synchronized锁
|
||||||
// 添加同步锁保障多线程下执行时序
|
// RoutingContext的put和next操作是线程安全的,不需要额外同步
|
||||||
synchronized (BeforeInterceptor.class) {
|
context.put(IS_NEXT, true);
|
||||||
context.put(IS_NEXT, true);
|
context.next(); // 继续执行下一个处理器
|
||||||
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 int code = SUCCESS_CODE;//状态码
|
||||||
|
|
||||||
private String msg = SUCCESS_MESSAGE; //消息
|
private String msg = SUCCESS_MESSAGE;//消息
|
||||||
|
|
||||||
private boolean success = true; //是否成功
|
private boolean success = true; //是否成功
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* ModuleGen cn.qaiu.vx.core
|
* 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;
|
package cn.qaiu.vx.core;
|
||||||
|
|
||||||
import io.vertx.codegen.annotations.ModuleGen;
|
import io.vertx.codegen.annotations.ModuleGen;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import io.vertx.serviceproxy.ServiceProxyBuilder;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Xu Haidong
|
* @author Xu Haidong
|
||||||
* Create at 2018/8/15
|
* @date 2018/8/15
|
||||||
*/
|
*/
|
||||||
public final class AsyncServiceUtil {
|
public final class AsyncServiceUtil {
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import java.net.Socket;
|
|||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +31,16 @@ public class CommonUtil {
|
|||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(CommonUtil.class);
|
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
|
* 匹配正则list
|
||||||
*
|
*
|
||||||
@@ -38,7 +51,7 @@ public class CommonUtil {
|
|||||||
public static boolean matchRegList(List<?> regList, String destStr) {
|
public static boolean matchRegList(List<?> regList, String destStr) {
|
||||||
// 判断是否忽略
|
// 判断是否忽略
|
||||||
for (Object ignores : regList) {
|
for (Object ignores : regList) {
|
||||||
if (destStr.matches(ignores.toString())) {
|
if (getCachedPattern(ignores.toString()).matcher(destStr).matches()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +130,7 @@ public class CommonUtil {
|
|||||||
return set.stream().filter(c1 -> {
|
return set.stream().filter(c1 -> {
|
||||||
HandleSortFilter s1 = c1.getAnnotation(HandleSortFilter.class);
|
HandleSortFilter s1 = c1.getAnnotation(HandleSortFilter.class);
|
||||||
if (s1 != null) {
|
if (s1 != null) {
|
||||||
return s1.value() > 0;
|
return s1.value() >= 0;
|
||||||
} else {
|
} else {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -138,7 +151,7 @@ public class CommonUtil {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}).collect(Collectors.toSet());
|
}).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String appVersion;
|
private static String appVersion;
|
||||||
@@ -146,13 +159,15 @@ public class CommonUtil {
|
|||||||
public static String getAppVersion() {
|
public static String getAppVersion() {
|
||||||
if (null == appVersion) {
|
if (null == appVersion) {
|
||||||
Properties properties = new Properties();
|
Properties properties = new Properties();
|
||||||
try {
|
try (var is = CommonUtil.class.getClassLoader().getResourceAsStream("app.properties")) {
|
||||||
properties.load(CommonUtil.class.getClassLoader().getResourceAsStream("app.properties"));
|
if (is != null) {
|
||||||
if (!properties.isEmpty()) {
|
properties.load(is);
|
||||||
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
|
if (!properties.isEmpty()) {
|
||||||
|
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
LOGGER.error("读取app.properties失败", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return appVersion;
|
return appVersion;
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import io.vertx.config.ConfigRetriever;
|
|||||||
import io.vertx.config.ConfigRetrieverOptions;
|
import io.vertx.config.ConfigRetrieverOptions;
|
||||||
import io.vertx.config.ConfigStoreOptions;
|
import io.vertx.config.ConfigStoreOptions;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.Vertx;
|
import io.vertx.core.Vertx;
|
||||||
import io.vertx.core.json.JsonObject;
|
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
|
* <br>Create date 2021/9/2 1:23
|
||||||
@@ -24,7 +30,29 @@ public class ConfigUtil {
|
|||||||
* @return JsonObject的Future
|
* @return JsonObject的Future
|
||||||
*/
|
*/
|
||||||
public static Future<JsonObject> readConfig(String format, String path, Vertx vertx) {
|
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()
|
ConfigStoreOptions store = new ConfigStoreOptions()
|
||||||
.setType("file")
|
.setType("file")
|
||||||
.setFormat(format)
|
.setFormat(format)
|
||||||
@@ -33,10 +61,45 @@ public class ConfigUtil {
|
|||||||
ConfigRetriever retriever = ConfigRetriever
|
ConfigRetriever retriever = ConfigRetriever
|
||||||
.create(vertx, new ConfigRetrieverOptions().addStore(store));
|
.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配置文件
|
* 异步读取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>
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
* Create at 2023/10/14 9:07
|
* @date 2023/10/14 9:07
|
||||||
*/
|
*/
|
||||||
public class JacksonConfig {
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package cn.qaiu.vx.core.util;
|
package cn.qaiu.vx.core.util;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* vertx 上下文外的本地容器 为不在vertx线程的方法传递数据
|
* vertx 上下文外的本地容器 为不在vertx线程的方法传递数据
|
||||||
@@ -10,11 +10,10 @@ import java.util.Map;
|
|||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
*/
|
*/
|
||||||
public class LocalConstant {
|
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) {
|
public static Map<String, Object> put(String k, Object v) {
|
||||||
if (LOCAL_CONST.containsKey(k)) return LOCAL_CONST;
|
LOCAL_CONST.putIfAbsent(k, v);
|
||||||
LOCAL_CONST.put(k, v);
|
|
||||||
return LOCAL_CONST;
|
return LOCAL_CONST;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,16 +36,20 @@ public final class ParamUtil {
|
|||||||
|
|
||||||
public static MultiMap paramsToMap(String paramString) {
|
public static MultiMap paramsToMap(String paramString) {
|
||||||
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
|
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
|
||||||
if (paramString == null) return entries;
|
if (paramString == null || paramString.isEmpty()) return entries;
|
||||||
String[] params = paramString.split("&");
|
String[] params = paramString.split("&");
|
||||||
if (params.length == 0) return entries;
|
if (params.length == 0) return entries;
|
||||||
for (String param : params) {
|
for (String param : params) {
|
||||||
String[] kv = param.split("=");
|
if (param == null || param.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String[] kv = param.split("=", 2);
|
||||||
if (kv.length == 2) {
|
if (kv.length == 2) {
|
||||||
entries.set(kv[0], kv[1]);
|
entries.set(kv[0], kv[1]);
|
||||||
} else {
|
} else if (kv.length == 1) {
|
||||||
entries.set(kv[0], "");
|
entries.set(kv[0], "");
|
||||||
}
|
}
|
||||||
|
// kv.length == 0 时(空字符串),跳过
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import java.lang.reflect.Method;
|
|||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.text.ParseException;
|
import java.text.ParseException;
|
||||||
import java.util.*;
|
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;
|
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 {
|
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
|
* @param packageAddress Package address String
|
||||||
* @return Reflections object
|
* @return Reflections object
|
||||||
*/
|
*/
|
||||||
public static Reflections getReflections(String packageAddress) {
|
public static Reflections getReflections(String packageAddress) {
|
||||||
List<String> packageAddressList;
|
return REFLECTIONS_CACHE.computeIfAbsent(packageAddress, key -> {
|
||||||
if (packageAddress.contains(",")) {
|
List<String> packageAddressList;
|
||||||
packageAddressList = Arrays.asList(packageAddress.split(","));
|
if (key.contains(",")) {
|
||||||
} else if (packageAddress.contains(";")) {
|
packageAddressList = Arrays.asList(key.split(","));
|
||||||
packageAddressList = Arrays.asList(packageAddress.split(";"));
|
} else if (key.contains(";")) {
|
||||||
} else {
|
packageAddressList = Arrays.asList(key.split(";"));
|
||||||
packageAddressList = Collections.singletonList(packageAddress);
|
} else {
|
||||||
}
|
packageAddressList = Collections.singletonList(key);
|
||||||
|
}
|
||||||
return getReflections(packageAddressList);
|
return createReflections(packageAddressList);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取反射器
|
* 获取反射器(带缓存)
|
||||||
*
|
*
|
||||||
* @param packageAddresses Package address List
|
* @param packageAddresses Package address List
|
||||||
* @return Reflections object
|
* @return Reflections object
|
||||||
*/
|
*/
|
||||||
public static Reflections getReflections(List<String> packageAddresses) {
|
public static Reflections getReflections(List<String> packageAddresses) {
|
||||||
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
|
String cacheKey = String.join(",", packageAddresses);
|
||||||
FilterBuilder filterBuilder = new FilterBuilder();
|
return REFLECTIONS_CACHE.computeIfAbsent(cacheKey, key -> createReflections(packageAddresses));
|
||||||
packageAddresses.forEach(str -> {
|
}
|
||||||
Collection<URL> urls = ClasspathHelper.forPackage(str.trim());
|
|
||||||
configurationBuilder.addUrls(urls);
|
|
||||||
filterBuilder.includePackage(str.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
// 采坑记录 2021-05-08
|
private static Reflections createReflections(List<String> packageAddresses) {
|
||||||
// 发现注解api层 没有继承父类时 这里反射一直有问题(Scanner SubTypesScanner was not configured)
|
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder()
|
||||||
// 因此这里需要手动配置各种Scanner扫描器 -- https://blog.csdn.net/qq_29499107/article/details/106889781
|
.addClassLoaders(Thread.currentThread().getContextClassLoader())
|
||||||
configurationBuilder.setScanners(
|
.forPackages(packageAddresses.toArray(new String[0]))
|
||||||
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
|
.setScanners(
|
||||||
// 会报错.默认为true.
|
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
|
||||||
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
|
// 会报错.默认为true.
|
||||||
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
|
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
|
||||||
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
|
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
|
||||||
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
|
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
|
||||||
);
|
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
|
||||||
|
);
|
||||||
configurationBuilder.filterInputsBy(filterBuilder);
|
|
||||||
return new Reflections(configurationBuilder);
|
return new Reflections(configurationBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +140,7 @@ public final class ReflectionUtil {
|
|||||||
parameterTypes[j - k]));
|
parameterTypes[j - k]));
|
||||||
}
|
}
|
||||||
} catch (NotFoundException e) {
|
} catch (NotFoundException e) {
|
||||||
e.printStackTrace();
|
LOGGER.error("获取方法参数失败", e);
|
||||||
}
|
}
|
||||||
return paramMap;
|
return paramMap;
|
||||||
}
|
}
|
||||||
@@ -185,7 +195,7 @@ public final class ReflectionUtil {
|
|||||||
try {
|
try {
|
||||||
return DateUtils.parseDate(value, fmt);
|
return DateUtils.parseDate(value, fmt);
|
||||||
} catch (ParseException e) {
|
} catch (ParseException e) {
|
||||||
e.printStackTrace();
|
LOGGER.error("日期解析失败: {}", value, e);
|
||||||
throw new RuntimeException("无法将格式化日期");
|
throw new RuntimeException("无法将格式化日期");
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -217,7 +227,7 @@ public final class ReflectionUtil {
|
|||||||
}
|
}
|
||||||
return arr;
|
return arr;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
LOGGER.error("数组类型转换失败: {}", value, e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -231,8 +241,7 @@ public final class ReflectionUtil {
|
|||||||
if (ctClass.isPrimitive() || "java.util.Date".equals(ctClass.getName())) {
|
if (ctClass.isPrimitive() || "java.util.Date".equals(ctClass.getName())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return ctClass.getName().matches("^java\\.lang\\.((Boolean)|(Character)|(Byte)|(Short)|(Integer)|(Long)|" +
|
return BASIC_TYPE_PATTERN.matcher(ctClass.getName()).matches();
|
||||||
"(Float)|(Double)|(String))$");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,7 +252,7 @@ public final class ReflectionUtil {
|
|||||||
public static boolean isBasicTypeArray(CtClass ctClass) {
|
public static boolean isBasicTypeArray(CtClass ctClass) {
|
||||||
if (!ctClass.isArray()) {
|
if (!ctClass.isArray()) {
|
||||||
return false;
|
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 class ResponseUtil {
|
||||||
|
|
||||||
public static void redirect(HttpServerResponse response, String url) {
|
public static void redirect(HttpServerResponse response, String url) {
|
||||||
|
if (response.ended() || response.closed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
response.putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
|
response.putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
|
||||||
|
.putHeader("Referrer-Policy", "no-referrer")
|
||||||
.putHeader(HttpHeaders.LOCATION, url).setStatusCode(302).end();
|
.putHeader(HttpHeaders.LOCATION, url).setStatusCode(302).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void redirect(HttpServerResponse response, String url, Promise<?> promise) {
|
public static void redirect(HttpServerResponse response, String url, Promise<?> promise) {
|
||||||
redirect(response, url);
|
try {
|
||||||
promise.complete();
|
redirect(response, url);
|
||||||
|
promise.tryComplete();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
promise.tryFail(t);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
|
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
|
||||||
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
fireJsonObjectResponse(ctx, jsonObject, 200);
|
||||||
.setStatusCode(200)
|
|
||||||
.end(jsonObject.encode());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject) {
|
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")
|
ctx.putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||||
.setStatusCode(200)
|
.setStatusCode(statusCode)
|
||||||
.end(jsonObject.encode());
|
.end(jsonObject.encode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,15 +59,25 @@ public class ResponseUtil {
|
|||||||
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
|
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) {
|
public static <T> void fireJsonResultResponse(HttpServerResponse ctx, JsonResult<T> jsonResult) {
|
||||||
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
|
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void fireTextResponse(RoutingContext ctx, String text) {
|
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);
|
ctx.response().putHeader(CONTENT_TYPE, "text/html; charset=utf-8").end(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void sendError(RoutingContext ctx, int statusCode) {
|
public static void sendError(RoutingContext ctx, int statusCode) {
|
||||||
|
if (ctx.response().ended() || ctx.response().closed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
ctx.response().setStatusCode(statusCode).end();
|
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.JsonArray;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.core.shareddata.LocalMap;
|
import io.vertx.core.shareddata.LocalMap;
|
||||||
import io.vertx.core.shareddata.SharedData;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* vertx 共享数据
|
* vertx 共享数据
|
||||||
@@ -13,10 +12,8 @@ import io.vertx.core.shareddata.SharedData;
|
|||||||
*/
|
*/
|
||||||
public class SharedDataUtil {
|
public class SharedDataUtil {
|
||||||
|
|
||||||
private static final SharedData sharedData = VertxHolder.getVertxInstance().sharedData();
|
public static io.vertx.core.shareddata.SharedData shareData() {
|
||||||
|
return VertxHolder.getVertxInstance().sharedData();
|
||||||
public static SharedData shareData() {
|
|
||||||
return sharedData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static LocalMap<String, Object> getLocalMap(String key) {
|
public static LocalMap<String, Object> getLocalMap(String key) {
|
||||||
@@ -24,7 +21,7 @@ public class SharedDataUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static <T> LocalMap<String, T> getLocalMapWithCast(String key) {
|
public static <T> LocalMap<String, T> getLocalMapWithCast(String key) {
|
||||||
return sharedData.getLocalMap(key);
|
return shareData().getLocalMap(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static JsonObject getJsonConfig(String key) {
|
public static JsonObject getJsonConfig(String key) {
|
||||||
|
|||||||
@@ -1,57 +1,101 @@
|
|||||||
package cn.qaiu.vx.core.verticle;
|
package cn.qaiu.vx.core.verticle;
|
||||||
|
|
||||||
import io.vertx.core.AbstractVerticle;
|
import io.vertx.core.AbstractVerticle;
|
||||||
import io.vertx.core.Vertx;
|
import io.vertx.core.Future;
|
||||||
import io.vertx.core.VertxOptions;
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.dns.AddressResolverOptions;
|
|
||||||
import io.vertx.core.http.*;
|
import io.vertx.core.http.*;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.core.net.NetClient;
|
import io.vertx.core.net.NetClient;
|
||||||
import io.vertx.core.net.NetClientOptions;
|
import io.vertx.core.net.NetClientOptions;
|
||||||
import io.vertx.core.net.NetSocket;
|
import io.vertx.core.net.NetSocket;
|
||||||
import io.vertx.core.net.ProxyOptions;
|
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 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 {
|
public class HttpProxyVerticle extends AbstractVerticle {
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(HttpProxyVerticle.class);
|
||||||
|
|
||||||
private HttpClient httpClient;
|
private HttpClient httpClient;
|
||||||
private NetClient netClient;
|
private NetClient netClient;
|
||||||
|
private HttpServer httpServer;
|
||||||
|
private volatile boolean stopping = false;
|
||||||
|
|
||||||
|
private JsonObject proxyPreConf;
|
||||||
|
private JsonObject proxyServerConf;
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start() {
|
public void start(io.vertx.core.Promise<Void> startPromise) {
|
||||||
ProxyOptions proxyOptions = new ProxyOptions().setHost("127.0.0.1").setPort(7890);
|
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 请求
|
// 初始化 HTTP 客户端,用于向目标服务器发送 HTTP 请求
|
||||||
HttpClientOptions httpClientOptions = new HttpClientOptions();
|
HttpClientOptions httpClientOptions = new HttpClientOptions()
|
||||||
httpClient = vertx.createHttpClient(httpClientOptions.setProxyOptions(proxyOptions));
|
.setMaxPoolSize(64)
|
||||||
|
.setMaxWaitQueueSize(256)
|
||||||
|
.setConnectTimeout(15000)
|
||||||
|
.setIdleTimeout(60)
|
||||||
|
.setKeepAlive(true);
|
||||||
|
if (proxyOptions != null) {
|
||||||
|
httpClientOptions.setProxyOptions(proxyOptions);
|
||||||
|
}
|
||||||
|
httpClient = vertx.createHttpClient(httpClientOptions);
|
||||||
|
|
||||||
// 创建并启动 HTTP 代理服务器,监听指定端口
|
// 创建并启动 HTTP 代理服务器,监听指定端口
|
||||||
HttpServer server = vertx.createHttpServer(new HttpServerOptions().setClientAuth(ClientAuth.REQUIRED));
|
HttpServerOptions httpServerOptions = new HttpServerOptions();
|
||||||
server.requestHandler(this::handleClientRequest);
|
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,用于在 CONNECT 请求中建立 TCP 连接隧道
|
||||||
netClient = vertx.createNetClient(new NetClientOptions()
|
NetClientOptions netClientOptions = new NetClientOptions();
|
||||||
.setProxyOptions(proxyOptions)
|
|
||||||
|
if (proxyOptions != null) {
|
||||||
|
netClientOptions.setProxyOptions(proxyOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
netClient = vertx.createNetClient(netClientOptions
|
||||||
.setConnectTimeout(15000)
|
.setConnectTimeout(15000)
|
||||||
.setTrustAll(true));
|
.setTrustAll(true));
|
||||||
|
|
||||||
// 启动 HTTP 代理服务器
|
// 启动 HTTP 代理服务器
|
||||||
server.listen(7891, ar -> {
|
httpServer.listen(serverPort)
|
||||||
if (ar.succeeded()) {
|
.onSuccess(res -> {
|
||||||
System.out.println("HTTP Proxy server started on port 7891");
|
LOGGER.info("HTTP Proxy server started on port {}", serverPort);
|
||||||
} else {
|
startPromise.complete();
|
||||||
System.err.println("Failed to start HTTP Proxy server: " + ar.cause());
|
})
|
||||||
}
|
.onFailure(err -> {
|
||||||
});
|
LOGGER.error("Failed to start HTTP Proxy server: " + err.getMessage(), err);
|
||||||
|
closeClients().onComplete(close -> startPromise.fail(err));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
|
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
|
||||||
private void handleConnectRequest(HttpServerRequest clientRequest) {
|
private void handleConnectRequest(HttpServerRequest clientRequest) {
|
||||||
String[] uriParts = clientRequest.uri().split(":");
|
String[] uriParts = clientRequest.uri().split(":");
|
||||||
if (uriParts.length != 2) {
|
if (uriParts.length != 2) {
|
||||||
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid URI format");
|
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid URI format");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,55 +105,85 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
|||||||
try {
|
try {
|
||||||
targetPort = Integer.parseInt(uriParts[1]);
|
targetPort = Integer.parseInt(uriParts[1]);
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid port");
|
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid port");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clientRequest.pause();
|
clientRequest.pause();
|
||||||
// 通过 NetClient 连接目标服务器并创建隧道
|
// 通过 NetClient 连接目标服务器并创建隧道
|
||||||
netClient.connect(targetPort, targetHost, connectionAttempt -> {
|
try {
|
||||||
if (connectionAttempt.succeeded()) {
|
netClient.connect(targetPort, targetHost)
|
||||||
NetSocket targetSocket = connectionAttempt.result();
|
.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 并实现双向数据流
|
// Close the other socket when one side closes
|
||||||
clientRequest.toNetSocket().onComplete(clientSocketAttempt -> {
|
clientSocket.closeHandler(v -> targetSocket.close());
|
||||||
if (clientSocketAttempt.succeeded()) {
|
targetSocket.closeHandler(v -> clientSocket.close());
|
||||||
NetSocket clientSocket = clientSocketAttempt.result();
|
})
|
||||||
|
.onFailure(clientSocketAttempt -> {
|
||||||
// 设置双向数据流转发
|
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.getMessage());
|
||||||
clientSocket.handler(targetSocket::write);
|
targetSocket.close();
|
||||||
targetSocket.handler(clientSocket::write);
|
failClientRequestAndClose(clientRequest, 500, "Internal Server Error");
|
||||||
|
});
|
||||||
// 关闭其中一方时关闭另一方
|
})
|
||||||
clientSocket.closeHandler(v -> targetSocket.close());
|
.onFailure(connectionAttempt -> {
|
||||||
targetSocket.closeHandler(v -> clientSocket.close());
|
LOGGER.warn("Failed to connect to target: {}", connectionAttempt.getMessage());
|
||||||
} else {
|
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to connect to target");
|
||||||
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.cause().getMessage());
|
});
|
||||||
targetSocket.close();
|
} catch (Exception e) {
|
||||||
clientRequest.response().setStatusCode(500).end("Internal Server Error");
|
LOGGER.warn("CONNECT 请求创建失败", e);
|
||||||
}
|
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to connect to target");
|
||||||
});
|
}
|
||||||
} else {
|
|
||||||
System.err.println("Failed to connect to target: " + connectionAttempt.cause().getMessage());
|
|
||||||
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理客户端的 HTTP 请求
|
// 处理客户端的 HTTP 请求
|
||||||
private void handleClientRequest(HttpServerRequest clientRequest) {
|
private void handleClientRequest(HttpServerRequest clientRequest) {
|
||||||
String s = clientRequest.headers().get("Proxy-Authorization");
|
if (stopping) {
|
||||||
if (s == null) {
|
failClientResponse(clientRequest.response(), 503, "Service Unavailable");
|
||||||
clientRequest.response().setStatusCode(403).end();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String[] split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
|
// 打印来源ip和访问目标URI
|
||||||
if (split.length > 1) {
|
LOGGER.debug("source: {}, target: {}", clientRequest.remoteAddress().toString(), clientRequest.uri());
|
||||||
System.out.println(split[0]);
|
if (proxyServerConf.containsKey("username") &&
|
||||||
System.out.println(split[1]);
|
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
|
||||||
// TODO
|
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) {
|
if (clientRequest.method() == HttpMethod.CONNECT) {
|
||||||
// 处理 CONNECT 请求
|
// 处理 CONNECT 请求
|
||||||
handleConnectRequest(clientRequest);
|
handleConnectRequest(clientRequest);
|
||||||
@@ -124,61 +198,222 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
|||||||
// 获取目标主机
|
// 获取目标主机
|
||||||
String hostHeader = clientRequest.getHeader("Host");
|
String hostHeader = clientRequest.getHeader("Host");
|
||||||
if (hostHeader == null) {
|
if (hostHeader == null) {
|
||||||
clientRequest.response().setStatusCode(400).end("Host header is missing");
|
failClientResponse(clientRequest.response(), 400, "Host header is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String targetHost = hostHeader.split(":")[0];
|
HostAndPort target;
|
||||||
int targetPort = 80; // 默认为 HTTP 的端口
|
try {
|
||||||
clientRequest.pause(); // 暂停客户端请求的读取,避免数据丢失
|
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())
|
try {
|
||||||
.onSuccess(request -> {
|
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
|
||||||
clientRequest.resume(); // 恢复客户端请求的读取
|
.onSuccess(request -> {
|
||||||
|
// 逐个设置请求头
|
||||||
|
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
|
||||||
|
|
||||||
// 逐个设置请求头
|
request.response()
|
||||||
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
|
.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.pipeTo(request)
|
||||||
clientRequest.bodyHandler(body -> request.send(body, ar -> {
|
.onFailure(err -> {
|
||||||
if (ar.succeeded()) {
|
LOGGER.error("HTTP代理请求转发失败", err);
|
||||||
var response = ar.result();
|
try {
|
||||||
clientRequest.response().setStatusCode(response.statusCode());
|
request.reset();
|
||||||
clientRequest.response().headers().setAll(response.headers());
|
} catch (Exception e) {
|
||||||
response.body().onSuccess(b-> clientRequest.response().end(b));
|
LOGGER.debug("HTTP代理上游请求已关闭", e);
|
||||||
} else {
|
}
|
||||||
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to reach target");
|
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
|
||||||
}
|
});
|
||||||
}));
|
clientRequest.resume();
|
||||||
})
|
})
|
||||||
.onFailure(err -> {
|
.onFailure(err -> {
|
||||||
err.printStackTrace();
|
LOGGER.error("HTTP请求失败", err);
|
||||||
clientRequest.response().setStatusCode(502).end("Bad Gateway: Request failed");
|
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Request failed");
|
||||||
});
|
});
|
||||||
}
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("HTTP请求创建失败", e);
|
||||||
@Override
|
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Request failed");
|
||||||
public void stop() {
|
|
||||||
// 停止 HTTP 客户端以释放资源
|
|
||||||
if (httpClient != null) {
|
|
||||||
httpClient.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void failClientResponse(HttpServerResponse response, String message) {
|
||||||
* TODO add Deploy
|
failClientResponse(response, 502, message);
|
||||||
* @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, 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 cn.qaiu.vx.core.util.*;
|
||||||
import io.vertx.core.AbstractVerticle;
|
import io.vertx.core.AbstractVerticle;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
|
import io.vertx.core.Handler;
|
||||||
import io.vertx.core.Promise;
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.http.HttpClient;
|
import io.vertx.core.http.HttpClient;
|
||||||
|
import io.vertx.core.http.HttpClientOptions;
|
||||||
import io.vertx.core.http.HttpServer;
|
import io.vertx.core.http.HttpServer;
|
||||||
import io.vertx.core.http.HttpServerOptions;
|
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.JsonArray;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.core.net.PemKeyCertOptions;
|
import io.vertx.core.net.PemKeyCertOptions;
|
||||||
import io.vertx.ext.web.Route;
|
import io.vertx.ext.web.Route;
|
||||||
import io.vertx.ext.web.Router;
|
import io.vertx.ext.web.Router;
|
||||||
|
import io.vertx.ext.web.RoutingContext;
|
||||||
import io.vertx.ext.web.handler.StaticHandler;
|
import io.vertx.ext.web.handler.StaticHandler;
|
||||||
import io.vertx.ext.web.proxy.handler.ProxyHandler;
|
import io.vertx.ext.web.proxy.handler.ProxyHandler;
|
||||||
import io.vertx.httpproxy.HttpProxy;
|
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.apache.commons.lang3.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -22,12 +30,18 @@ import org.slf4j.LoggerFactory;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.net.MalformedURLException;
|
import java.net.MalformedURLException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>反向代理服务</p>
|
* <p>反向代理服务</p>
|
||||||
* <p>可以根据配置文件自动生成代理服务</p>
|
* <p>可以根据配置文件自动生成代理服务</p>
|
||||||
* <p>可以配置多个服务, 配置文件见示例</p>
|
* <p>可以配置多个服务, 配置文件见示例</p>
|
||||||
|
* <p>【优化】支持高并发场景,连接池复用,避免线程阻塞</p>
|
||||||
* <br>Create date 2021/9/2 0:41
|
* <br>Create date 2021/9/2 0:41
|
||||||
*
|
*
|
||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
* @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 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 final String DEFAULT_PATH_404 = "webroot/err/page404.html";
|
||||||
|
|
||||||
private static String serverName = "Vert.x-proxy-server"; //Server name in Http response header
|
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 硬编码
|
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
|
@Override
|
||||||
public void start(Promise<Void> startPromise) {
|
public void start(Promise<Void> startPromise) {
|
||||||
CONFIG.onSuccess(this::handleProxyConfList);
|
stopping = false;
|
||||||
// createFileListener
|
String pathProxyConfig = SharedDataUtil
|
||||||
startPromise.complete();
|
.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
|
* @param config proxy config
|
||||||
*/
|
*/
|
||||||
private void handleProxyConfList(JsonObject config) {
|
private Future<Void> startProxyServers(JsonObject config) {
|
||||||
serverName = config.getString("server-name");
|
serverName = config.getString("server-name");
|
||||||
JsonArray proxyConfList = config.getJsonArray("proxy");
|
// 解析全局 trusted-proxies
|
||||||
if (proxyConfList != null) {
|
JsonArray trustedArr = config.getJsonArray("trusted-proxies");
|
||||||
proxyConfList.forEach(proxyConf -> {
|
if (trustedArr != null) {
|
||||||
if (proxyConf instanceof JsonObject) {
|
trustedArr.forEach(ip -> {
|
||||||
handleProxyConf((JsonObject) proxyConf);
|
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 代理配置
|
* @param proxyConf 代理配置
|
||||||
*/
|
*/
|
||||||
private void handleProxyConf(JsonObject proxyConf) {
|
private Future<Void> handleProxyConf(JsonObject proxyConf) {
|
||||||
// page404 path
|
// page404 path
|
||||||
if (proxyConf.containsKey(
|
if (proxyConf.containsKey(
|
||||||
|
|
||||||
@@ -97,18 +273,31 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
proxyConf.put("page404", DEFAULT_PATH_404);
|
proxyConf.put("page404", DEFAULT_PATH_404);
|
||||||
}
|
}
|
||||||
|
|
||||||
final HttpClient httpClient = VertxHolder.getVertxInstance().createHttpClient();
|
|
||||||
Router proxyRouter = Router.router(vertx);
|
Router proxyRouter = Router.router(vertx);
|
||||||
|
|
||||||
// Add Server name header
|
// Add Server name header
|
||||||
proxyRouter.route().handler(ctx -> {
|
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();
|
ctx.next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// http api proxy
|
// http api proxy
|
||||||
if (proxyConf.containsKey("location")) {
|
if (proxyConf.containsKey("location")) {
|
||||||
handleLocation(proxyConf.getJsonArray("location"), httpClient, proxyRouter);
|
handleLocation(proxyConf.getJsonArray("location"), proxyRouter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// static server
|
// static server
|
||||||
@@ -117,20 +306,34 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send page404 page
|
// 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);
|
HttpServer server = getHttpsServer(proxyConf);
|
||||||
server.requestHandler(proxyRouter);
|
server.requestHandler(proxyRouter);
|
||||||
|
|
||||||
Integer port = proxyConf.getInteger("listen");
|
Integer port = proxyConf.getInteger("listen");
|
||||||
LOGGER.info("proxy server start on {} port", port);
|
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) {
|
private HttpServer getHttpsServer(JsonObject proxyConf) {
|
||||||
HttpServerOptions httpServerOptions = new HttpServerOptions()
|
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")) {
|
if (proxyConf.containsKey("ssl")) {
|
||||||
JsonObject sslConfig = proxyConf.getJsonObject("ssl");
|
JsonObject sslConfig = proxyConf.getJsonObject("ssl");
|
||||||
|
|
||||||
@@ -161,6 +364,67 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
return vertx.createHttpServer(httpServerOptions);
|
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 {
|
} else {
|
||||||
staticHandler = StaticHandler.create();
|
staticHandler = StaticHandler.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (staticConf.containsKey("directory-listing")) {
|
if (staticConf.containsKey("directory-listing")) {
|
||||||
staticHandler.setDirectoryListing(staticConf.getBoolean("directory-listing"));
|
staticHandler.setDirectoryListing(staticConf.getBoolean("directory-listing"));
|
||||||
} else if (staticConf.containsKey("index")) {
|
} else if (staticConf.containsKey("index")) {
|
||||||
@@ -197,10 +460,9 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
* 处理Location配置 代理请求Location(和nginx类似?)
|
* 处理Location配置 代理请求Location(和nginx类似?)
|
||||||
*
|
*
|
||||||
* @param locationsConf location配置
|
* @param locationsConf location配置
|
||||||
* @param httpClient 客户端
|
|
||||||
* @param proxyRouter 代理路由
|
* @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 -> {
|
locationsConf.stream().map(e -> (JsonObject) e).forEach(location -> {
|
||||||
// 代理规则
|
// 代理规则
|
||||||
@@ -211,14 +473,38 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
String host = url.getHost();
|
String host = url.getHost();
|
||||||
int port = url.getPort();
|
int port = url.getPort();
|
||||||
if (port == -1) {
|
if (port == -1) {
|
||||||
port = 80;
|
port = 443;
|
||||||
}
|
}
|
||||||
String originPath = url.getPath();
|
String originPath = url.getPath();
|
||||||
LOGGER.info("path {}, originPath {}, to {}:{}", path, originPath, host, port);
|
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);
|
final HttpProxy httpProxy = HttpProxy.reverseProxy(httpClient);
|
||||||
httpProxy.origin(port, host);
|
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)) {
|
if (StringUtils.isEmpty(path)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -227,24 +513,64 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
if (StringUtils.isEmpty(originPath) || path.equals(originPath)) {
|
if (StringUtils.isEmpty(originPath) || path.equals(originPath)) {
|
||||||
Route route = path.startsWith("~") ? proxyRouter.routeWithRegex(path.substring(1))
|
Route route = path.startsWith("~") ? proxyRouter.routeWithRegex(path.substring(1))
|
||||||
: proxyRouter.route(path);
|
: proxyRouter.route(path);
|
||||||
route.handler(ProxyHandler.create(httpProxy));
|
addProxyHandler(route, httpProxy);
|
||||||
} else {
|
} else {
|
||||||
// 配置 /api/, / => 请求 /api/test 代理后 /test
|
// 配置 /api/, / => 请求 /api/test 代理后 /test
|
||||||
// 配置 /api/, /xxx => 请求 /api/test 代理后 /xxx/test
|
// 配置 /api/, /xxx => 请求 /api/test 代理后 /xxx/test
|
||||||
final String path0 = path;
|
final String path0 = path;
|
||||||
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
|
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
|
||||||
|
|
||||||
proxyRouter.route(originPath0 + "*").handler(ProxyHandler.create(httpProxy));
|
addProxyHandler(proxyRouter.route(originPath0 + "*"), httpProxy);
|
||||||
proxyRouter.route(path0 + "*").handler(ctx -> {
|
proxyRouter.route(path0 + "*").handler(ctx -> {
|
||||||
String realPath = ctx.request().uri();
|
String realPath = ctx.request().uri();
|
||||||
if (realPath.startsWith(path0)) {
|
if (realPath.startsWith(path0)) {
|
||||||
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
||||||
String rePath = realPath.replaceAll("^" + path0, originPath0);
|
String rePath = realPath.replaceAll("^" + path0, originPath0);
|
||||||
ctx.reroute(rePath);
|
ctx.reroute(rePath);
|
||||||
} else {
|
} else {
|
||||||
ctx.next();
|
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) {
|
} catch (MalformedURLException e) {
|
||||||
|
|||||||
@@ -22,22 +22,22 @@ public class RouterVerticle extends AbstractVerticle {
|
|||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(RouterVerticle.class);
|
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 HttpServer server;
|
||||||
|
private Router router;
|
||||||
|
private int port;
|
||||||
|
private JsonObject globalConfig;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
LOGGER.info(JacksonConfig.class.getSimpleName() + " >> ");
|
LOGGER.info(JacksonConfig.class.getSimpleName() + " >> ");
|
||||||
JacksonConfig.nothing();
|
JacksonConfig.nothing();
|
||||||
LOGGER.info("To start listening to port {} ......", port);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start(Promise<Void> startPromise) {
|
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)) {
|
if (CommonUtil.isPortUsing(port)) {
|
||||||
throw new RuntimeException("Start fail: the '" + port + "' port is already in use...");
|
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 = new HttpServerOptions();
|
||||||
}
|
}
|
||||||
options.setPort(port);
|
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 = vertx.createHttpServer(options);
|
||||||
|
|
||||||
server.requestHandler(router).webSocketHandler(s->{}).listen()
|
server.requestHandler(router).listen()
|
||||||
.onSuccess(s -> startPromise.complete())
|
.onSuccess(s -> startPromise.complete())
|
||||||
.onFailure(e -> startPromise.fail(e.getCause()));
|
.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 cn.qaiu.vx.core.util.ReflectionUtil;
|
||||||
import io.vertx.core.AbstractVerticle;
|
import io.vertx.core.AbstractVerticle;
|
||||||
import io.vertx.core.Promise;
|
import io.vertx.core.Promise;
|
||||||
|
import io.vertx.core.eventbus.MessageConsumer;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.serviceproxy.ServiceBinder;
|
import io.vertx.serviceproxy.ServiceBinder;
|
||||||
import org.reflections.Reflections;
|
import org.reflections.Reflections;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
@@ -24,26 +28,48 @@ public class ServiceVerticle extends AbstractVerticle {
|
|||||||
Logger LOGGER = LoggerFactory.getLogger(ServiceVerticle.class);
|
Logger LOGGER = LoggerFactory.getLogger(ServiceVerticle.class);
|
||||||
private static final AtomicInteger ID = new AtomicInteger(1);
|
private static final AtomicInteger ID = new AtomicInteger(1);
|
||||||
private static final Set<Class<?>> handlers;
|
private static final Set<Class<?>> handlers;
|
||||||
|
private final List<MessageConsumer<JsonObject>> consumers = new ArrayList<>();
|
||||||
|
|
||||||
static {
|
static {
|
||||||
Reflections reflections = ReflectionUtil.getReflections();
|
Reflections reflections = ReflectionUtil.getReflections();
|
||||||
handlers = reflections.getTypesAnnotatedWith(Service.class);
|
handlers = reflections.getTypesAnnotatedWith(Service.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start(Promise<Void> startPromise) {
|
public void start(Promise<Void> startPromise) {
|
||||||
ServiceBinder binder = new ServiceBinder(vertx);
|
ServiceBinder binder = new ServiceBinder(vertx);
|
||||||
if (null != handlers && handlers.size() > 0) {
|
if (null != handlers && handlers.size() > 0) {
|
||||||
|
// handlers转为拼接类列表,xxx,yyy,zzz
|
||||||
|
StringBuilder serviceNames = new StringBuilder();
|
||||||
handlers.forEach(asyncService -> {
|
handlers.forEach(asyncService -> {
|
||||||
try {
|
try {
|
||||||
|
serviceNames.append(asyncService.getName()).append("|");
|
||||||
BaseAsyncService asInstance = (BaseAsyncService) ReflectionUtil.newWithNoParam(asyncService);
|
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) {
|
} 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();
|
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
|
- 语言:Java 17
|
||||||
- 构建:Maven
|
- 构建:Maven
|
||||||
- 模块版本:10.1.17
|
- 模块版本:10.2.5
|
||||||
|
|
||||||
## 依赖(Maven Central)
|
## 依赖(Maven Central)
|
||||||
```xml
|
```xml
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.qaiu</groupId>
|
<groupId>cn.qaiu</groupId>
|
||||||
<artifactId>parser</artifactId>
|
<artifactId>parser</artifactId>
|
||||||
<version>10.1.17</version>
|
<version>10.2.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
```
|
```
|
||||||
- Gradle Groovy DSL:
|
- Gradle Groovy DSL:
|
||||||
```groovy
|
```groovy
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'cn.qaiu:parser:10.1.17'
|
implementation 'cn.qaiu:parser:10.2.5'
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- Gradle Kotlin DSL:
|
- Gradle Kotlin DSL:
|
||||||
```kotlin
|
```kotlin
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("cn.qaiu:parser:10.1.17")
|
implementation("cn.qaiu:parser:10.2.5")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.qaiu</groupId>
|
<groupId>cn.qaiu</groupId>
|
||||||
<artifactId>parser</artifactId>
|
<artifactId>parser</artifactId>
|
||||||
<version>10.1.17</version>
|
<version>10.2.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.qaiu</groupId>
|
<groupId>cn.qaiu</groupId>
|
||||||
<artifactId>parser</artifactId>
|
<artifactId>parser</artifactId>
|
||||||
<version>10.1.17</version>
|
<version>10.2.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -240,13 +240,13 @@ var encoded = JsHttpClient.urlEncode("hello world"); // "hello%20world"
|
|||||||
var decoded = JsHttpClient.urlDecode("hello%20world"); // "hello world"
|
var decoded = JsHttpClient.urlDecode("hello%20world"); // "hello world"
|
||||||
|
|
||||||
// 发送简单表单数据
|
// 发送简单表单数据
|
||||||
var formResponse = http.sendForm({
|
var formResponse = http.sendForm("https://api.example.com/login", {
|
||||||
username: "user",
|
username: "user",
|
||||||
password: "pass"
|
password: "pass"
|
||||||
});
|
});
|
||||||
|
|
||||||
// 发送JSON数据
|
// 发送JSON数据
|
||||||
var jsonResponse = http.sendJson({
|
var jsonResponse = http.sendJson("https://api.example.com/submit", {
|
||||||
name: "test",
|
name: "test",
|
||||||
value: 123
|
value: 123
|
||||||
});
|
});
|
||||||
@@ -637,7 +637,7 @@ A: 使用 `shareLinkInfo.getSharePassword()` 方法。
|
|||||||
|
|
||||||
### Q: 如何处理需要登录的网盘?
|
### Q: 如何处理需要登录的网盘?
|
||||||
|
|
||||||
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm()` 发送登录表单。
|
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm(url, data)` 发送登录表单。
|
||||||
|
|
||||||
### Q: 如何解析复杂的HTML?
|
### Q: 如何解析复杂的HTML?
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ List<FileInfo> files = tool.parseFileListSync();
|
|||||||
```
|
```
|
||||||
|
|
||||||
要点:
|
要点:
|
||||||
- 必须先 WebClientVertxInit.init(Vertx);若未显式初始化,内部将懒加载 Vertx.vertx(),建议显式注入以统一生命周期。
|
- 必须先 WebClientVertxInit.init(Vertx);未初始化时会直接报错,避免解析器偷偷创建第二个 Vert.x 实例。
|
||||||
- 支持三种同步方法:
|
- 支持三种同步方法:
|
||||||
- `parseSync()`: 解析单个文件下载链接
|
- `parseSync()`: 解析单个文件下载链接
|
||||||
- `parseFileListSync()`: 解析文件列表
|
- `parseFileListSync()`: 解析文件列表
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ URL解码 → Base64解码 → AES解密 → JSON对象
|
|||||||
- **密钥长度**: 16位(128位)
|
- **密钥长度**: 16位(128位)
|
||||||
- **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置)
|
- **默认密钥**: `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 模型定义
|
## JSON 模型定义
|
||||||
|
|
||||||
### AuthParam 对象
|
### AuthParam 对象
|
||||||
@@ -301,14 +317,25 @@ if (auths != null) {
|
|||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
在 `app-dev.yml` 中配置加密密钥:
|
在 `app-dev.yml` 中配置密钥:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
# auth参数加密密钥(16位AES密钥)
|
# auth参数加密密钥(16位AES密钥)
|
||||||
authEncryptKey: 'your_custom_key16'
|
authEncryptKey: 'your_custom_key16'
|
||||||
|
|
||||||
|
# 捐赠账号失败计数token签名密钥(HMAC)
|
||||||
|
# 建议使用较长随机字符串,并与 authEncryptKey 不同
|
||||||
|
donatedAccountFailureTokenSignKey: 'your_random_hmac_sign_key'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 密钥管理建议
|
||||||
|
|
||||||
|
- 不要在公开仓库提交生产密钥
|
||||||
|
- 建议通过环境变量或私有配置注入
|
||||||
|
- 调整 `authEncryptKey` 会影响 `auth` 参数兼容性
|
||||||
|
- 调整 `donatedAccountFailureTokenSignKey` 会使已签发的失败计数 token 失效(短期可接受)
|
||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
|
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
this.temporaryExecutor = WebClientVertxInit.get().createSharedWorkerExecutor(
|
this.temporaryExecutor = WebClientVertxInit.get().createSharedWorkerExecutor(
|
||||||
"playground-temp-" + System.currentTimeMillis(),
|
"playground-temp-" + System.currentTimeMillis(),
|
||||||
1, // 每个请求只需要1个线程
|
1, // 每个请求只需要1个线程
|
||||||
10000000000L // 设置非常长的超时,避免被vertx强制中断
|
10000000000L // 设置非常长的超时,避免触发Vert.x阻塞线程告警
|
||||||
);
|
);
|
||||||
|
|
||||||
// 执行完成或超时后关闭
|
// 执行完成或超时后关闭
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ executionFuture.toCompletionStage()
|
|||||||
### 长期方案(需大量工作)
|
### 长期方案(需大量工作)
|
||||||
1. **迁移到GraalVM JavaScript引擎**
|
1. **迁移到GraalVM JavaScript引擎**
|
||||||
- 支持CPU时间限制
|
- 支持CPU时间限制
|
||||||
- 可以强制中断
|
- 相比Nashorn更容易实现受控取消
|
||||||
- 更好的性能
|
- 更好的性能
|
||||||
- 但需要额外依赖
|
- 但需要额外依赖
|
||||||
|
|
||||||
|
|||||||
+43
-9
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<groupId>cn.qaiu</groupId>
|
<groupId>cn.qaiu</groupId>
|
||||||
<artifactId>parser</artifactId>
|
<artifactId>parser</artifactId>
|
||||||
<version>10.2.3</version>
|
<version>${parserVersion}</version>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>cn.qaiu:parser</name>
|
<name>cn.qaiu:parser</name>
|
||||||
@@ -35,9 +35,9 @@
|
|||||||
</developers>
|
</developers>
|
||||||
|
|
||||||
<scm>
|
<scm>
|
||||||
<connection>scm:git:https://github.com/qaiu/netdisk-fast-download.git</connection>
|
<connection>scm:git:https://github.com/${github.owner}/${github.repo}.git</connection>
|
||||||
<developerConnection>scm:git:ssh://[email protected]:qaiu/netdisk-fast-download.git</developerConnection>
|
<developerConnection>scm:git:ssh://[email protected]:${github.owner}/${github.repo}.git</developerConnection>
|
||||||
<url>https://github.com/qaiu/netdisk-fast-download</url>
|
<url>https://github.com/${github.owner}/${github.repo}</url>
|
||||||
</scm>
|
</scm>
|
||||||
|
|
||||||
<distributionManagement>
|
<distributionManagement>
|
||||||
@@ -52,20 +52,19 @@
|
|||||||
</distributionManagement>
|
</distributionManagement>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>0.2.1</revision>
|
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
<maven.compiler.source>17</maven.compiler.source>
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
<maven.compiler.target>17</maven.compiler.target>
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
|
||||||
<!-- Versions -->
|
<!-- Versions -->
|
||||||
<vertx.version>4.5.22</vertx.version>
|
<vertx.version>4.5.27</vertx.version>
|
||||||
<org.reflections.version>0.10.2</org.reflections.version>
|
<org.reflections.version>0.10.2</org.reflections.version>
|
||||||
<lombok.version>1.18.38</lombok.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>
|
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||||
<jackson.version>2.14.2</jackson.version>
|
<jackson.version>2.18.6</jackson.version>
|
||||||
<logback.version>1.5.19</logback.version>
|
<logback.version>1.5.32</logback.version>
|
||||||
<junit.version>4.13.2</junit.version>
|
<junit.version>4.13.2</junit.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
@@ -124,6 +123,41 @@
|
|||||||
<build>
|
<build>
|
||||||
<plugins>
|
<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>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import org.slf4j.LoggerFactory;
|
|||||||
import cn.qaiu.parser.custom.CustomParserRegistry;
|
import cn.qaiu.parser.custom.CustomParserRegistry;
|
||||||
|
|
||||||
public class WebClientVertxInit {
|
public class WebClientVertxInit {
|
||||||
private Vertx vertx = null;
|
private volatile Vertx vertx = null;
|
||||||
private static final WebClientVertxInit INSTANCE = new WebClientVertxInit();
|
private static final WebClientVertxInit INSTANCE = new WebClientVertxInit();
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(WebClientVertxInit.class);
|
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;
|
INSTANCE.vertx = vx;
|
||||||
|
|
||||||
// 自动加载JavaScript解析器脚本
|
// 自动加载JavaScript解析器脚本
|
||||||
@@ -23,18 +26,10 @@ public class WebClientVertxInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Vertx get() {
|
public static synchronized Vertx get() {
|
||||||
if (INSTANCE.vertx == null) {
|
if (INSTANCE.vertx == null) {
|
||||||
log.info("getVertx: Vertx实例不存在, 创建Vertx实例.");
|
throw new IllegalStateException("Vertx实例未初始化,请先调用 WebClientVertxInit.init(vertx)");
|
||||||
INSTANCE.vertx = Vertx.vertx();
|
|
||||||
|
|
||||||
// 如果Vertx实例是新创建的,也尝试加载JavaScript脚本
|
|
||||||
try {
|
|
||||||
CustomParserRegistry.autoLoadJsScripts();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("自动加载JavaScript解析器脚本失败", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return INSTANCE.vertx;
|
return INSTANCE.vertx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ public class ShareLinkInfo {
|
|||||||
// 将type和shareKey组合成一个字符串作为缓存key
|
// 将type和shareKey组合成一个字符串作为缓存key
|
||||||
String key = type + ":" + shareKey;
|
String key = type + ":" + shareKey;
|
||||||
if (type.equals("p115")) {
|
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;
|
return key;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cn.qaiu.parser;//package cn.qaiu.lz.common.parser;
|
package cn.qaiu.parser;//package cn.qaiu.lz.common.parser;
|
||||||
|
|
||||||
|
import cn.qaiu.WebClientVertxInit;
|
||||||
import cn.qaiu.entity.FileInfo;
|
import cn.qaiu.entity.FileInfo;
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory;
|
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.Future;
|
||||||
import io.vertx.core.Promise;
|
import io.vertx.core.Promise;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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();
|
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() {
|
default String parseSync() {
|
||||||
return parse().toCompletionStage().toCompletableFuture().join();
|
return timedJoin(parse());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +114,7 @@ public interface IPanTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default List<FileInfo> parseFileListSync() {
|
default List<FileInfo> parseFileListSync() {
|
||||||
return parseFileList().toCompletionStage().toCompletableFuture().join();
|
return timedJoin(parseFileList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +128,7 @@ public interface IPanTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default String parseByIdSync() {
|
default String parseByIdSync() {
|
||||||
return parseById().toCompletionStage().toCompletableFuture().join();
|
return timedJoin(parseById());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,7 +207,7 @@ public interface IPanTool {
|
|||||||
* @return Map<ClientLinkType, String> 客户端下载链接集合
|
* @return Map<ClientLinkType, String> 客户端下载链接集合
|
||||||
*/
|
*/
|
||||||
default Map<ClientLinkType, String> parseWithClientLinksSync() {
|
default Map<ClientLinkType, String> parseWithClientLinksSync() {
|
||||||
return parseWithClientLinks().toCompletionStage().toCompletableFuture().join();
|
return timedJoin(parseWithClientLinks());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,4 +218,26 @@ public interface IPanTool {
|
|||||||
default ShareLinkInfo getShareLinkInfo() {
|
default ShareLinkInfo getShareLinkInfo() {
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,36 +34,65 @@ import java.util.zip.GZIPInputStream;
|
|||||||
* <p>{网盘标识}Tool, 网盘标识不超过5个字符, 可以取网盘名称首字母缩写或拼音首字母, <br>
|
* <p>{网盘标识}Tool, 网盘标识不超过5个字符, 可以取网盘名称首字母缩写或拼音首字母, <br>
|
||||||
* 音乐类型的解析以M开头, 例如网易云音乐Mne</p>
|
* 音乐类型的解析以M开头, 例如网易云音乐Mne</p>
|
||||||
*/
|
*/
|
||||||
public abstract class PanBase implements IPanTool {
|
public abstract class PanBase implements IPanTool, Closeable {
|
||||||
protected Logger log = LoggerFactory.getLogger(this.getClass());
|
protected Logger log = LoggerFactory.getLogger(this.getClass());
|
||||||
|
|
||||||
protected Promise<String> promise = Promise.promise();
|
protected Promise<String> promise = Promise.promise();
|
||||||
|
|
||||||
/**
|
private static final int MAX_COMPRESSED_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
* Http client
|
private static final int MAX_DECOMPRESSED_RESPONSE_CHARS = 16 * 1024 * 1024;
|
||||||
*/
|
private static final int MAX_ERROR_BODY_CHARS = 4096;
|
||||||
protected WebClient client = WebClient.create(WebClientVertxInit.get(),
|
|
||||||
new WebClientOptions());
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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);
|
protected WebClientSession clientSession = WebClientSession.create(client);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Http client 不自动跳转
|
* Http client 不自动跳转
|
||||||
*/
|
*/
|
||||||
protected WebClient clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
protected WebClient clientNoRedirects = sharedClientNoRedirects();
|
||||||
new WebClientOptions().setFollowRedirects(false));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Http client disable UserAgent
|
* Http client disable UserAgent
|
||||||
*/
|
*/
|
||||||
protected WebClient clientDisableUA = WebClient.create(WebClientVertxInit.get()
|
protected WebClient clientDisableUA = sharedClientDisableUA();
|
||||||
, new WebClientOptions().setUserAgentEnabled(false)
|
|
||||||
);
|
|
||||||
|
|
||||||
protected ShareLinkInfo shareLinkInfo;
|
protected ShareLinkInfo shareLinkInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记是否为代理模式(代理模式创建的 WebClient 需要手动关闭)
|
||||||
|
*/
|
||||||
|
private boolean isProxyMode = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理模式下创建的独立 WebClient 实例(需要在 close 时释放)
|
||||||
|
*/
|
||||||
|
private WebClient proxyClient = null;
|
||||||
|
private WebClient proxyClientNoRedirects = null;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +109,7 @@ public abstract class PanBase implements IPanTool {
|
|||||||
public PanBase(ShareLinkInfo shareLinkInfo) {
|
public PanBase(ShareLinkInfo shareLinkInfo) {
|
||||||
this.shareLinkInfo = shareLinkInfo;
|
this.shareLinkInfo = shareLinkInfo;
|
||||||
if (shareLinkInfo.getOtherParam().containsKey("proxy")) {
|
if (shareLinkInfo.getOtherParam().containsKey("proxy")) {
|
||||||
|
this.isProxyMode = true;
|
||||||
JsonObject proxy = (JsonObject) shareLinkInfo.getOtherParam().get("proxy");
|
JsonObject proxy = (JsonObject) shareLinkInfo.getOtherParam().get("proxy");
|
||||||
ProxyOptions proxyOptions = new ProxyOptions()
|
ProxyOptions proxyOptions = new ProxyOptions()
|
||||||
.setType(ProxyType.valueOf(proxy.getString("type").toUpperCase()))
|
.setType(ProxyType.valueOf(proxy.getString("type").toUpperCase()))
|
||||||
@@ -91,22 +121,86 @@ public abstract class PanBase implements IPanTool {
|
|||||||
if (StringUtils.isNotEmpty(proxy.getString("password"))) {
|
if (StringUtils.isNotEmpty(proxy.getString("password"))) {
|
||||||
proxyOptions.setPassword(proxy.getString("password"));
|
proxyOptions.setPassword(proxy.getString("password"));
|
||||||
}
|
}
|
||||||
this.client = WebClient.create(WebClientVertxInit.get(),
|
// 代理模式下创建独立的 WebClient 实例(应用超时配置)
|
||||||
new WebClientOptions()
|
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)
|
.setUserAgentEnabled(false)
|
||||||
.setProxyOptions(proxyOptions));
|
.setProxyOptions(proxyOptions));
|
||||||
|
|
||||||
|
this.client = proxyClient;
|
||||||
this.clientSession = WebClientSession.create(client);
|
this.clientSession = WebClientSession.create(client);
|
||||||
this.clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
this.clientNoRedirects = proxyClientNoRedirects;
|
||||||
new WebClientOptions().setFollowRedirects(false)
|
|
||||||
.setUserAgentEnabled(false)
|
|
||||||
.setProxyOptions(proxyOptions));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected PanBase() {
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected String baseMsg() {
|
protected String baseMsg() {
|
||||||
if (shareLinkInfo.getShareUrl() != null) {
|
if (shareLinkInfo.getShareUrl() != null) {
|
||||||
return shareLinkInfo.getPanName() + "-" + shareLinkInfo.getType() + ": url=" + shareLinkInfo.getShareUrl();
|
return shareLinkInfo.getPanName() + "-" + shareLinkInfo.getType() + ": url=" + shareLinkInfo.getShareUrl();
|
||||||
@@ -131,16 +225,19 @@ public abstract class PanBase implements IPanTool {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String s = String.format(errorMsg.replaceAll("\\{}", "%s"), args);
|
String s = String.format(errorMsg.replaceAll("\\{}", "%s"), args);
|
||||||
log.error("解析异常: " + s, t.fillInStackTrace());
|
// 只记录异常消息和类型,不调用 fillInStackTrace 避免产生巨大栈信息
|
||||||
promise.fail(baseMsg() + ": 解析异常: " + s + " -> " + t);
|
log.error("解析异常: {} - {}: {}", s, t.getClass().getSimpleName(), t.getMessage());
|
||||||
|
// 只传递异常消息,不传递完整异常对象,减少内存占用
|
||||||
|
String failMsg = baseMsg() + ": 解析异常: " + s + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage();
|
||||||
|
promise.fail(failMsg);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("ErrorMsg format fail. The parameter has been discarded", 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()) {
|
if (promise.future().isComplete()) {
|
||||||
log.warn("ErrorMsg format. Promise 已经完成, 无法再次失败: {}", errorMsg);
|
log.warn("ErrorMsg format. Promise 已经完成, 无法再次失败: {}", errorMsg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t);
|
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +277,7 @@ public abstract class PanBase implements IPanTool {
|
|||||||
* @return Handler
|
* @return Handler
|
||||||
*/
|
*/
|
||||||
protected Handler<Throwable> handleFail(String errorMsg) {
|
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() {
|
protected Handler<Throwable> handleFail() {
|
||||||
@@ -199,28 +296,22 @@ public abstract class PanBase implements IPanTool {
|
|||||||
String contentEncoding = res.getHeader("Content-Encoding");
|
String contentEncoding = res.getHeader("Content-Encoding");
|
||||||
try {
|
try {
|
||||||
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
||||||
// 如果是gzip压缩的响应体,解压
|
// 如果是gzip压缩的响应体,解压(只解压一次,缓存结果)
|
||||||
return new JsonObject(decompressGzip((Buffer) res.body()));
|
String decompressed = decompressGzip((Buffer) res.body());
|
||||||
|
return new JsonObject(decompressed);
|
||||||
} else {
|
} else {
|
||||||
return res.bodyAsJsonObject();
|
return res.bodyAsJsonObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
||||||
// 如果是gzip压缩的响应体,解压
|
// gzip解压失败,记录错误
|
||||||
try {
|
log.error("响应gzip解压或JSON解析失败: {}", e.getMessage());
|
||||||
log.error(decompressGzip((Buffer) res.body()));
|
fail("响应gzip解压或JSON解析失败: {}", e.getMessage());
|
||||||
fail(decompressGzip((Buffer) res.body()));
|
|
||||||
//throw new RuntimeException("响应不是JSON格式");
|
|
||||||
} catch (IOException ex) {
|
|
||||||
log.error("响应gzip解压失败");
|
|
||||||
fail("响应gzip解压失败: {}", ex.getMessage());
|
|
||||||
//throw new RuntimeException("响应gzip解压失败", ex);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log.error("解析失败: json格式异常: {}", res.bodyAsString());
|
String bodyPreview = responseBodyPreview(res);
|
||||||
fail("解析失败: json格式异常: {}", res.bodyAsString());
|
log.error("解析失败: json格式异常: {}", bodyPreview);
|
||||||
//throw new RuntimeException("解析失败: json格式异常");
|
fail("解析失败: json格式异常: {}", bodyPreview);
|
||||||
}
|
}
|
||||||
return JsonObject.of();
|
return JsonObject.of();
|
||||||
}
|
}
|
||||||
@@ -283,11 +374,15 @@ public abstract class PanBase implements IPanTool {
|
|||||||
if (iterator.hasNext()) {
|
if (iterator.hasNext()) {
|
||||||
PanDomainTemplate next = iterator.next();
|
PanDomainTemplate next = iterator.next();
|
||||||
log.debug("规则不匹配, 处理解析器转发: {} -> {}", shareLinkInfo.getPanName(), next.getDisplayName());
|
log.debug("规则不匹配, 处理解析器转发: {} -> {}", shareLinkInfo.getPanName(), next.getDisplayName());
|
||||||
ParserCreate.fromType(next.name())
|
try {
|
||||||
.fromAnyShareUrl(shareLinkInfo.getShareUrl())
|
IPanTool nextTool = ParserCreate.fromType(next.name())
|
||||||
.createTool()
|
.fromAnyShareUrl(shareLinkInfo.getShareUrl())
|
||||||
.parse()
|
.createTool();
|
||||||
.onComplete(promise);
|
IPanTool.closeAfter(nextTool, nextTool::parse)
|
||||||
|
.onComplete(promise);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail(e, "转发到下一个解析器失败: {}", next.getDisplayName());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fail("error: 没有下一个解析处理器");
|
fail("error: 没有下一个解析处理器");
|
||||||
}
|
}
|
||||||
@@ -303,6 +398,12 @@ public abstract class PanBase implements IPanTool {
|
|||||||
* @throws IOException IOException
|
* @throws IOException IOException
|
||||||
*/
|
*/
|
||||||
private String decompressGzip(Buffer compressedData) throws 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());
|
try (ByteArrayInputStream bais = new ByteArrayInputStream(compressedData.getBytes());
|
||||||
GZIPInputStream gzis = new GZIPInputStream(bais);
|
GZIPInputStream gzis = new GZIPInputStream(bais);
|
||||||
InputStreamReader isr = new InputStreamReader(gzis, StandardCharsets.UTF_8);
|
InputStreamReader isr = new InputStreamReader(gzis, StandardCharsets.UTF_8);
|
||||||
@@ -311,12 +412,39 @@ public abstract class PanBase implements IPanTool {
|
|||||||
char[] buffer = new char[4096];
|
char[] buffer = new char[4096];
|
||||||
int n;
|
int n;
|
||||||
while ((n = isr.read(buffer)) != -1) {
|
while ((n = isr.read(buffer)) != -1) {
|
||||||
writer.write(buffer, 0, n);
|
writeLimited(writer, buffer, n);
|
||||||
}
|
}
|
||||||
return writer.toString();
|
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(){
|
protected String getDomainName(){
|
||||||
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
|
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
|
||||||
}
|
}
|
||||||
@@ -325,4 +453,28 @@ public abstract class PanBase implements IPanTool {
|
|||||||
public ShareLinkInfo getShareLinkInfo() {
|
public ShareLinkInfo getShareLinkInfo() {
|
||||||
return shareLinkInfo;
|
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
|
t-is.cn
|
||||||
*/
|
*/
|
||||||
LZ("蓝奏云",
|
LZ("蓝奏云",
|
||||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?(" +
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?(?:" +
|
||||||
"lanzoul|" +
|
"(?:lanzoul|" +
|
||||||
"lanzouh|" +
|
"lanzouh|" +
|
||||||
"lanosso|" +
|
"lanosso|" +
|
||||||
"lanpv|" +
|
"lanpv|" +
|
||||||
"bakstotre|" +
|
"bakstotre|" +
|
||||||
"lanzouo|" +
|
"lanzouo|" +
|
||||||
"lanzov|" +
|
"lanzov|" +
|
||||||
"lanpw|" +
|
"lanpw|" +
|
||||||
"ulanzou|" +
|
"ulanzou|" +
|
||||||
"lanzouf|" +
|
"lanzouf|" +
|
||||||
"lanzn|" +
|
"lanzn|" +
|
||||||
"lanzouj|" +
|
"lanzouj|" +
|
||||||
"lanzouk|" +
|
"lanzouk|" +
|
||||||
"lanzouq|" +
|
"lanzouq|" +
|
||||||
"lanzouv|" +
|
"lanzouv|" +
|
||||||
"lanzoue|" +
|
"lanzoue|" +
|
||||||
"lanzouw|" +
|
"lanzouw|" +
|
||||||
"lanzoub|" +
|
"lanzoub|" +
|
||||||
"lanzouu|" +
|
"lanzouu|" +
|
||||||
"lanwp|" +
|
"lanwp|" +
|
||||||
"lanzouy|" +
|
"lanzouy|" +
|
||||||
"lanzoup|" +
|
"lanzoup|" +
|
||||||
"woozooo|" +
|
"woozooo|" +
|
||||||
"lanzv|" +
|
"lanzv|" +
|
||||||
"dmpdmp|" +
|
"dmpdmp|" +
|
||||||
"lanrar|" +
|
"lanrar|" +
|
||||||
"webgetstore|" +
|
"webgetstore|" +
|
||||||
"lanzb|" +
|
"lanzb|" +
|
||||||
"lanzoux|" +
|
"lanzoux|" +
|
||||||
"lanzout|" +
|
"lanzout|" +
|
||||||
"lanzouc|" +
|
"lanzouc|" +
|
||||||
"lanzoui|" +
|
"lanzoui|" +
|
||||||
"lanzoug|" +
|
"lanzoug|" +
|
||||||
"lanzoum" +
|
"lanzoum)\\.com" +
|
||||||
")\\.com/(.+/)?(?<KEY>.+)"),
|
"|t-is\\.cn" +
|
||||||
|
")/(?<KEY>.+)"),
|
||||||
"https://w1.lanzn.com/{shareKey}",
|
"https://w1.lanzn.com/{shareKey}",
|
||||||
LzTool.class),
|
LzTool.class),
|
||||||
|
|
||||||
@@ -114,15 +115,15 @@ public enum PanDomainTemplate {
|
|||||||
"https://www.feijix.com/s/{shareKey}",
|
"https://www.feijix.com/s/{shareKey}",
|
||||||
FjTool.class),
|
FjTool.class),
|
||||||
|
|
||||||
// https://lecloud.lenovo.com/share/
|
// https://lecloud.lenovo.com/share/ https://lecloud.lenovo.com/mshare/
|
||||||
LE("联想乐云",
|
LE("联想乐云",
|
||||||
compile("https://lecloud?\\.lenovo\\.com/share/(?<KEY>.+)"),
|
compile("https://lecloud\\.lenovo\\.com/m?share/(?<KEY>.+)"),
|
||||||
"https://lecloud.lenovo.com/share/{shareKey}",
|
"https://lecloud.lenovo.com/share/{shareKey}",
|
||||||
LeTool.class),
|
LeTool.class),
|
||||||
|
|
||||||
// https://v2.fangcloud.com/s/
|
// https://v2.fangcloud.com/s/
|
||||||
FC("亿方云",
|
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://v2.fangcloud.com/s/{shareKey}",
|
||||||
"https://www.fangcloud.com/",
|
"https://www.fangcloud.com/",
|
||||||
FcTool.class),
|
FcTool.class),
|
||||||
@@ -143,9 +144,41 @@ public enum PanDomainTemplate {
|
|||||||
compile("https://qfile\\.qq\\.com/q/(?<KEY>.+)"),
|
compile("https://qfile\\.qq\\.com/q/(?<KEY>.+)"),
|
||||||
"https://qfile.qq.com/q/{shareKey}",
|
"https://qfile.qq.com/q/{shareKey}",
|
||||||
QQscTool.class),
|
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("文叔叔",
|
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}",
|
"https://www.wenshushu.cn/f/{shareKey}",
|
||||||
WsTool.class),
|
WsTool.class),
|
||||||
// https://www.123pan.com/s/
|
// https://www.123pan.com/s/
|
||||||
@@ -175,7 +208,7 @@ public enum PanDomainTemplate {
|
|||||||
123795.com
|
123795.com
|
||||||
*/
|
*/
|
||||||
YE("123网盘",
|
YE("123网盘",
|
||||||
compile("https://www\\.(" +
|
compile("https://(?:[a-zA-Z\\d-]+\\.)*(" +
|
||||||
"123254\\.com|" +
|
"123254\\.com|" +
|
||||||
"123957\\.com|" +
|
"123957\\.com|" +
|
||||||
"123295\\.com|" +
|
"123295\\.com|" +
|
||||||
@@ -199,7 +232,7 @@ public enum PanDomainTemplate {
|
|||||||
"123635\\.com|" +
|
"123635\\.com|" +
|
||||||
"123242\\.com|" +
|
"123242\\.com|" +
|
||||||
"123795\\.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}",
|
"https://www.123pan.com/s/{shareKey}",
|
||||||
Ye2Tool.class),
|
Ye2Tool.class),
|
||||||
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
|
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
|
||||||
@@ -210,19 +243,20 @@ public enum PanDomainTemplate {
|
|||||||
EcTool.class),
|
EcTool.class),
|
||||||
// https://cowtransfer.com/s/
|
// https://cowtransfer.com/s/
|
||||||
COW("奶牛快传",
|
COW("奶牛快传",
|
||||||
compile("https://(.*)cowtransfer\\.com/s/(?<KEY>.+)"),
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?cowtransfer\\.com/s/(?<KEY>.+)"),
|
||||||
"https://cowtransfer.com/s/{shareKey}",
|
"https://cowtransfer.com/s/{shareKey}",
|
||||||
CowTool.class),
|
CowTool.class),
|
||||||
CT("城通网盘",
|
CT("城通网盘",
|
||||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
|
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
|
||||||
"(?<KEY>[0-9a-zA-Z_-]+)(\\?p=(?<PWD>\\w+))?"),
|
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
|
||||||
"https://474b.com/file/{shareKey}",
|
"https://ctfile.com/file/{shareKey}",
|
||||||
|
CtTool.class),
|
||||||
|
// https://url94.ctfile.com/d/64115194-164803691-48508c?p=7609&d=164803691&fk=decb36
|
||||||
|
CTD("城通网盘-目录",
|
||||||
|
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/d/" +
|
||||||
|
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
|
||||||
|
"https://ctfile.com/d/{shareKey}",
|
||||||
CtTool.class),
|
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?password=I4KG7H
|
||||||
// https://www.vyuyun.com/s/QMa6ie/file?password=I4KG7H
|
// https://www.vyuyun.com/s/QMa6ie/file?password=I4KG7H
|
||||||
PVYY("微雨云存储",
|
PVYY("微雨云存储",
|
||||||
@@ -238,7 +272,7 @@ public enum PanDomainTemplate {
|
|||||||
PodTool.class),
|
PodTool.class),
|
||||||
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
|
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
|
||||||
PGD("GoogleDrive",
|
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",
|
"https://drive.google.com/file/d/{shareKey}/view?usp=sharing",
|
||||||
PgdTool.class),
|
PgdTool.class),
|
||||||
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
|
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
|
||||||
@@ -248,11 +282,11 @@ public enum PanDomainTemplate {
|
|||||||
PicTool.class),
|
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
|
// 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",
|
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",
|
"https://www.dropbox.com/scl/fi/{shareKey}/?rlkey={pwd}&dl=0",
|
||||||
PdbTool.class),
|
PdbTool.class),
|
||||||
P115("115网盘",
|
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}",
|
"https://115.com/s/{shareKey}?password={pwd}",
|
||||||
P115Tool.class),
|
P115Tool.class),
|
||||||
// 链接:https://www.yunpan.com/surl_yD7wz4VgU9v(提取码:fc70)
|
// 链接:https://www.yunpan.com/surl_yD7wz4VgU9v(提取码:fc70)
|
||||||
@@ -285,6 +319,14 @@ public enum PanDomainTemplate {
|
|||||||
"https://pan.quark.cn/s/{shareKey}",
|
"https://pan.quark.cn/s/{shareKey}",
|
||||||
QkTool.class),
|
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 (单歌曲/普通音质)==========================
|
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
|
||||||
// http://163cn.tv/xxx
|
// http://163cn.tv/xxx
|
||||||
MNES("网易云音乐分享",
|
MNES("网易云音乐分享",
|
||||||
@@ -293,7 +335,7 @@ public enum PanDomainTemplate {
|
|||||||
MnesTool.class),
|
MnesTool.class),
|
||||||
// https://music.163.com/#/song?id=xxx
|
// https://music.163.com/#/song?id=xxx
|
||||||
MNE("网易云音乐歌曲详情",
|
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}",
|
"https://music.163.com/#/song?id={shareKey}",
|
||||||
MnesTool.MneTool.class),
|
MnesTool.MneTool.class),
|
||||||
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
|
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
|
||||||
@@ -314,7 +356,7 @@ public enum PanDomainTemplate {
|
|||||||
MkgsTool.class),
|
MkgsTool.class),
|
||||||
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
|
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
|
||||||
MKGS2("酷狗音乐分享2",
|
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",
|
"https://www.kugou.com/share/{shareKey}.html",
|
||||||
MkgsTool.Mkgs2Tool.class),
|
MkgsTool.Mkgs2Tool.class),
|
||||||
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
|
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import org.apache.commons.lang3.StringUtils;
|
|||||||
|
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
|
|
||||||
import static cn.qaiu.parser.PanDomainTemplate.KEY;
|
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
|
* Create at 2024/9/15 14:10
|
||||||
*/
|
*/
|
||||||
public class ParserCreate {
|
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 PanDomainTemplate panDomainTemplate;
|
||||||
private final ShareLinkInfo shareLinkInfo;
|
private final ShareLinkInfo shareLinkInfo;
|
||||||
|
|
||||||
@@ -81,16 +86,16 @@ public class ParserCreate {
|
|||||||
if (shareKey != null) {
|
if (shareKey != null) {
|
||||||
shareLinkInfo.setShareKey(shareKey);
|
shareLinkInfo.setShareKey(shareKey);
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||||
|
|
||||||
// 提取密码
|
// 提取密码
|
||||||
try {
|
try {
|
||||||
String pwd = matcher.group("PWD");
|
String pwd = matcher.group("PWD");
|
||||||
if (StringUtils.isNotEmpty(pwd)) {
|
if (StringUtils.isNotEmpty(pwd)) {
|
||||||
shareLinkInfo.setSharePassword(pwd);
|
shareLinkInfo.setSharePassword(pwd);
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||||
|
|
||||||
// 设置标准URL
|
// 设置标准URL
|
||||||
if (customParserConfig.getStandardUrlTemplate() != null) {
|
if (customParserConfig.getStandardUrlTemplate() != null) {
|
||||||
String standardUrl = customParserConfig.getStandardUrlTemplate()
|
String standardUrl = customParserConfig.getStandardUrlTemplate()
|
||||||
@@ -132,12 +137,12 @@ public class ParserCreate {
|
|||||||
if (StringUtils.isNotEmpty(pwd)) {
|
if (StringUtils.isNotEmpty(pwd)) {
|
||||||
shareLinkInfo.setSharePassword(pwd);
|
shareLinkInfo.setSharePassword(pwd);
|
||||||
}
|
}
|
||||||
standardUrl = standardUrl.replace("{pwd}", pwd);
|
standardUrl = standardUrl.replace("{pwd}", StringUtils.defaultString(pwd));
|
||||||
} catch (Exception ignored) {}
|
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||||
|
|
||||||
shareLinkInfo.setShareUrl(shareUrl);
|
shareLinkInfo.setShareUrl(shareUrl);
|
||||||
shareLinkInfo.setShareKey(shareKey);
|
shareLinkInfo.setShareKey(shareKey);
|
||||||
if (!(panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal())) {
|
if (!isGenericBuiltInParser(panDomainTemplate)) {
|
||||||
shareLinkInfo.setStandardUrl(standardUrl);
|
shareLinkInfo.setStandardUrl(standardUrl);
|
||||||
}
|
}
|
||||||
return this;
|
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 _ -> /
|
// 处理Cloudreve(ce)类: pan.huang1111.cn_s_wDz5TK _ -> /
|
||||||
String[] s = shareKey.split("_");
|
String[] s = shareKey.split("_");
|
||||||
String standardUrl = "https://" + String.join("/", s);
|
String standardUrl = "https://" + String.join("/", s);
|
||||||
@@ -247,9 +252,19 @@ public class ParserCreate {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据分享链接获取PanDomainTemplate实例(优先匹配自定义解析器)
|
// 根据分享链接获取PanDomainTemplate实例
|
||||||
public synchronized static ParserCreate fromShareUrl(String shareUrl) {
|
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()) {
|
for (CustomParserConfig customConfig : CustomParserRegistry.getAll().values()) {
|
||||||
if (customConfig.supportsFromShareUrl()) {
|
if (customConfig.supportsFromShareUrl()) {
|
||||||
Matcher matcher = customConfig.getMatchPattern().matcher(shareUrl);
|
Matcher matcher = customConfig.getMatchPattern().matcher(shareUrl);
|
||||||
@@ -266,15 +281,15 @@ public class ParserCreate {
|
|||||||
if (shareKey != null) {
|
if (shareKey != null) {
|
||||||
shareLinkInfo.setShareKey(shareKey);
|
shareLinkInfo.setShareKey(shareKey);
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String password = matcher.group("PWD");
|
String password = matcher.group("PWD");
|
||||||
if (password != null) {
|
if (password != null) {
|
||||||
shareLinkInfo.setSharePassword(password);
|
shareLinkInfo.setSharePassword(password);
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (IllegalStateException | IllegalArgumentException ignored) {}
|
||||||
|
|
||||||
// 设置标准URL(如果有模板)
|
// 设置标准URL(如果有模板)
|
||||||
if (customConfig.getStandardUrlTemplate() != null) {
|
if (customConfig.getStandardUrlTemplate() != null) {
|
||||||
String standardUrl = customConfig.getStandardUrlTemplate()
|
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()) {
|
for (PanDomainTemplate panDomainTemplate : PanDomainTemplate.values()) {
|
||||||
|
boolean genericParser = isGenericBuiltInParser(panDomainTemplate);
|
||||||
|
if (genericOnly != genericParser) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (panDomainTemplate.getPattern().matcher(shareUrl).matches()) {
|
if (panDomainTemplate.getPattern().matcher(shareUrl).matches()) {
|
||||||
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
|
||||||
.type(panDomainTemplate.name().toLowerCase())
|
.type(panDomainTemplate.name().toLowerCase())
|
||||||
.panName(panDomainTemplate.getDisplayName())
|
.panName(panDomainTemplate.getDisplayName())
|
||||||
.shareUrl(shareUrl).build();
|
.shareUrl(shareUrl).build();
|
||||||
if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
|
if (isGenericBuiltInParser(panDomainTemplate)) {
|
||||||
shareLinkInfo.setStandardUrl(shareUrl);
|
shareLinkInfo.setStandardUrl(shareUrl);
|
||||||
}
|
}
|
||||||
ParserCreate parserCreate = new ParserCreate(panDomainTemplate, shareLinkInfo);
|
ParserCreate parserCreate = new ParserCreate(panDomainTemplate, shareLinkInfo);
|
||||||
return parserCreate.normalizeShareLink();
|
return parserCreate.normalizeShareLink();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new IllegalArgumentException("Unsupported share URL");
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据type获取枚举实例(优先查找自定义解析器)
|
// 根据type获取枚举实例(优先查找自定义解析器)
|
||||||
@@ -353,7 +381,7 @@ public class ParserCreate {
|
|||||||
// 自定义解析器处理
|
// 自定义解析器处理
|
||||||
if (isCustomParser) {
|
if (isCustomParser) {
|
||||||
path = this.shareLinkInfo.getType() + "/" + this.shareLinkInfo.getShareKey();
|
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 _ -> /
|
// 处理Cloudreve(ce)类: pan.huang1111.cn_s_wDz5TK _ -> /
|
||||||
path = this.shareLinkInfo.getType() + "/"
|
path = this.shareLinkInfo.getType() + "/"
|
||||||
+ this.shareLinkInfo.getShareUrl()
|
+ this.shareLinkInfo.getShareUrl()
|
||||||
@@ -381,7 +409,11 @@ public class ParserCreate {
|
|||||||
public CustomParserConfig getCustomParserConfig() {
|
public CustomParserConfig getCustomParserConfig() {
|
||||||
return customParserConfig;
|
return customParserConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isGenericBuiltInParser(PanDomainTemplate panDomainTemplate) {
|
||||||
|
return GENERIC_BUILT_IN_PARSERS.contains(panDomainTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取内置解析器模板(仅当isCustomParser为false时有效)
|
* 获取内置解析器模板(仅当isCustomParser为false时有效)
|
||||||
* @return 内置解析器模板,如果是自定义解析器则返回null
|
* @return 内置解析器模板,如果是自定义解析器则返回null
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package cn.qaiu.parser;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser token cache keyed by parser type and account identity.
|
||||||
|
*/
|
||||||
|
public final class TokenCache {
|
||||||
|
|
||||||
|
private static final Map<String, String> TOKENS = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<String, Long> EXPIRES = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private TokenCache() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String key(String type, String accountId) {
|
||||||
|
return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void putToken(String key, String token) {
|
||||||
|
if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TOKENS.put(key, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getToken(String key) {
|
||||||
|
if (StringUtils.isBlank(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isExpired(key)) {
|
||||||
|
TOKENS.remove(key);
|
||||||
|
EXPIRES.remove(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return TOKENS.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void putExpire(String key, long expireTimeMillis) {
|
||||||
|
if (StringUtils.isBlank(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EXPIRES.put(key, expireTimeMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isExpired(String key) {
|
||||||
|
Long expireTimeMillis = EXPIRES.get(key);
|
||||||
|
return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
public class CustomParserRegistry {
|
public class CustomParserRegistry {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(CustomParserRegistry.class);
|
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为配置对象
|
* 存储自定义解析器配置的Map,key为类型标识,value为配置对象
|
||||||
@@ -33,7 +34,7 @@ public class CustomParserRegistry {
|
|||||||
* @param config 解析器配置
|
* @param config 解析器配置
|
||||||
* @throws IllegalArgumentException 如果type已存在或与内置解析器冲突
|
* @throws IllegalArgumentException 如果type已存在或与内置解析器冲突
|
||||||
*/
|
*/
|
||||||
public static void register(CustomParserConfig config) {
|
public static synchronized void register(CustomParserConfig config) {
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
throw new IllegalArgumentException("config不能为空");
|
throw new IllegalArgumentException("config不能为空");
|
||||||
}
|
}
|
||||||
@@ -59,6 +60,11 @@ public class CustomParserRegistry {
|
|||||||
"类型标识 '" + type + "' 已被注册,请先注销或使用其他标识"
|
"类型标识 '" + type + "' 已被注册,请先注销或使用其他标识"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (CUSTOM_PARSERS.size() >= MAX_CUSTOM_PARSERS) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"自定义解析器数量已达到上限(" + MAX_CUSTOM_PARSERS + "个),请先注销不需要的解析器"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
CUSTOM_PARSERS.put(type, config);
|
CUSTOM_PARSERS.put(type, config);
|
||||||
log.info("注册自定义解析器成功: {} ({})", config.getDisplayName(), type);
|
log.info("注册自定义解析器成功: {} ({})", config.getDisplayName(), type);
|
||||||
@@ -171,7 +177,7 @@ public class CustomParserRegistry {
|
|||||||
* @param type 解析器类型标识
|
* @param type 解析器类型标识
|
||||||
* @return 是否注销成功
|
* @return 是否注销成功
|
||||||
*/
|
*/
|
||||||
public static boolean unregister(String type) {
|
public static synchronized boolean unregister(String type) {
|
||||||
if (type == null || type.trim().isEmpty()) {
|
if (type == null || type.trim().isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -213,7 +219,7 @@ public class CustomParserRegistry {
|
|||||||
/**
|
/**
|
||||||
* 清空所有自定义解析器
|
* 清空所有自定义解析器
|
||||||
*/
|
*/
|
||||||
public static void clear() {
|
public static synchronized void clear() {
|
||||||
CUSTOM_PARSERS.clear();
|
CUSTOM_PARSERS.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,21 @@ package cn.qaiu.parser.customjs;
|
|||||||
|
|
||||||
import cn.qaiu.WebClientVertxInit;
|
import cn.qaiu.WebClientVertxInit;
|
||||||
import cn.qaiu.util.HttpResponseHelper;
|
import cn.qaiu.util.HttpResponseHelper;
|
||||||
import io.vertx.core.Future;
|
|
||||||
import io.vertx.core.MultiMap;
|
import io.vertx.core.MultiMap;
|
||||||
import io.vertx.core.Promise;
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.buffer.Buffer;
|
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.json.JsonObject;
|
||||||
import io.vertx.core.net.ProxyOptions;
|
import io.vertx.core.net.ProxyOptions;
|
||||||
import io.vertx.core.net.ProxyType;
|
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.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.apache.commons.lang3.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -27,8 +29,13 @@ import java.net.UnknownHostException;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
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.TimeUnit;
|
||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,11 +46,60 @@ import java.util.regex.Pattern;
|
|||||||
* Create at 2025/10/17
|
* Create at 2025/10/17
|
||||||
*/
|
*/
|
||||||
public class JsHttpClient {
|
public class JsHttpClient {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(JsHttpClient.class);
|
private static final Logger log = LoggerFactory.getLogger(JsHttpClient.class);
|
||||||
|
private static final int MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
|
||||||
private final WebClient client;
|
private static final int MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
|
||||||
private final WebClientSession clientSession;
|
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 MultiMap headers;
|
||||||
private int timeoutSeconds = 30; // 默认超时时间30秒
|
private int timeoutSeconds = 30; // 默认超时时间30秒
|
||||||
|
|
||||||
@@ -61,11 +117,12 @@ public class JsHttpClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public JsHttpClient() {
|
public JsHttpClient() {
|
||||||
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());;
|
ensureSharedClientAvailable();
|
||||||
this.clientSession = WebClientSession.create(client);
|
this.client = sharedClient();
|
||||||
|
this.ownClient = false;
|
||||||
this.headers = MultiMap.caseInsensitiveMultiMap();
|
this.headers = MultiMap.caseInsensitiveMultiMap();
|
||||||
// 设置默认的Accept-Encoding头以支持压缩响应
|
// 设置默认的Accept-Encoding头以支持压缩响应
|
||||||
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
|
||||||
// 设置默认的User-Agent头
|
// 设置默认的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");
|
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头
|
// 设置默认的Accept-Language头
|
||||||
@@ -77,31 +134,35 @@ public class JsHttpClient {
|
|||||||
* @param proxyConfig 代理配置JsonObject,包含type、host、port、username、password
|
* @param proxyConfig 代理配置JsonObject,包含type、host、port、username、password
|
||||||
*/
|
*/
|
||||||
public JsHttpClient(JsonObject proxyConfig) {
|
public JsHttpClient(JsonObject proxyConfig) {
|
||||||
|
ensureSharedClientAvailable();
|
||||||
if (proxyConfig != null && proxyConfig.containsKey("type")) {
|
if (proxyConfig != null && proxyConfig.containsKey("type")) {
|
||||||
ProxyOptions proxyOptions = new ProxyOptions()
|
ProxyOptions proxyOptions = new ProxyOptions()
|
||||||
.setType(ProxyType.valueOf(proxyConfig.getString("type").toUpperCase()))
|
.setType(ProxyType.valueOf(proxyConfig.getString("type").toUpperCase()))
|
||||||
.setHost(proxyConfig.getString("host"))
|
.setHost(proxyConfig.getString("host"))
|
||||||
.setPort(proxyConfig.getInteger("port"));
|
.setPort(proxyConfig.getInteger("port"));
|
||||||
|
|
||||||
if (StringUtils.isNotEmpty(proxyConfig.getString("username"))) {
|
if (StringUtils.isNotEmpty(proxyConfig.getString("username"))) {
|
||||||
proxyOptions.setUsername(proxyConfig.getString("username"));
|
proxyOptions.setUsername(proxyConfig.getString("username"));
|
||||||
}
|
}
|
||||||
if (StringUtils.isNotEmpty(proxyConfig.getString("password"))) {
|
if (StringUtils.isNotEmpty(proxyConfig.getString("password"))) {
|
||||||
proxyOptions.setPassword(proxyConfig.getString("password"));
|
proxyOptions.setPassword(proxyConfig.getString("password"));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.client = WebClient.create(WebClientVertxInit.get(),
|
this.client = WebClientVertxInit.get().createHttpClient(
|
||||||
new WebClientOptions()
|
new HttpClientOptions()
|
||||||
.setUserAgentEnabled(false)
|
.setConnectTimeout(10000)
|
||||||
|
.setIdleTimeout(30)
|
||||||
|
.setIdleTimeoutUnit(TimeUnit.SECONDS)
|
||||||
|
.setMaxPoolSize(16)
|
||||||
.setProxyOptions(proxyOptions));
|
.setProxyOptions(proxyOptions));
|
||||||
this.clientSession = WebClientSession.create(client);
|
this.ownClient = true;
|
||||||
} else {
|
} else {
|
||||||
this.client = WebClient.create(WebClientVertxInit.get());
|
this.client = sharedClient();
|
||||||
this.clientSession = WebClientSession.create(client);
|
this.ownClient = false;
|
||||||
}
|
}
|
||||||
this.headers = MultiMap.caseInsensitiveMultiMap();
|
this.headers = MultiMap.caseInsensitiveMultiMap();
|
||||||
// 设置默认的Accept-Encoding头以支持压缩响应
|
// 设置默认的Accept-Encoding头以支持压缩响应
|
||||||
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
|
||||||
// 设置默认的User-Agent头
|
// 设置默认的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");
|
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头
|
// 设置默认的Accept-Language头
|
||||||
@@ -183,13 +244,7 @@ public class JsHttpClient {
|
|||||||
*/
|
*/
|
||||||
public JsHttpResponse get(String url) {
|
public JsHttpResponse get(String url) {
|
||||||
validateUrlSecurity(url);
|
validateUrlSecurity(url);
|
||||||
return executeRequest(() -> {
|
return executeRequest(HttpMethod.GET, url, null, false);
|
||||||
HttpRequest<Buffer> request = client.getAbs(url);
|
|
||||||
if (!headers.isEmpty()) {
|
|
||||||
request.putHeaders(headers);
|
|
||||||
}
|
|
||||||
return request.send();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,16 +253,25 @@ public class JsHttpClient {
|
|||||||
* @return HTTP响应
|
* @return HTTP响应
|
||||||
*/
|
*/
|
||||||
public JsHttpResponse getWithRedirect(String url) {
|
public JsHttpResponse getWithRedirect(String url) {
|
||||||
validateUrlSecurity(url);
|
String currentUrl = url;
|
||||||
return executeRequest(() -> {
|
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
||||||
HttpRequest<Buffer> request = client.getAbs(url);
|
validateUrlSecurity(currentUrl);
|
||||||
if (!headers.isEmpty()) {
|
JsHttpResponse response = executeRequest(HttpMethod.GET, currentUrl, null, false);
|
||||||
request.putHeaders(headers);
|
if (!isRedirectStatus(response.statusCode())) {
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
// 设置跟随重定向
|
|
||||||
request.followRedirects(true);
|
if (redirectCount == MAX_REDIRECTS) {
|
||||||
return request.send();
|
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) {
|
public JsHttpResponse getNoRedirect(String url) {
|
||||||
validateUrlSecurity(url);
|
validateUrlSecurity(url);
|
||||||
return executeRequest(() -> {
|
return executeRequest(HttpMethod.GET, url, null, false);
|
||||||
HttpRequest<Buffer> request = client.getAbs(url);
|
|
||||||
if (!headers.isEmpty()) {
|
|
||||||
request.putHeaders(headers);
|
|
||||||
}
|
|
||||||
// 设置不跟随重定向
|
|
||||||
request.followRedirects(false);
|
|
||||||
return request.send();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,26 +292,7 @@ public class JsHttpClient {
|
|||||||
*/
|
*/
|
||||||
public JsHttpResponse post(String url, Object data) {
|
public JsHttpResponse post(String url, Object data) {
|
||||||
validateUrlSecurity(url);
|
validateUrlSecurity(url);
|
||||||
return executeRequest(() -> {
|
return executeRequest(HttpMethod.POST, url, bodyFromData(data), false);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,26 +303,7 @@ public class JsHttpClient {
|
|||||||
*/
|
*/
|
||||||
public JsHttpResponse put(String url, Object data) {
|
public JsHttpResponse put(String url, Object data) {
|
||||||
validateUrlSecurity(url);
|
validateUrlSecurity(url);
|
||||||
return executeRequest(() -> {
|
return executeRequest(HttpMethod.PUT, url, bodyFromData(data), false);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -294,13 +312,8 @@ public class JsHttpClient {
|
|||||||
* @return HTTP响应
|
* @return HTTP响应
|
||||||
*/
|
*/
|
||||||
public JsHttpResponse delete(String url) {
|
public JsHttpResponse delete(String url) {
|
||||||
return executeRequest(() -> {
|
validateUrlSecurity(url);
|
||||||
HttpRequest<Buffer> request = client.deleteAbs(url);
|
return executeRequest(HttpMethod.DELETE, url, null, false);
|
||||||
if (!headers.isEmpty()) {
|
|
||||||
request.putHeaders(headers);
|
|
||||||
}
|
|
||||||
return request.send();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -310,26 +323,8 @@ public class JsHttpClient {
|
|||||||
* @return HTTP响应
|
* @return HTTP响应
|
||||||
*/
|
*/
|
||||||
public JsHttpResponse patch(String url, Object data) {
|
public JsHttpResponse patch(String url, Object data) {
|
||||||
return executeRequest(() -> {
|
validateUrlSecurity(url);
|
||||||
HttpRequest<Buffer> request = client.patchAbs(url);
|
return executeRequest(HttpMethod.PATCH, url, bodyFromData(data), false);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,6 +335,12 @@ public class JsHttpClient {
|
|||||||
*/
|
*/
|
||||||
public JsHttpClient putHeader(String name, String value) {
|
public JsHttpClient putHeader(String name, String value) {
|
||||||
if (name != null && value != null) {
|
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);
|
headers.set(name, value);
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
@@ -353,9 +354,7 @@ public class JsHttpClient {
|
|||||||
public JsHttpClient putHeaders(Map<String, String> headersMap) {
|
public JsHttpClient putHeaders(Map<String, String> headersMap) {
|
||||||
if (headersMap != null) {
|
if (headersMap != null) {
|
||||||
for (Map.Entry<String, String> entry : headersMap.entrySet()) {
|
for (Map.Entry<String, String> entry : headersMap.entrySet()) {
|
||||||
if (entry.getKey() != null && entry.getValue() != null) {
|
putHeader(entry.getKey(), entry.getValue());
|
||||||
headers.set(entry.getKey(), entry.getValue());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
@@ -380,7 +379,7 @@ public class JsHttpClient {
|
|||||||
public JsHttpClient clearHeaders() {
|
public JsHttpClient clearHeaders() {
|
||||||
headers.clear();
|
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("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");
|
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;
|
return this;
|
||||||
@@ -405,7 +404,7 @@ public class JsHttpClient {
|
|||||||
*/
|
*/
|
||||||
public JsHttpClient setTimeout(int seconds) {
|
public JsHttpClient setTimeout(int seconds) {
|
||||||
if (seconds > 0) {
|
if (seconds > 0) {
|
||||||
this.timeoutSeconds = seconds;
|
this.timeoutSeconds = Math.min(seconds, MAX_TIMEOUT_SECONDS);
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -450,19 +449,12 @@ public class JsHttpClient {
|
|||||||
* @return HTTP响应
|
* @return HTTP响应
|
||||||
*/
|
*/
|
||||||
public JsHttpResponse sendForm(Map<String, String> data) {
|
public JsHttpResponse sendForm(Map<String, String> data) {
|
||||||
return executeRequest(() -> {
|
throw new IllegalArgumentException("sendForm(data) 缺少请求URL,请使用 post(url, data)");
|
||||||
HttpRequest<Buffer> request = client.postAbs("");
|
}
|
||||||
if (!headers.isEmpty()) {
|
|
||||||
request.putHeaders(headers);
|
public JsHttpResponse sendForm(String url, Map<String, String> data) {
|
||||||
}
|
validateUrlSecurity(url);
|
||||||
|
return executeRequest(HttpMethod.POST, url, formBody(data), false);
|
||||||
MultiMap formData = MultiMap.caseInsensitiveMultiMap();
|
|
||||||
if (data != null) {
|
|
||||||
formData.addAll(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return request.sendForm(formData);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -474,34 +466,8 @@ public class JsHttpClient {
|
|||||||
* @return HTTP响应
|
* @return HTTP响应
|
||||||
*/
|
*/
|
||||||
public JsHttpResponse sendMultipartForm(String url, Map<String, Object> data) {
|
public JsHttpResponse sendMultipartForm(String url, Map<String, Object> data) {
|
||||||
return executeRequest(() -> {
|
validateUrlSecurity(url);
|
||||||
HttpRequest<Buffer> request = client.postAbs(url);
|
return executeRequest(HttpMethod.POST, url, multipartBody(data), false);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -510,44 +476,103 @@ public class JsHttpClient {
|
|||||||
* @return HTTP响应
|
* @return HTTP响应
|
||||||
*/
|
*/
|
||||||
public JsHttpResponse sendJson(Object data) {
|
public JsHttpResponse sendJson(Object data) {
|
||||||
return executeRequest(() -> {
|
throw new IllegalArgumentException("sendJson(data) 缺少请求URL,请使用 post(url, data)");
|
||||||
HttpRequest<Buffer> request = client.postAbs("");
|
}
|
||||||
if (!headers.isEmpty()) {
|
|
||||||
request.putHeaders(headers);
|
public JsHttpResponse sendJson(String url, Object data) {
|
||||||
}
|
validateUrlSecurity(url);
|
||||||
|
return executeRequest(HttpMethod.POST, url, jsonBody(data), false);
|
||||||
return request.sendJson(data);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行HTTP请求(同步)
|
* 执行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 {
|
try {
|
||||||
Promise<HttpResponse<Buffer>> promise = Promise.promise();
|
Promise<JsHttpResponse> promise = Promise.promise();
|
||||||
Future<HttpResponse<Buffer>> future = executor.execute();
|
|
||||||
|
RequestOptions options = new RequestOptions()
|
||||||
future.onComplete(result -> {
|
.setMethod(method)
|
||||||
if (result.succeeded()) {
|
.setAbsoluteURI(url)
|
||||||
promise.complete(result.result());
|
.setFollowRedirects(followRedirects)
|
||||||
} else {
|
.setTimeout(TimeUnit.SECONDS.toMillis(timeoutSeconds))
|
||||||
promise.fail(result.cause());
|
.setHeaders(MultiMap.caseInsensitiveMultiMap().setAll(headers));
|
||||||
|
|
||||||
|
client.request(options).onComplete(ar -> {
|
||||||
|
if (ar.failed()) {
|
||||||
|
promise.tryFail(ar.cause());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}).onFailure(Throwable::printStackTrace);
|
|
||||||
|
HttpClientRequest request = ar.result();
|
||||||
// 等待响应完成(使用配置的超时时间)
|
synchronized (requestLock) {
|
||||||
HttpResponse<Buffer> response = promise.future().toCompletionStage()
|
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()
|
.toCompletableFuture()
|
||||||
.get(timeoutSeconds, TimeUnit.SECONDS);
|
.get(timeoutSeconds, TimeUnit.SECONDS);
|
||||||
|
|
||||||
return new JsHttpResponse(response);
|
|
||||||
|
|
||||||
} catch (TimeoutException e) {
|
} catch (TimeoutException e) {
|
||||||
|
// RequestOptions timeout 通常会先触发;这里再兜底,避免等待线程返回后请求还在后台下载。
|
||||||
String errorMsg = "HTTP请求超时(" + timeoutSeconds + "秒)";
|
String errorMsg = "HTTP请求超时(" + timeoutSeconds + "秒)";
|
||||||
|
abandoned.set(true);
|
||||||
|
synchronized (requestLock) {
|
||||||
|
abortRequest(requestRef);
|
||||||
|
}
|
||||||
log.error(errorMsg, e);
|
log.error(errorMsg, e);
|
||||||
throw new RuntimeException(errorMsg, e);
|
throw new RuntimeException(errorMsg, e);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
abandoned.set(true);
|
||||||
|
synchronized (requestLock) {
|
||||||
|
abortRequest(requestRef);
|
||||||
|
}
|
||||||
String errorMsg = e.getMessage();
|
String errorMsg = e.getMessage();
|
||||||
if (errorMsg == null || errorMsg.trim().isEmpty()) {
|
if (errorMsg == null || errorMsg.trim().isEmpty()) {
|
||||||
errorMsg = e.getClass().getSimpleName();
|
errorMsg = e.getClass().getSimpleName();
|
||||||
@@ -559,13 +584,196 @@ public class JsHttpClient {
|
|||||||
throw new RuntimeException("HTTP请求执行失败: " + errorMsg, e);
|
throw new RuntimeException("HTTP请求执行失败: " + errorMsg, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static boolean isRedirectStatus(int statusCode) {
|
||||||
* 请求执行器接口
|
return statusCode == 301 || statusCode == 302 || statusCode == 303
|
||||||
*/
|
|| statusCode == 307 || statusCode == 308;
|
||||||
@FunctionalInterface
|
}
|
||||||
private interface RequestExecutor {
|
|
||||||
Future<HttpResponse<Buffer>> execute();
|
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 {
|
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) {
|
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 响应体字符串
|
* @return 响应体字符串
|
||||||
*/
|
*/
|
||||||
public String body() {
|
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() {
|
public Object json() {
|
||||||
try {
|
try {
|
||||||
JsonObject jsonObject = HttpResponseHelper.asJson(response);
|
JsonObject jsonObject = HttpResponseHelper.asJson(body, header(HttpHeaders.CONTENT_ENCODING.toString()));
|
||||||
if (jsonObject == null || jsonObject.isEmpty()) {
|
if (jsonObject == null || jsonObject.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -611,7 +838,7 @@ public class JsHttpClient {
|
|||||||
* @return 状态码
|
* @return 状态码
|
||||||
*/
|
*/
|
||||||
public int statusCode() {
|
public int statusCode() {
|
||||||
return response.statusCode();
|
return statusCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -620,7 +847,7 @@ public class JsHttpClient {
|
|||||||
* @return 头值
|
* @return 头值
|
||||||
*/
|
*/
|
||||||
public String header(String name) {
|
public String header(String name) {
|
||||||
return response.getHeader(name);
|
return headers.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -628,10 +855,9 @@ public class JsHttpClient {
|
|||||||
* @return 响应头Map
|
* @return 响应头Map
|
||||||
*/
|
*/
|
||||||
public Map<String, String> headers() {
|
public Map<String, String> headers() {
|
||||||
MultiMap responseHeaders = response.headers();
|
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
for (String name : responseHeaders.names()) {
|
for (String name : headers.names()) {
|
||||||
result.put(name, responseHeaders.get(name));
|
result.put(name, headers.get(name));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -649,8 +875,14 @@ public class JsHttpClient {
|
|||||||
* 获取原始响应对象
|
* 获取原始响应对象
|
||||||
* @return HttpResponse对象
|
* @return HttpResponse对象
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
public HttpResponse<Buffer> getOriginalResponse() {
|
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 响应体字节数组
|
* @return 响应体字节数组
|
||||||
*/
|
*/
|
||||||
public byte[] bodyBytes() {
|
public byte[] bodyBytes() {
|
||||||
Buffer buffer = response.body();
|
ensureResponseBodyLimit(body);
|
||||||
if (buffer == null) {
|
return body.getBytes();
|
||||||
return new byte[0];
|
|
||||||
}
|
|
||||||
return buffer.getBytes();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -670,11 +899,46 @@ public class JsHttpClient {
|
|||||||
* @return 响应体大小(字节)
|
* @return 响应体大小(字节)
|
||||||
*/
|
*/
|
||||||
public long bodySize() {
|
public long bodySize() {
|
||||||
Buffer buffer = response.body();
|
return body.length();
|
||||||
if (buffer == null) {
|
}
|
||||||
return 0;
|
|
||||||
|
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.IPanTool;
|
||||||
import cn.qaiu.parser.custom.CustomParserConfig;
|
import cn.qaiu.parser.custom.CustomParserConfig;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.WorkerExecutor;
|
import io.vertx.core.WorkerExecutor;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory;
|
import org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory;
|
||||||
@@ -20,6 +21,13 @@ import java.io.InputStreamReader;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,21 +37,46 @@ import java.util.stream.Collectors;
|
|||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
* Create at 2025/10/17
|
* 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 Logger log = LoggerFactory.getLogger(JsParserExecutor.class);
|
||||||
|
|
||||||
private static final WorkerExecutor EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
|
private static volatile WorkerExecutor EXECUTOR;
|
||||||
|
private static final Object EXECUTOR_LOCK = new Object();
|
||||||
private static String FETCH_RUNTIME_JS = null;
|
private 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 CustomParserConfig config;
|
||||||
private final ShareLinkInfo shareLinkInfo;
|
private final ShareLinkInfo shareLinkInfo;
|
||||||
private final ScriptEngine engine;
|
private volatile ScriptEngine engine;
|
||||||
|
private final Object engineLock = new Object();
|
||||||
private final JsHttpClient httpClient;
|
private final JsHttpClient httpClient;
|
||||||
private final JsLogger jsLogger;
|
private final JsLogger jsLogger;
|
||||||
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
|
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
|
||||||
private final JsFetchBridge fetchBridge;
|
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) {
|
public JsParserExecutor(ShareLinkInfo shareLinkInfo, CustomParserConfig config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -59,7 +92,6 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
this.jsLogger = new JsLogger("JsParser-" + config.getType());
|
this.jsLogger = new JsLogger("JsParser-" + config.getType());
|
||||||
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
|
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
|
||||||
this.fetchBridge = new JsFetchBridge(httpClient);
|
this.fetchBridge = new JsFetchBridge(httpClient);
|
||||||
this.engine = initEngine();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,6 +143,7 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
if (engine == null) {
|
if (engine == null) {
|
||||||
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
|
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
|
||||||
}
|
}
|
||||||
|
this.engine = engine;
|
||||||
|
|
||||||
// 注入Java对象到JavaScript环境
|
// 注入Java对象到JavaScript环境
|
||||||
engine.put("http", httpClient);
|
engine.put("http", httpClient);
|
||||||
@@ -145,28 +178,207 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
throw new RuntimeException("JavaScript引擎初始化失败: " + e.getMessage(), e);
|
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
|
@Override
|
||||||
public Future<String> parse() {
|
public Future<String> parse() {
|
||||||
jsLogger.info("开始执行JavaScript解析器: {}", config.getType());
|
jsLogger.info("开始执行JavaScript解析器: {}", config.getType());
|
||||||
|
|
||||||
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
||||||
return EXECUTOR.executeBlocking(() -> {
|
Future<String> executionFuture = executeBlockingWithPermit("parse", () -> {
|
||||||
|
ScriptEngine engine = engine();
|
||||||
// 直接调用全局parse函数
|
// 直接调用全局parse函数
|
||||||
Object parseFunction = engine.get("parse");
|
Object parseFunction = engine.get("parse");
|
||||||
if (parseFunction == null) {
|
if (parseFunction == null) {
|
||||||
throw new RuntimeException("JavaScript代码中未找到parse函数");
|
throw new RuntimeException("JavaScript代码中未找到parse函数");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parseFunction instanceof ScriptObjectMirror parseMirror) {
|
if (parseFunction instanceof ScriptObjectMirror parseMirror) {
|
||||||
|
|
||||||
Object result = parseMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
Object result = parseMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
||||||
|
|
||||||
if (result instanceof String) {
|
if (result instanceof String) {
|
||||||
jsLogger.info("解析成功: {}", result);
|
String resultText = limitResultString((String) result, "parse");
|
||||||
return (String) result;
|
jsLogger.info("解析成功,结果长度: {}", resultText.length());
|
||||||
|
return resultText;
|
||||||
} else {
|
} else {
|
||||||
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
|
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
|
||||||
result != null ? result.getClass().getSimpleName() : "null");
|
result != null ? result.getClass().getSimpleName() : "null");
|
||||||
throw new RuntimeException("parse方法返回值类型错误");
|
throw new RuntimeException("parse方法返回值类型错误");
|
||||||
}
|
}
|
||||||
@@ -174,32 +386,34 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
throw new RuntimeException("parse函数类型错误");
|
throw new RuntimeException("parse函数类型错误");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return withTimeout(executionFuture, "parse");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Future<List<FileInfo>> parseFileList() {
|
public Future<List<FileInfo>> parseFileList() {
|
||||||
jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType());
|
jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType());
|
||||||
|
|
||||||
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
||||||
return EXECUTOR.executeBlocking(() -> {
|
Future<List<FileInfo>> executionFuture = executeBlockingWithPermit("parseFileList", () -> {
|
||||||
|
ScriptEngine engine = engine();
|
||||||
// 直接调用全局parseFileList函数
|
// 直接调用全局parseFileList函数
|
||||||
Object parseFileListFunction = engine.get("parseFileList");
|
Object parseFileListFunction = engine.get("parseFileList");
|
||||||
if (parseFileListFunction == null) {
|
if (parseFileListFunction == null) {
|
||||||
throw new RuntimeException("JavaScript代码中未找到parseFileList函数");
|
throw new RuntimeException("JavaScript代码中未找到parseFileList函数");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用parseFileList方法
|
// 调用parseFileList方法
|
||||||
if (parseFileListFunction instanceof ScriptObjectMirror parseFileListMirror) {
|
if (parseFileListFunction instanceof ScriptObjectMirror parseFileListMirror) {
|
||||||
|
|
||||||
Object result = parseFileListMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
Object result = parseFileListMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
||||||
|
|
||||||
if (result instanceof ScriptObjectMirror resultMirror) {
|
if (result instanceof ScriptObjectMirror resultMirror) {
|
||||||
List<FileInfo> fileList = convertToFileInfoList(resultMirror);
|
List<FileInfo> fileList = convertToFileInfoList(resultMirror);
|
||||||
|
|
||||||
jsLogger.info("文件列表解析成功,共 {} 个文件", fileList.size());
|
jsLogger.info("文件列表解析成功,共 {} 个文件", fileList.size());
|
||||||
return fileList;
|
return fileList;
|
||||||
} else {
|
} else {
|
||||||
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
|
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
|
||||||
result != null ? result.getClass().getSimpleName() : "null");
|
result != null ? result.getClass().getSimpleName() : "null");
|
||||||
throw new RuntimeException("parseFileList方法返回值类型错误");
|
throw new RuntimeException("parseFileList方法返回值类型错误");
|
||||||
}
|
}
|
||||||
@@ -207,30 +421,33 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
throw new RuntimeException("parseFileList函数类型错误");
|
throw new RuntimeException("parseFileList函数类型错误");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return withTimeout(executionFuture, "parseFileList");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Future<String> parseById() {
|
public Future<String> parseById() {
|
||||||
jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType());
|
jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType());
|
||||||
|
|
||||||
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
|
||||||
return EXECUTOR.executeBlocking(() -> {
|
Future<String> executionFuture = executeBlockingWithPermit("parseById", () -> {
|
||||||
|
ScriptEngine engine = engine();
|
||||||
// 直接调用全局parseById函数
|
// 直接调用全局parseById函数
|
||||||
Object parseByIdFunction = engine.get("parseById");
|
Object parseByIdFunction = engine.get("parseById");
|
||||||
if (parseByIdFunction == null) {
|
if (parseByIdFunction == null) {
|
||||||
throw new RuntimeException("JavaScript代码中未找到parseById函数");
|
throw new RuntimeException("JavaScript代码中未找到parseById函数");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用parseById方法
|
// 调用parseById方法
|
||||||
if (parseByIdFunction instanceof ScriptObjectMirror parseByIdMirror) {
|
if (parseByIdFunction instanceof ScriptObjectMirror parseByIdMirror) {
|
||||||
|
|
||||||
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
|
||||||
|
|
||||||
if (result instanceof String) {
|
if (result instanceof String) {
|
||||||
jsLogger.info("按ID解析成功: {}", result);
|
String resultText = limitResultString((String) result, "parseById");
|
||||||
return (String) result;
|
jsLogger.info("按ID解析成功,结果长度: {}", resultText.length());
|
||||||
|
return resultText;
|
||||||
} else {
|
} else {
|
||||||
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
|
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
|
||||||
result != null ? result.getClass().getSimpleName() : "null");
|
result != null ? result.getClass().getSimpleName() : "null");
|
||||||
throw new RuntimeException("parseById方法返回值类型错误");
|
throw new RuntimeException("parseById方法返回值类型错误");
|
||||||
}
|
}
|
||||||
@@ -238,6 +455,7 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
throw new RuntimeException("parseById函数类型错误");
|
throw new RuntimeException("parseById函数类型错误");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return withTimeout(executionFuture, "parseById");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -247,6 +465,9 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
List<FileInfo> fileList = new ArrayList<>();
|
List<FileInfo> fileList = new ArrayList<>();
|
||||||
|
|
||||||
if (resultMirror.isArray()) {
|
if (resultMirror.isArray()) {
|
||||||
|
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
|
||||||
|
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
|
||||||
|
}
|
||||||
for (int i = 0; i < resultMirror.size(); i++) {
|
for (int i = 0; i < resultMirror.size(); i++) {
|
||||||
Object item = resultMirror.get(String.valueOf(i));
|
Object item = resultMirror.get(String.valueOf(i));
|
||||||
if (item instanceof ScriptObjectMirror) {
|
if (item instanceof ScriptObjectMirror) {
|
||||||
@@ -270,13 +491,13 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
|
|
||||||
// 设置基本字段
|
// 设置基本字段
|
||||||
if (itemMirror.hasMember("fileName")) {
|
if (itemMirror.hasMember("fileName")) {
|
||||||
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
|
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("fileId")) {
|
if (itemMirror.hasMember("fileId")) {
|
||||||
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
|
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("fileType")) {
|
if (itemMirror.hasMember("fileType")) {
|
||||||
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
|
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("size")) {
|
if (itemMirror.hasMember("size")) {
|
||||||
Object size = itemMirror.getMember("size");
|
Object size = itemMirror.getMember("size");
|
||||||
@@ -285,16 +506,16 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("sizeStr")) {
|
if (itemMirror.hasMember("sizeStr")) {
|
||||||
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
|
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("createTime")) {
|
if (itemMirror.hasMember("createTime")) {
|
||||||
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
|
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("updateTime")) {
|
if (itemMirror.hasMember("updateTime")) {
|
||||||
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
|
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("createBy")) {
|
if (itemMirror.hasMember("createBy")) {
|
||||||
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
|
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("downloadCount")) {
|
if (itemMirror.hasMember("downloadCount")) {
|
||||||
Object downloadCount = itemMirror.getMember("downloadCount");
|
Object downloadCount = itemMirror.getMember("downloadCount");
|
||||||
@@ -303,16 +524,16 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("fileIcon")) {
|
if (itemMirror.hasMember("fileIcon")) {
|
||||||
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
|
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("panType")) {
|
if (itemMirror.hasMember("panType")) {
|
||||||
fileInfo.setPanType(itemMirror.getMember("panType").toString());
|
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("parserUrl")) {
|
if (itemMirror.hasMember("parserUrl")) {
|
||||||
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
|
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("previewUrl")) {
|
if (itemMirror.hasMember("previewUrl")) {
|
||||||
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
|
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
|
||||||
}
|
}
|
||||||
|
|
||||||
return fileInfo;
|
return fileInfo;
|
||||||
@@ -322,4 +543,22 @@ public class JsParserExecutor implements IPanTool {
|
|||||||
return null;
|
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>
|
* @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);
|
private static final Logger log = LoggerFactory.getLogger(JsPlaygroundExecutor.class);
|
||||||
|
|
||||||
// JavaScript执行超时时间(秒)
|
// JavaScript执行超时时间(秒)
|
||||||
private static final long EXECUTION_TIMEOUT_SECONDS = 30;
|
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 -> {
|
private static final int POOL_MAX_THREADS = 16;
|
||||||
Thread thread = new Thread(r);
|
private static final int POOL_QUEUE_CAPACITY = 256;
|
||||||
thread.setName("playground-independent-" + System.currentTimeMillis());
|
private static final ExecutorService INDEPENDENT_EXECUTOR = new ThreadPoolExecutor(
|
||||||
thread.setDaemon(true); // 设置为守护线程,服务关闭时自动清理
|
4, POOL_MAX_THREADS, 60L, TimeUnit.SECONDS,
|
||||||
return thread;
|
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 -> {
|
private static final ScheduledExecutorService TIMEOUT_SCHEDULER = Executors.newScheduledThreadPool(2, r -> {
|
||||||
@@ -43,14 +60,29 @@ public class JsPlaygroundExecutor {
|
|||||||
thread.setDaemon(true);
|
thread.setDaemon(true);
|
||||||
return thread;
|
return thread;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭静态线程池(应在应用关闭时调用)
|
||||||
|
*/
|
||||||
|
public static void shutdownPools() {
|
||||||
|
INDEPENDENT_EXECUTOR.shutdown();
|
||||||
|
TIMEOUT_SCHEDULER.shutdown();
|
||||||
|
log.info("JsPlaygroundExecutor 线程池已关闭");
|
||||||
|
}
|
||||||
|
|
||||||
private final ShareLinkInfo shareLinkInfo;
|
private final ShareLinkInfo shareLinkInfo;
|
||||||
private final String jsCode;
|
private final String jsCode;
|
||||||
private final ScriptEngine engine;
|
private volatile ScriptEngine engine;
|
||||||
|
private final Object engineLock = new Object();
|
||||||
private final JsHttpClient httpClient;
|
private final JsHttpClient httpClient;
|
||||||
private final JsPlaygroundLogger playgroundLogger;
|
private final JsPlaygroundLogger playgroundLogger;
|
||||||
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
|
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
|
||||||
private final JsFetchBridge fetchBridge;
|
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.playgroundLogger = new JsPlaygroundLogger();
|
||||||
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
|
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
|
||||||
this.fetchBridge = new JsFetchBridge(httpClient);
|
this.fetchBridge = new JsFetchBridge(httpClient);
|
||||||
this.engine = initEngine();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,6 +120,7 @@ public class JsPlaygroundExecutor {
|
|||||||
if (engine == null) {
|
if (engine == null) {
|
||||||
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
|
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
|
||||||
}
|
}
|
||||||
|
this.engine = engine;
|
||||||
|
|
||||||
// 注入Java对象到JavaScript环境
|
// 注入Java对象到JavaScript环境
|
||||||
engine.put("http", httpClient);
|
engine.put("http", httpClient);
|
||||||
@@ -133,9 +165,14 @@ public class JsPlaygroundExecutor {
|
|||||||
*/
|
*/
|
||||||
public Future<String> executeParseAsync() {
|
public Future<String> executeParseAsync() {
|
||||||
Promise<String> promise = Promise.promise();
|
Promise<String> promise = Promise.promise();
|
||||||
|
|
||||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
final CompletableFuture<String> executionFuture;
|
||||||
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
|
try {
|
||||||
|
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||||
|
executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||||
|
beginExecution();
|
||||||
|
try {
|
||||||
|
ScriptEngine engine = engine();
|
||||||
playgroundLogger.infoJava("开始执行parse方法");
|
playgroundLogger.infoJava("开始执行parse方法");
|
||||||
try {
|
try {
|
||||||
Object parseFunction = engine.get("parse");
|
Object parseFunction = engine.get("parse");
|
||||||
@@ -151,8 +188,9 @@ public class JsPlaygroundExecutor {
|
|||||||
log.debug("[JsPlaygroundExecutor] parse函数执行完成,当前日志数量: {}", playgroundLogger.size());
|
log.debug("[JsPlaygroundExecutor] parse函数执行完成,当前日志数量: {}", playgroundLogger.size());
|
||||||
|
|
||||||
if (result instanceof String) {
|
if (result instanceof String) {
|
||||||
playgroundLogger.infoJava("解析成功,返回结果: " + result);
|
String resultText = limitResultString((String) result, "parse");
|
||||||
return (String) result;
|
playgroundLogger.infoJava("解析成功,返回结果长度: " + resultText.length());
|
||||||
|
return resultText;
|
||||||
} else {
|
} else {
|
||||||
String errorMsg = "parse方法返回值类型错误,期望String,实际: " +
|
String errorMsg = "parse方法返回值类型错误,期望String,实际: " +
|
||||||
(result != null ? result.getClass().getSimpleName() : "null");
|
(result != null ? result.getClass().getSimpleName() : "null");
|
||||||
@@ -167,25 +205,27 @@ public class JsPlaygroundExecutor {
|
|||||||
playgroundLogger.errorJava("执行parse方法失败: " + e.getMessage(), e);
|
playgroundLogger.errorJava("执行parse方法失败: " + e.getMessage(), e);
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}, INDEPENDENT_EXECUTOR);
|
} finally {
|
||||||
|
finishExecution();
|
||||||
// 创建超时任务,强制取消执行
|
|
||||||
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
|
|
||||||
if (!executionFuture.isDone()) {
|
|
||||||
executionFuture.cancel(true); // 强制中断执行线程
|
|
||||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
|
||||||
log.warn("JavaScript执行超时,已强制取消");
|
|
||||||
}
|
}
|
||||||
}, 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) -> {
|
executionFuture.whenComplete((result, error) -> {
|
||||||
// 取消超时任务
|
// 取消超时任务
|
||||||
timeoutTask.cancel(false);
|
timeoutTask.cancel(false);
|
||||||
|
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
if (error instanceof CancellationException) {
|
if (error instanceof CancellationException) {
|
||||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
|
||||||
playgroundLogger.errorJava(timeoutMsg);
|
playgroundLogger.errorJava(timeoutMsg);
|
||||||
log.error(timeoutMsg);
|
log.error(timeoutMsg);
|
||||||
promise.fail(new RuntimeException(timeoutMsg));
|
promise.fail(new RuntimeException(timeoutMsg));
|
||||||
@@ -197,10 +237,10 @@ public class JsPlaygroundExecutor {
|
|||||||
promise.complete(result);
|
promise.complete(result);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行parseFileList方法(异步,带超时控制)
|
* 执行parseFileList方法(异步,带超时控制)
|
||||||
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
|
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
|
||||||
@@ -209,9 +249,14 @@ public class JsPlaygroundExecutor {
|
|||||||
*/
|
*/
|
||||||
public Future<List<FileInfo>> executeParseFileListAsync() {
|
public Future<List<FileInfo>> executeParseFileListAsync() {
|
||||||
Promise<List<FileInfo>> promise = Promise.promise();
|
Promise<List<FileInfo>> promise = Promise.promise();
|
||||||
|
|
||||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
final CompletableFuture<List<FileInfo>> executionFuture;
|
||||||
CompletableFuture<List<FileInfo>> executionFuture = CompletableFuture.supplyAsync(() -> {
|
try {
|
||||||
|
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||||
|
executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||||
|
beginExecution();
|
||||||
|
try {
|
||||||
|
ScriptEngine engine = engine();
|
||||||
playgroundLogger.infoJava("开始执行parseFileList方法");
|
playgroundLogger.infoJava("开始执行parseFileList方法");
|
||||||
try {
|
try {
|
||||||
Object parseFileListFunction = engine.get("parseFileList");
|
Object parseFileListFunction = engine.get("parseFileList");
|
||||||
@@ -242,25 +287,27 @@ public class JsPlaygroundExecutor {
|
|||||||
playgroundLogger.errorJava("执行parseFileList方法失败: " + e.getMessage(), e);
|
playgroundLogger.errorJava("执行parseFileList方法失败: " + e.getMessage(), e);
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}, INDEPENDENT_EXECUTOR);
|
} finally {
|
||||||
|
finishExecution();
|
||||||
// 创建超时任务,强制取消执行
|
|
||||||
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
|
|
||||||
if (!executionFuture.isDone()) {
|
|
||||||
executionFuture.cancel(true); // 强制中断执行线程
|
|
||||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
|
||||||
log.warn("JavaScript执行超时,已强制取消");
|
|
||||||
}
|
}
|
||||||
}, 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) -> {
|
executionFuture.whenComplete((result, error) -> {
|
||||||
// 取消超时任务
|
// 取消超时任务
|
||||||
timeoutTask.cancel(false);
|
timeoutTask.cancel(false);
|
||||||
|
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
if (error instanceof CancellationException) {
|
if (error instanceof CancellationException) {
|
||||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
|
||||||
playgroundLogger.errorJava(timeoutMsg);
|
playgroundLogger.errorJava(timeoutMsg);
|
||||||
log.error(timeoutMsg);
|
log.error(timeoutMsg);
|
||||||
promise.fail(new RuntimeException(timeoutMsg));
|
promise.fail(new RuntimeException(timeoutMsg));
|
||||||
@@ -272,10 +319,10 @@ public class JsPlaygroundExecutor {
|
|||||||
promise.complete(result);
|
promise.complete(result);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行parseById方法(异步,带超时控制)
|
* 执行parseById方法(异步,带超时控制)
|
||||||
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
|
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
|
||||||
@@ -284,9 +331,14 @@ public class JsPlaygroundExecutor {
|
|||||||
*/
|
*/
|
||||||
public Future<String> executeParseByIdAsync() {
|
public Future<String> executeParseByIdAsync() {
|
||||||
Promise<String> promise = Promise.promise();
|
Promise<String> promise = Promise.promise();
|
||||||
|
|
||||||
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
final CompletableFuture<String> executionFuture;
|
||||||
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
|
try {
|
||||||
|
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
|
||||||
|
executionFuture = CompletableFuture.supplyAsync(() -> {
|
||||||
|
beginExecution();
|
||||||
|
try {
|
||||||
|
ScriptEngine engine = engine();
|
||||||
playgroundLogger.infoJava("开始执行parseById方法");
|
playgroundLogger.infoJava("开始执行parseById方法");
|
||||||
try {
|
try {
|
||||||
Object parseByIdFunction = engine.get("parseById");
|
Object parseByIdFunction = engine.get("parseById");
|
||||||
@@ -300,8 +352,9 @@ public class JsPlaygroundExecutor {
|
|||||||
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, playgroundLogger);
|
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, playgroundLogger);
|
||||||
|
|
||||||
if (result instanceof String) {
|
if (result instanceof String) {
|
||||||
playgroundLogger.infoJava("按ID解析成功: " + result);
|
String resultText = limitResultString((String) result, "parseById");
|
||||||
return (String) result;
|
playgroundLogger.infoJava("按ID解析成功,返回结果长度: " + resultText.length());
|
||||||
|
return resultText;
|
||||||
} else {
|
} else {
|
||||||
String errorMsg = "parseById方法返回值类型错误,期望String,实际: " +
|
String errorMsg = "parseById方法返回值类型错误,期望String,实际: " +
|
||||||
(result != null ? result.getClass().getSimpleName() : "null");
|
(result != null ? result.getClass().getSimpleName() : "null");
|
||||||
@@ -316,25 +369,27 @@ public class JsPlaygroundExecutor {
|
|||||||
playgroundLogger.errorJava("执行parseById方法失败: " + e.getMessage(), e);
|
playgroundLogger.errorJava("执行parseById方法失败: " + e.getMessage(), e);
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}, INDEPENDENT_EXECUTOR);
|
} finally {
|
||||||
|
finishExecution();
|
||||||
// 创建超时任务,强制取消执行
|
|
||||||
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
|
|
||||||
if (!executionFuture.isDone()) {
|
|
||||||
executionFuture.cancel(true); // 强制中断执行线程
|
|
||||||
playgroundLogger.errorJava("执行超时,已强制中断");
|
|
||||||
log.warn("JavaScript执行超时,已强制取消");
|
|
||||||
}
|
}
|
||||||
}, 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) -> {
|
executionFuture.whenComplete((result, error) -> {
|
||||||
// 取消超时任务
|
// 取消超时任务
|
||||||
timeoutTask.cancel(false);
|
timeoutTask.cancel(false);
|
||||||
|
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
if (error instanceof CancellationException) {
|
if (error instanceof CancellationException) {
|
||||||
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
|
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
|
||||||
playgroundLogger.errorJava(timeoutMsg);
|
playgroundLogger.errorJava(timeoutMsg);
|
||||||
log.error(timeoutMsg);
|
log.error(timeoutMsg);
|
||||||
promise.fail(new RuntimeException(timeoutMsg));
|
promise.fail(new RuntimeException(timeoutMsg));
|
||||||
@@ -346,7 +401,7 @@ public class JsPlaygroundExecutor {
|
|||||||
promise.complete(result);
|
promise.complete(result);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +410,7 @@ public class JsPlaygroundExecutor {
|
|||||||
*/
|
*/
|
||||||
public List<JsPlaygroundLogger.LogEntry> getLogs() {
|
public List<JsPlaygroundLogger.LogEntry> getLogs() {
|
||||||
List<JsPlaygroundLogger.LogEntry> logs = playgroundLogger.getLogs();
|
List<JsPlaygroundLogger.LogEntry> logs = playgroundLogger.getLogs();
|
||||||
System.out.println("[JsPlaygroundExecutor] 获取日志,数量: " + logs.size());
|
log.debug("获取日志,数量: {}", logs.size());
|
||||||
return logs;
|
return logs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +428,9 @@ public class JsPlaygroundExecutor {
|
|||||||
List<FileInfo> fileList = new ArrayList<>();
|
List<FileInfo> fileList = new ArrayList<>();
|
||||||
|
|
||||||
if (resultMirror.isArray()) {
|
if (resultMirror.isArray()) {
|
||||||
|
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
|
||||||
|
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
|
||||||
|
}
|
||||||
for (int i = 0; i < resultMirror.size(); i++) {
|
for (int i = 0; i < resultMirror.size(); i++) {
|
||||||
Object item = resultMirror.get(String.valueOf(i));
|
Object item = resultMirror.get(String.valueOf(i));
|
||||||
if (item instanceof ScriptObjectMirror) {
|
if (item instanceof ScriptObjectMirror) {
|
||||||
@@ -396,13 +454,13 @@ public class JsPlaygroundExecutor {
|
|||||||
|
|
||||||
// 设置基本字段
|
// 设置基本字段
|
||||||
if (itemMirror.hasMember("fileName")) {
|
if (itemMirror.hasMember("fileName")) {
|
||||||
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
|
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("fileId")) {
|
if (itemMirror.hasMember("fileId")) {
|
||||||
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
|
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("fileType")) {
|
if (itemMirror.hasMember("fileType")) {
|
||||||
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
|
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("size")) {
|
if (itemMirror.hasMember("size")) {
|
||||||
Object size = itemMirror.getMember("size");
|
Object size = itemMirror.getMember("size");
|
||||||
@@ -411,16 +469,16 @@ public class JsPlaygroundExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("sizeStr")) {
|
if (itemMirror.hasMember("sizeStr")) {
|
||||||
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
|
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("createTime")) {
|
if (itemMirror.hasMember("createTime")) {
|
||||||
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
|
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("updateTime")) {
|
if (itemMirror.hasMember("updateTime")) {
|
||||||
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
|
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("createBy")) {
|
if (itemMirror.hasMember("createBy")) {
|
||||||
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
|
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("downloadCount")) {
|
if (itemMirror.hasMember("downloadCount")) {
|
||||||
Object downloadCount = itemMirror.getMember("downloadCount");
|
Object downloadCount = itemMirror.getMember("downloadCount");
|
||||||
@@ -429,24 +487,154 @@ public class JsPlaygroundExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("fileIcon")) {
|
if (itemMirror.hasMember("fileIcon")) {
|
||||||
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
|
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("panType")) {
|
if (itemMirror.hasMember("panType")) {
|
||||||
fileInfo.setPanType(itemMirror.getMember("panType").toString());
|
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("parserUrl")) {
|
if (itemMirror.hasMember("parserUrl")) {
|
||||||
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
|
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
|
||||||
}
|
}
|
||||||
if (itemMirror.hasMember("previewUrl")) {
|
if (itemMirror.hasMember("previewUrl")) {
|
||||||
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
|
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
|
||||||
}
|
}
|
||||||
|
|
||||||
return fileInfo;
|
return fileInfo;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
playgroundLogger.errorJava("转换FileInfo对象失败", e);
|
playgroundLogger.errorJava("转换FileInfo对象失败", e);
|
||||||
return null;
|
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.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 演练场日志收集器
|
* 演练场日志收集器
|
||||||
* 收集JavaScript执行过程中的日志信息
|
* 收集JavaScript执行过程中的日志信息
|
||||||
@@ -12,8 +15,12 @@ import java.util.List;
|
|||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
*/
|
*/
|
||||||
public class JsPlaygroundLogger {
|
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<>());
|
private final List<LogEntry> logs = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,9 +63,25 @@ public class JsPlaygroundLogger {
|
|||||||
if (obj == null) {
|
if (obj == null) {
|
||||||
return "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 日志级别
|
* @param level 日志级别
|
||||||
@@ -67,8 +90,8 @@ public class JsPlaygroundLogger {
|
|||||||
*/
|
*/
|
||||||
private void log(String level, Object message, String source) {
|
private void log(String level, Object message, String source) {
|
||||||
String msg = toString(message);
|
String msg = toString(message);
|
||||||
logs.add(new LogEntry(level, msg, source));
|
addLog(new LogEntry(level, msg, source));
|
||||||
System.out.println("[" + source + "PlaygroundLogger] " + level + ": " + msg);
|
log.debug("[{}PlaygroundLogger] {}: {}", source, level, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,10 +132,10 @@ public class JsPlaygroundLogger {
|
|||||||
public void error(Object message, Throwable throwable) {
|
public void error(Object message, Throwable throwable) {
|
||||||
String msg = toString(message);
|
String msg = toString(message);
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
msg = msg + ": " + throwable.getMessage();
|
msg = toString(msg + ": " + throwable.getMessage());
|
||||||
}
|
}
|
||||||
logs.add(new LogEntry("ERROR", msg, "JS"));
|
addLog(new LogEntry("ERROR", msg, "JS"));
|
||||||
System.out.println("[JSPlaygroundLogger] ERROR: " + msg);
|
log.debug("[JSPlaygroundLogger] ERROR: {}", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 以下是供Java层调用的内部方法 =====
|
// ===== 以下是供Java层调用的内部方法 =====
|
||||||
@@ -149,12 +172,12 @@ public class JsPlaygroundLogger {
|
|||||||
* 错误日志(带异常,供Java层调用)
|
* 错误日志(带异常,供Java层调用)
|
||||||
*/
|
*/
|
||||||
public void errorJava(String message, Throwable throwable) {
|
public void errorJava(String message, Throwable throwable) {
|
||||||
String msg = message;
|
String msg = toString(message);
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
msg = msg + ": " + throwable.getMessage();
|
msg = toString(msg + ": " + throwable.getMessage());
|
||||||
}
|
}
|
||||||
logs.add(new LogEntry("ERROR", msg, "JAVA"));
|
addLog(new LogEntry("ERROR", msg, "JAVA"));
|
||||||
System.out.println("[JAVAPlaygroundLogger] ERROR: " + msg);
|
log.debug("[JAVAPlaygroundLogger] ERROR: {}", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,4 +202,17 @@ public class JsPlaygroundLogger {
|
|||||||
public void clear() {
|
public void clear() {
|
||||||
logs.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 RESOURCE_PATH = "custom-parsers";
|
||||||
private static final String EXTERNAL_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";
|
private static final String EXTERNAL_PATH_PROPERTY = "parser.custom-parsers.path";
|
||||||
@@ -81,14 +83,16 @@ public class JsScriptLoader {
|
|||||||
try {
|
try {
|
||||||
InputStream inputStream = JsScriptLoader.class.getClassLoader()
|
InputStream inputStream = JsScriptLoader.class.getClassLoader()
|
||||||
.getResourceAsStream(resourceFile);
|
.getResourceAsStream(resourceFile);
|
||||||
|
|
||||||
if (inputStream != null) {
|
if (inputStream != null) {
|
||||||
String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
try (inputStream) {
|
||||||
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
String jsCode = readResourceScript(inputStream, resourceFile);
|
||||||
configs.add(config);
|
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
||||||
|
configs.add(config);
|
||||||
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
|
|
||||||
log.debug("从资源目录加载脚本: {}", fileName);
|
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
|
||||||
|
log.debug("从资源目录加载脚本: {}", fileName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("加载资源脚本失败: {}", resourceFile, e);
|
log.warn("加载资源脚本失败: {}", resourceFile, e);
|
||||||
@@ -139,21 +143,20 @@ public class JsScriptLoader {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String jarPath = jarUrl.getPath().substring(5, jarUrl.getPath().indexOf("!"));
|
String jarPath = jarUrl.getPath().substring(5, jarUrl.getPath().indexOf("!"));
|
||||||
JarFile jarFile = new JarFile(jarPath);
|
|
||||||
|
try (JarFile jarFile = new JarFile(jarPath)) {
|
||||||
Enumeration<JarEntry> entries = jarFile.entries();
|
Enumeration<JarEntry> entries = jarFile.entries();
|
||||||
while (entries.hasMoreElements()) {
|
while (entries.hasMoreElements()) {
|
||||||
JarEntry entry = entries.nextElement();
|
JarEntry entry = entries.nextElement();
|
||||||
String entryName = entry.getName();
|
String entryName = entry.getName();
|
||||||
|
|
||||||
if (entryName.startsWith(RESOURCE_PATH + "/") &&
|
if (entryName.startsWith(RESOURCE_PATH + "/") &&
|
||||||
entryName.endsWith(".js") &&
|
entryName.endsWith(".js") &&
|
||||||
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
|
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
|
||||||
resourceFiles.add(entryName);
|
resourceFiles.add(entryName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jarFile.close();
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("解析JAR包资源文件失败", e);
|
log.debug("解析JAR包资源文件失败", e);
|
||||||
}
|
}
|
||||||
@@ -208,8 +211,10 @@ public class JsScriptLoader {
|
|||||||
paths.filter(Files::isRegularFile)
|
paths.filter(Files::isRegularFile)
|
||||||
.filter(path -> path.toString().endsWith(".js"))
|
.filter(path -> path.toString().endsWith(".js"))
|
||||||
.filter(path -> !isExcludedFile(path.getFileName().toString()))
|
.filter(path -> !isExcludedFile(path.getFileName().toString()))
|
||||||
|
.limit(MAX_EXTERNAL_SCRIPT_COUNT)
|
||||||
.forEach(path -> {
|
.forEach(path -> {
|
||||||
try {
|
try {
|
||||||
|
ensureScriptSize(path);
|
||||||
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
|
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
|
||||||
configs.add(config);
|
configs.add(config);
|
||||||
@@ -263,6 +268,7 @@ public class JsScriptLoader {
|
|||||||
throw new IllegalArgumentException("文件不存在: " + filePath);
|
throw new IllegalArgumentException("文件不存在: " + filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureScriptSize(path);
|
||||||
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
|
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
return JsScriptMetadataParser.parseScript(jsCode);
|
return JsScriptMetadataParser.parseScript(jsCode);
|
||||||
|
|
||||||
@@ -280,14 +286,16 @@ public class JsScriptLoader {
|
|||||||
try {
|
try {
|
||||||
InputStream inputStream = JsScriptLoader.class.getClassLoader()
|
InputStream inputStream = JsScriptLoader.class.getClassLoader()
|
||||||
.getResourceAsStream(resourcePath);
|
.getResourceAsStream(resourcePath);
|
||||||
|
|
||||||
if (inputStream == null) {
|
if (inputStream == null) {
|
||||||
throw new IllegalArgumentException("资源文件不存在: " + resourcePath);
|
throw new IllegalArgumentException("资源文件不存在: " + resourcePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
try (inputStream) {
|
||||||
return JsScriptMetadataParser.parseScript(jsCode);
|
String jsCode = readResourceScript(inputStream, resourcePath);
|
||||||
|
return JsScriptMetadataParser.parseScript(jsCode);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("读取资源文件失败: " + resourcePath, e);
|
throw new RuntimeException("读取资源文件失败: " + resourcePath, e);
|
||||||
}
|
}
|
||||||
@@ -347,4 +355,19 @@ public class JsScriptLoader {
|
|||||||
fileName.contains(".test.") ||
|
fileName.contains(".test.") ||
|
||||||
fileName.contains(".spec.");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cn.qaiu.parser.impl;
|
|||||||
|
|
||||||
import cn.qaiu.entity.FileInfo;
|
import cn.qaiu.entity.FileInfo;
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
|
import cn.qaiu.parser.IPanTool;
|
||||||
import cn.qaiu.parser.PanBase;
|
import cn.qaiu.parser.PanBase;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
import io.vertx.core.buffer.Buffer;
|
import io.vertx.core.buffer.Buffer;
|
||||||
@@ -160,6 +161,7 @@ public class CeTool extends PanBase {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("v3 share API解析失败: {}", e.getMessage());
|
log.debug("v3 share API解析失败: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
tryV4ShareApi(baseUrl, key, pwd);
|
||||||
}).onFailure(t -> {
|
}).onFailure(t -> {
|
||||||
log.debug("v3 share API请求失败: {}", t.getMessage());
|
log.debug("v3 share API请求失败: {}", t.getMessage());
|
||||||
// 请求失败,尝试 v4 或下一个解析器
|
// 请求失败,尝试 v4 或下一个解析器
|
||||||
@@ -206,7 +208,8 @@ public class CeTool extends PanBase {
|
|||||||
*/
|
*/
|
||||||
private void delegateToCe4Tool() {
|
private void delegateToCe4Tool() {
|
||||||
log.debug("检测到Cloudreve 4.x,转发到Ce4Tool处理");
|
log.debug("检测到Cloudreve 4.x,转发到Ce4Tool处理");
|
||||||
new Ce4Tool(shareLinkInfo).parse().onComplete(promise);
|
Ce4Tool ce4Tool = new Ce4Tool(shareLinkInfo);
|
||||||
|
IPanTool.closeAfter(ce4Tool, ce4Tool::parse).onComplete(promise);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,73 @@
|
|||||||
package cn.qaiu.parser.impl;
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
|
import cn.qaiu.entity.FileInfo;
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
import cn.qaiu.parser.PanBase;
|
import cn.qaiu.parser.PanBase;
|
||||||
import cn.qaiu.util.RandomStringGenerator;
|
import cn.qaiu.util.CommonUtils;
|
||||||
|
import cn.qaiu.util.FileSizeConverter;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.buffer.Buffer;
|
import io.vertx.core.buffer.Buffer;
|
||||||
|
import io.vertx.core.json.JsonArray;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.ext.web.client.HttpRequest;
|
import io.vertx.ext.web.client.HttpRequest;
|
||||||
import io.vertx.uritemplate.UriTemplate;
|
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.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <a href="https://www.ctfile.com">诚通网盘</a>
|
* <a href="https://www.ctfile.com">诚通网盘</a>
|
||||||
*/
|
*/
|
||||||
public class CtTool extends PanBase {
|
public class CtTool extends PanBase {
|
||||||
private static final String API_URL_PREFIX = "https://webapi.ctfile.com";
|
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&
|
// https://webapi.ctfile.com/getfile.php?path=f&f=64115194-17569800420720-06c697&
|
||||||
// passcode=7548&token=30wiijxs1fzhb6brw0p9m6&r=0.5885881231735761&
|
// passcode=7609&r=0.6611183001986635&ref=&url=https%3A%2F%2Furl94.ctfile.com%2Ff%2F64115194-17569800420720-06c697%3Fp%3D7609
|
||||||
// ref=&url=https%3A%2F%2F474b.com%2Ff%2F55050874-1246660795-6464f6%3Fp%3D7548
|
|
||||||
private static final String API1 = API_URL_PREFIX + "/getfile.php?path={path}" +
|
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&
|
// https://webapi.ctfile.com/get_down_url.php?uid=64115194&fid=17569800420720&
|
||||||
// file_chk=054bc20461f5c63ff82015b9e69fb7fc&mb=1&token=30wiijxs1fzhb6brw0p9m6&app=0&
|
// file_chk=af5c8757a49cbc69a557eb3da59b246c&start_time=1780471868&wait_seconds=0&rd=0.36...
|
||||||
// acheck=1&verifycode=&rd=0.965929071503574
|
private static final String API2 = API_URL_PREFIX + "/get_down_url.php?" +
|
||||||
private static final String API2 = API_URL_PREFIX + "/get_file_url.php?" +
|
"uid={uid}&fid={fid}&file_chk={file_chk}" +
|
||||||
"uid={uid}&fid={fid}&folder_id=0&file_chk={file_chk}&mb=0&token={token}&app=0&acheck=0&verifycode=" +
|
"&start_time={start_time}&wait_seconds={wait_seconds}&rd={rand}";
|
||||||
"&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
|
@Override
|
||||||
public Future<String> parse() {
|
public Future<String> parse() {
|
||||||
final String shareKey = shareLinkInfo.getShareKey();
|
final String shareKey = shareLinkInfo.getShareKey();
|
||||||
if (shareKey.indexOf('-') == -1) {
|
if (shareKey == null || shareKey.indexOf('-') == -1) {
|
||||||
fail("shareKey格式不正确找不到'-': {}", shareKey);
|
fail("shareKey格式不正确找不到'-': {}", shareKey);
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
String[] split = shareKey.split("-");
|
String[] split = shareKey.split("-");
|
||||||
String uid = split[0], fid = split[1];
|
if (split.length < 2 || split[0].isBlank() || split[1].isBlank()) {
|
||||||
String token = RandomStringGenerator.generateRandomString();
|
fail("shareKey格式不正确: {}", shareKey);
|
||||||
// 获取url path
|
return promise.future();
|
||||||
int i1 = shareLinkInfo.getShareUrl().indexOf("com/");
|
}
|
||||||
int i2 = shareLinkInfo.getShareUrl().lastIndexOf("/");
|
String fallbackUid = split[0], fallbackFid = split[1];
|
||||||
String path = shareLinkInfo.getShareUrl().substring(i1 + 4, i2);
|
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("path", path)
|
||||||
.setTemplateParam("shareKey", shareKey)
|
.setTemplateParam("shareKey", shareKey)
|
||||||
.setTemplateParam("pwd", shareLinkInfo.getSharePassword())
|
.setTemplateParam("pwd", shareLinkInfo.getSharePassword())
|
||||||
.setTemplateParam("token", token)
|
.setTemplateParam("rand", String.valueOf(Math.random()))
|
||||||
.setTemplateParam("r", Math.random() + "");
|
.setTemplateParam("url", shareLinkInfo.getShareUrl()), shareLinkInfo.getShareUrl());
|
||||||
|
|
||||||
bufferHttpRequest1
|
bufferHttpRequest1
|
||||||
.send().onSuccess(res -> {
|
.send().onSuccess(res -> {
|
||||||
var resJson = asJson(res);
|
try {
|
||||||
if (resJson.containsKey("file")) {
|
var resJson = asJson(res);
|
||||||
var fileJson = resJson.getJsonObject("file");
|
if (resJson == null || resJson.isEmpty()) {
|
||||||
if (fileJson.containsKey("file_chk")) {
|
fail("解析失败, 上游返回空响应或非JSON响应");
|
||||||
var file_chk = fileJson.getString("file_chk");
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} else {
|
Object fileValue = resJson.getValue("file");
|
||||||
fail("解析失败, 文件信息为空, 可能分享已失效");
|
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();
|
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}" +
|
private static final String DOWN_REQUEST_URL = "https://v2.fangcloud.cn/apps/files/download?file_id={fid}" +
|
||||||
"&scenario=share&unique_name={uname}";
|
"&scenario=share&unique_name={uname}";
|
||||||
|
|
||||||
|
// 静态编译的正则表达式,避免每次调用都重新编译
|
||||||
|
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) {
|
public FcTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
}
|
}
|
||||||
@@ -41,8 +45,7 @@ public class FcTool extends PanBase {
|
|||||||
if (StringUtils.isNotEmpty(pwd)) {
|
if (StringUtils.isNotEmpty(pwd)) {
|
||||||
// 获取requesttoken
|
// 获取requesttoken
|
||||||
String html = res.bodyAsString();
|
String html = res.bodyAsString();
|
||||||
Pattern compile = Pattern.compile("name=\"requesttoken\"\\s+value=\"([a-zA-Z0-9_+=]+)\"");
|
Matcher matcher = REQUEST_TOKEN_PATTERN.matcher(html);
|
||||||
Matcher matcher = compile.matcher(html);
|
|
||||||
if (!matcher.find()) {
|
if (!matcher.find()) {
|
||||||
fail(SHARE_URL_PREFIX + " 未匹配到加密分享的密码输入页面的requesttoken");
|
fail(SHARE_URL_PREFIX + " 未匹配到加密分享的密码输入页面的requesttoken");
|
||||||
return;
|
return;
|
||||||
@@ -71,8 +74,7 @@ public class FcTool extends PanBase {
|
|||||||
WebClientSession sClient) {
|
WebClientSession sClient) {
|
||||||
// 从HTML中找到文件id
|
// 从HTML中找到文件id
|
||||||
String html = res.bodyAsString();
|
String html = res.bodyAsString();
|
||||||
Pattern compile = Pattern.compile("id=\"typed_id\"\\s+value=\"file_(\\d+)\"");
|
Matcher matcher = TYPED_ID_PATTERN.matcher(html);
|
||||||
Matcher matcher = compile.matcher(html);
|
|
||||||
if (!matcher.find()) {
|
if (!matcher.find()) {
|
||||||
fail(SHARE_URL_PREFIX + " 未匹配到文件id(typed_id)");
|
fail(SHARE_URL_PREFIX + " 未匹配到文件id(typed_id)");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ public class FjTool extends PanBase {
|
|||||||
|
|
||||||
// String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
// String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
||||||
|
|
||||||
static String token = null;
|
static volatile String token = null;
|
||||||
static String userId = null;
|
static volatile String userId = null;
|
||||||
public static boolean authFlag = true;
|
public static volatile boolean authFlag = true;
|
||||||
|
|
||||||
public FjTool(ShareLinkInfo shareLinkInfo) {
|
public FjTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
@@ -169,8 +169,13 @@ public class FjTool extends PanBase {
|
|||||||
|
|
||||||
// 文件Id
|
// 文件Id
|
||||||
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
|
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
|
||||||
|
JsonArray fileListArray = fileInfo.getJsonArray("fileList");
|
||||||
|
if (fileListArray == null || fileListArray.isEmpty()) {
|
||||||
|
fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 如果是目录返回目录ID
|
// 如果是目录返回目录ID
|
||||||
JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0);
|
JsonObject fileList = fileListArray.getJsonObject(0);
|
||||||
if (fileList.getInteger("fileType") == 2) {
|
if (fileList.getInteger("fileType") == 2) {
|
||||||
promise.complete(fileList.getInteger("folderId").toString());
|
promise.complete(fileList.getInteger("folderId").toString());
|
||||||
return;
|
return;
|
||||||
@@ -289,12 +294,14 @@ public class FjTool extends PanBase {
|
|||||||
JsonObject json = asJson(res2);
|
JsonObject json = asJson(res2);
|
||||||
if (json.getInteger("code") == 200) {
|
if (json.getInteger("code") == 200) {
|
||||||
token = json.getJsonObject("data").getString("appToken");
|
token = json.getJsonObject("data").getString("appToken");
|
||||||
header0.set("appToken", token);
|
MultiMap h0 = MultiMap.caseInsensitiveMultiMap();
|
||||||
log.info("登录成功 token: {}", token);
|
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))
|
client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
|
||||||
.setTemplateParam("uuid", uuid)
|
.setTemplateParam("uuid", uuid)
|
||||||
.setTemplateParam("ts", tsEncode2)
|
.setTemplateParam("ts", tsEncode2)
|
||||||
.putHeaders(header0).send().onSuccess(res -> {
|
.putHeaders(h0).send().onSuccess(res -> {
|
||||||
if (asJson(res).getInteger("code") == 200) {
|
if (asJson(res).getInteger("code") == 200) {
|
||||||
if (FjTool.userId == null) {
|
if (FjTool.userId == null) {
|
||||||
FjTool.userId = asJson(res).getJsonObject("map").getString("userId");
|
FjTool.userId = asJson(res).getJsonObject("map").getString("userId");
|
||||||
@@ -454,7 +461,10 @@ public class FjTool extends PanBase {
|
|||||||
// 如果参数里的目录ID不为空,则直接解析目录
|
// 如果参数里的目录ID不为空,则直接解析目录
|
||||||
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
|
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
|
||||||
if (dirId != null && !dirId.isEmpty()) {
|
if (dirId != null && !dirId.isEmpty()) {
|
||||||
uuid = shareLinkInfo.getOtherParam().get("uuid").toString();
|
Object uuidObj = shareLinkInfo.getOtherParam().get("uuid");
|
||||||
|
if (uuidObj != null) {
|
||||||
|
uuid = uuidObj.toString();
|
||||||
|
}
|
||||||
parserDir(dirId, shareId, promise0);
|
parserDir(dirId, shareId, promise0);
|
||||||
return promise0.future();
|
return promise0.future();
|
||||||
}
|
}
|
||||||
@@ -495,7 +505,7 @@ public class FjTool extends PanBase {
|
|||||||
JsonArray list;
|
JsonArray list;
|
||||||
try {
|
try {
|
||||||
JsonObject jsonObject = asJson(res);
|
JsonObject jsonObject = asJson(res);
|
||||||
System.out.println(jsonObject.encodePrettily());
|
log.debug("目录列表: {}", jsonObject.encodePrettily());
|
||||||
list = jsonObject.getJsonArray("list");
|
list = jsonObject.getJsonArray("list");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("解析目录失败: {}", res.bodyAsString());
|
log.error("解析目录失败: {}", res.bodyAsString());
|
||||||
@@ -576,6 +586,10 @@ public class FjTool extends PanBase {
|
|||||||
|
|
||||||
// 第二次请求
|
// 第二次请求
|
||||||
JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson");
|
JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson");
|
||||||
|
if (paramJson == null) {
|
||||||
|
promise.fail("缺少 paramJson 参数");
|
||||||
|
return promise.future();
|
||||||
|
}
|
||||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||||
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
|
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
|
||||||
.setTemplateParam("uuid", paramJson.getString("uuid"))
|
.setTemplateParam("uuid", paramJson.getString("uuid"))
|
||||||
|
|||||||
@@ -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 WRAPPER_URL = "https://www.so.com/link?m=ewgUSYiFWXIoTybC3fJH8YoJy8y10iRquo6cazgINwWjTn3HvVJ92TrCJu0PmMUR0RMDfOAucP3wa4G8j64SrhNH9Z0Cr0PEyn9ASuvpkUGmAjjUEGJkO5%2BIDGWVrEkPHsL7UsoKO6%2BlT%2BD6r&ccc=";
|
||||||
private static final String MID = "5095144728824883"; // 微博的mid
|
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()
|
private static final MultiMap HEADER = HeadersMultiMap.headers()
|
||||||
.add("Content-Type", "application/x-www-form-urlencoded")
|
.add("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.add("Referer", "https://www.weibo.com")
|
.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");
|
.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");
|
Cookie cookie = new DefaultCookie("SUB", "_2A25KJE5vDeRhGeRJ6lsR9SjJzDuIHXVpWM-nrDV8PUJbkNAbLVPlkW1NUmJm3GjYtRHBsHdMUKafkdTL_YheMEmu");
|
||||||
@@ -64,11 +66,12 @@ public class GenShortUrl extends PanBase {
|
|||||||
String shortUrl = extractShortUrl(comment);
|
String shortUrl = extractShortUrl(comment);
|
||||||
if (shortUrl != null) {
|
if (shortUrl != null) {
|
||||||
log.info("生成的短链:{}", shortUrl);
|
log.info("生成的短链:{}", shortUrl);
|
||||||
|
// 先完成 promise,返回短链
|
||||||
|
promise.complete(shortUrl);
|
||||||
|
// 异步清理评论(best-effort,不影响结果)
|
||||||
String commentId = extractCommentId(comment);
|
String commentId = extractCommentId(comment);
|
||||||
if (commentId != null) {
|
if (commentId != null) {
|
||||||
deleteComment(commentId);
|
deleteComment(commentId);
|
||||||
} else {
|
|
||||||
promise.fail("未能提取评论ID");
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
promise.fail("未能生成短链");
|
promise.fail("未能生成短链");
|
||||||
@@ -103,8 +106,7 @@ public class GenShortUrl extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String extractShortUrl(String comment) {
|
private String extractShortUrl(String comment) {
|
||||||
Pattern pattern = Pattern.compile("(https?)://t.cn/\\w+");
|
Matcher matcher = SHORT_URL_PATTERN.matcher(comment);
|
||||||
Matcher matcher = pattern.matcher(comment);
|
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
return matcher.group(0);
|
return matcher.group(0);
|
||||||
}
|
}
|
||||||
@@ -112,8 +114,7 @@ public class GenShortUrl extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String extractCommentId(String comment) {
|
private String extractCommentId(String comment) {
|
||||||
Pattern pattern = Pattern.compile("comment_id=\"(\\d+)\"");
|
Matcher matcher = COMMENT_ID_PATTERN.matcher(comment);
|
||||||
Matcher matcher = pattern.matcher(comment);
|
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
return matcher.group(1);
|
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.parser.PanBase;
|
||||||
import cn.qaiu.util.AESUtils;
|
import cn.qaiu.util.AESUtils;
|
||||||
import cn.qaiu.util.AcwScV2Generator;
|
import cn.qaiu.util.AcwScV2Generator;
|
||||||
|
import cn.qaiu.util.CommonUtils;
|
||||||
import cn.qaiu.util.FileSizeConverter;
|
import cn.qaiu.util.FileSizeConverter;
|
||||||
import io.netty.handler.codec.http.cookie.DefaultCookie;
|
import io.netty.handler.codec.http.cookie.DefaultCookie;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
import io.vertx.core.MultiMap;
|
import io.vertx.core.MultiMap;
|
||||||
import io.vertx.core.Promise;
|
import io.vertx.core.Promise;
|
||||||
|
import io.vertx.core.buffer.Buffer;
|
||||||
import io.vertx.core.json.JsonArray;
|
import io.vertx.core.json.JsonArray;
|
||||||
import io.vertx.core.json.JsonObject;
|
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.ext.web.client.WebClientSession;
|
||||||
import io.vertx.uritemplate.UriTemplate;
|
import io.vertx.uritemplate.UriTemplate;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
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 {
|
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 API_URL_PREFIX = "https://api.ilanzou.com/unproved/";
|
||||||
|
|
||||||
private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" +
|
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";
|
"&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" +
|
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}";
|
"&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" +
|
private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" +
|
||||||
"={uuid}&extra=2×tamp={ts}";
|
"={uuid}&extra=2×tamp={ts}";
|
||||||
@@ -42,16 +57,15 @@ public class IzTool extends PanBase {
|
|||||||
"={uuid}&extra=2×tamp={ts}&shareId={shareId}&folderId" +
|
"={uuid}&extra=2×tamp={ts}&shareId={shareId}&folderId" +
|
||||||
"={folderId}&offset=1&limit=60";
|
"={folderId}&offset=1&limit=60";
|
||||||
|
|
||||||
long nowTs = System.currentTimeMillis();
|
|
||||||
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
|
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
|
||||||
String uuid = UUID.randomUUID().toString();
|
|
||||||
|
|
||||||
private static final MultiMap header;
|
private static final MultiMap header;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
header = MultiMap.caseInsensitiveMultiMap();
|
header = MultiMap.caseInsensitiveMultiMap();
|
||||||
header.set("Accept", "application/json, text/plain, */*");
|
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("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
|
||||||
header.set("Cache-Control", "no-cache");
|
header.set("Cache-Control", "no-cache");
|
||||||
header.set("Connection", "keep-alive");
|
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-mobile", "?0");
|
||||||
header.set("sec-ch-ua-platform", "\"Windows\"");
|
header.set("sec-ch-ua-platform", "\"Windows\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
public IzTool(ShareLinkInfo shareLinkInfo) {
|
public IzTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setCookie(String html) {
|
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
||||||
int beginIndex = html.indexOf("arg1='") + 6;
|
|
||||||
String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex));
|
public static volatile String token = null;
|
||||||
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
|
public static volatile boolean authFlag = true;
|
||||||
// 创建一个 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Future<String> parse() {
|
public Future<String> parse() {
|
||||||
String shareId = shareLinkInfo.getShareKey();
|
|
||||||
|
|
||||||
// 24.5.12 ilanzou改规则无需计算shareId
|
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||||
// String shareId = String.valueOf(AESUtils.idEncryptIz(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
|
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||||
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
|
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||||
: (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword());
|
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))
|
webClientSession.postAbs(UriTemplate.of(VIP_REQUEST_URL))
|
||||||
.setTemplateParam("uuid", uuid)
|
.setTemplateParam("uuid", uuid)
|
||||||
.setTemplateParam("ts", tsEncode)
|
.setTemplateParam("ts", tsEncode)
|
||||||
.send().onSuccess(r0 -> { // 忽略res
|
.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
|
// 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))
|
webClientSession.postAbs(UriTemplate.of(url))
|
||||||
@@ -121,36 +156,43 @@ public class IzTool extends PanBase {
|
|||||||
.setTemplateParam("uuid", uuid)
|
.setTemplateParam("uuid", uuid)
|
||||||
.setTemplateParam("ts", tsEncode)
|
.setTemplateParam("ts", tsEncode)
|
||||||
.send().onSuccess(res2 -> {
|
.send().onSuccess(res2 -> {
|
||||||
handleParseResponse(asText(res2), shareId);
|
processFirstResponse(res2);
|
||||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
}).onFailure(handleFail("请求1-重试"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleParseResponse(resBody, shareId);
|
processFirstResponse(res);
|
||||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
}).onFailure(handleFail("请求1"));
|
||||||
});
|
|
||||||
|
}).onFailure(handleFail("请求1"));
|
||||||
|
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleParseResponse(String resBody, String shareId) {
|
/**
|
||||||
JsonObject resJson;
|
* 设置 cookie
|
||||||
try {
|
*/
|
||||||
resJson = new JsonObject(resBody);
|
private void setCookie(String html) {
|
||||||
} catch (Exception e) {
|
int beginIndex = html.indexOf("arg1='") + 6;
|
||||||
fail(FIRST_REQUEST_URL + " 解析JSON失败: " + resBody);
|
String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex));
|
||||||
return;
|
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
|
||||||
}
|
// 创建一个 Cookie 并放入 CookieStore
|
||||||
if (resJson.isEmpty()) {
|
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
|
||||||
fail(FIRST_REQUEST_URL + " 返回内容为空");
|
nettyCookie.setDomain(".ilanzou.com"); // 设置域名
|
||||||
return;
|
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) {
|
if (resJson.getInteger("code") != 200) {
|
||||||
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
|
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resJson.getJsonArray("list").isEmpty()) {
|
|
||||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
|
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
|
||||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
||||||
return;
|
return;
|
||||||
@@ -167,30 +209,309 @@ public class IzTool extends PanBase {
|
|||||||
promise.complete(fileList.getInteger("folderId").toString());
|
promise.complete(fileList.getInteger("folderId").toString());
|
||||||
return;
|
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 fileId = fileInfo.getString("fileIds");
|
||||||
String userId = fileInfo.getString("userId");
|
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 fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
|
||||||
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs);
|
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2);
|
||||||
// 第二次请求
|
|
||||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
// 检查是否有认证信息
|
||||||
.setTemplateParam("fidEncode", fidEncode)
|
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||||
.setTemplateParam("uuid", uuid)
|
// 检查是否为临时认证(临时认证每次都尝试登录)
|
||||||
.setTemplateParam("ts", tsEncode)
|
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||||
.setTemplateParam("auth", auth)
|
// 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证
|
||||||
.setTemplateParam("shareId", shareId)
|
if (isTempAuth || authFlag) {
|
||||||
.putHeaders(header).send().onSuccess(res2 -> {
|
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
|
||||||
MultiMap headers = res2.headers();
|
HttpRequest<Buffer> httpRequest =
|
||||||
if (!headers.contains("Location")) {
|
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||||
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + headers);
|
.setTemplateParam("fidEncode", fidEncode)
|
||||||
return;
|
.setTemplateParam("uuid", uuid)
|
||||||
}
|
.setTemplateParam("ts", tsEncode2)
|
||||||
promise.complete(headers.get("Location"));
|
.setTemplateParam("auth", auth)
|
||||||
}).onFailure(handleFail(SECOND_REQUEST_URL));
|
.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
|
@Override
|
||||||
public Future<List<FileInfo>> parseFileList() {
|
public Future<List<FileInfo>> parseFileList() {
|
||||||
Promise<List<FileInfo>> promise = Promise.promise();
|
Promise<List<FileInfo>> promise = Promise.promise();
|
||||||
@@ -200,16 +521,15 @@ public class IzTool extends PanBase {
|
|||||||
// 如果参数里的目录ID不为空,则直接解析目录
|
// 如果参数里的目录ID不为空,则直接解析目录
|
||||||
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
|
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
|
||||||
if (dirId != null && !dirId.isEmpty()) {
|
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);
|
parserDir(dirId, shareId, promise);
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
parse().onSuccess(id -> {
|
parse().onSuccess(id -> {
|
||||||
if (id != null && id.matches("^[a-zA-Z0-9]+$")) {
|
parserDir(id, shareId, promise);
|
||||||
parserDir(id, shareId, promise);
|
|
||||||
} else {
|
|
||||||
promise.fail("解析目录ID失败");
|
|
||||||
}
|
|
||||||
}).onFailure(failRes -> {
|
}).onFailure(failRes -> {
|
||||||
log.error("解析目录失败: {}", failRes.getMessage());
|
log.error("解析目录失败: {}", failRes.getMessage());
|
||||||
promise.fail(failRes);
|
promise.fail(failRes);
|
||||||
@@ -218,6 +538,22 @@ public class IzTool extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void parserDir(String id, String shareId, Promise<List<FileInfo>> promise) {
|
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);
|
log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode);
|
||||||
// 开始解析目录: 164312216, shareId: bPMsbg5K, uuid: 0fmVWTx2Ea4zFwkpd7KXf, ts: 20865d7b7f00828279f437cd1f097860
|
// 开始解析目录: 164312216, shareId: bPMsbg5K, uuid: 0fmVWTx2Ea4zFwkpd7KXf, ts: 20865d7b7f00828279f437cd1f097860
|
||||||
// 拿到目录ID
|
// 拿到目录ID
|
||||||
@@ -228,103 +564,134 @@ public class IzTool extends PanBase {
|
|||||||
.setTemplateParam("ts", tsEncode)
|
.setTemplateParam("ts", tsEncode)
|
||||||
.setTemplateParam("folderId", id)
|
.setTemplateParam("folderId", id)
|
||||||
.send().onSuccess(res -> {
|
.send().onSuccess(res -> {
|
||||||
JsonObject jsonObject;
|
String resBody = asText(res);
|
||||||
try {
|
// 检查是否包含 cookie 验证
|
||||||
jsonObject = asJson(res);
|
if (resBody.contains("var arg1='")) {
|
||||||
} catch (Exception e) {
|
log.debug("目录解析需要 cookie 验证,重新创建 session");
|
||||||
promise.fail(FIRST_REQUEST_URL + " 解析JSON失败: " + res.bodyAsString());
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// System.out.println(jsonObject.encodePrettily());
|
processDirResponse(res, shareId, promise);
|
||||||
JsonArray list = jsonObject.getJsonArray("list");
|
}).onFailure(err -> {
|
||||||
ArrayList<FileInfo> result = new ArrayList<>();
|
log.error("目录解析请求失败: {}", err.getMessage());
|
||||||
list.forEach(item->{
|
promise.fail("目录解析失败: " + err.getMessage());
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理目录解析响应
|
||||||
|
*/
|
||||||
|
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
|
@Override
|
||||||
public Future<String> parseById() {
|
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))
|
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||||
|
.putHeaders(header)
|
||||||
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
|
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
|
||||||
.setTemplateParam("uuid", paramJson.getString("uuid"))
|
.setTemplateParam("uuid", paramJson.getString("uuid"))
|
||||||
.setTemplateParam("ts", paramJson.getString("ts"))
|
.setTemplateParam("ts", paramJson.getString("ts"))
|
||||||
.setTemplateParam("auth", paramJson.getString("auth"))
|
.setTemplateParam("auth", paramJson.getString("auth"))
|
||||||
.setTemplateParam("shareId", paramJson.getString("shareId"))
|
.setTemplateParam("dataKey", paramJson.getString("shareId"))
|
||||||
.putHeaders(header).send().onSuccess(res2 -> {
|
.send().onSuccess(this::down).onFailure(handleFail("parseById"));
|
||||||
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));
|
|
||||||
return promise.future();
|
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.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
import java.util.UUID;
|
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 API_URL_PREFIX = "https://lecloud.lenovo.com/mshare/api/clouddiskapi/share/public/v1/";
|
||||||
private static final String DEFAULT_FILE_TYPE = "file";
|
private static final String DEFAULT_FILE_TYPE = "file";
|
||||||
private static final int FILE_TYPE_DIRECTORY = 0; // 目录类型
|
private static final int FILE_TYPE_DIRECTORY = 0; // 目录类型
|
||||||
|
private static final Random RANDOM = new Random();
|
||||||
|
|
||||||
private static final MultiMap HEADERS;
|
private static final MultiMap HEADERS;
|
||||||
|
|
||||||
@@ -100,8 +102,8 @@ public class LeTool extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String fileId = fileInfoJson.getString("fileId");
|
String fileId = fileInfoJson.getString("fileId");
|
||||||
// 根据文件ID获取跳转链接
|
// 根据文件ID获取跳转链接(随机选择方式,失败自动fallback)
|
||||||
getDownURL(dataKey, fileId);
|
getDownURLWithFallback(dataKey, fileId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fail("{}: {}", resJson.getString("errcode"), resJson.getString("errmsg"));
|
fail("{}: {}", resJson.getString("errcode"), resJson.getString("errmsg"));
|
||||||
@@ -260,8 +262,8 @@ public class LeTool extends PanBase {
|
|||||||
String shareId = paramJson.getString("shareId");
|
String shareId = paramJson.getString("shareId");
|
||||||
String fileId = paramJson.getString("fileId");
|
String fileId = paramJson.getString("fileId");
|
||||||
|
|
||||||
// 调用获取下载链接
|
// 调用获取下载链接(随机选择方式,失败自动fallback)
|
||||||
getDownURLForById(shareId, fileId, parsePromise);
|
getDownURLWithFallbackForById(shareId, fileId, parsePromise);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
parsePromise.fail("解析参数失败: " + e.getMessage());
|
parsePromise.fail("解析参数失败: " + e.getMessage());
|
||||||
@@ -304,14 +306,22 @@ public class LeTool extends PanBase {
|
|||||||
}).onFailure(err -> promise.fail(err));
|
}).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();
|
String uuid = UUID.randomUUID().toString();
|
||||||
JsonArray fileIds = JsonArray.of(fileId);
|
JsonArray fileIds = JsonArray.of(fileId);
|
||||||
String apiUrl2 = API_URL_PREFIX + "packageDownloadWithFileIds";
|
String apiUrl = API_URL_PREFIX + "packageDownloadWithFileIds";
|
||||||
// {"fileIds":[123],"shareId":"xxx","browserId":"uuid"}
|
// {"fileIds":[123],"shareId":"xxx","browserId":"uuid"}
|
||||||
client.postAbs(apiUrl2)
|
client.postAbs(apiUrl)
|
||||||
.putHeaders(HEADERS)
|
.putHeaders(HEADERS)
|
||||||
.sendJsonObject(JsonObject.of("fileIds", fileIds, "shareId", key, "browserId", uuid))
|
.sendJsonObject(JsonObject.of("fileIds", fileIds, "shareId", shareId, "browserId", uuid))
|
||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
JsonObject resJson = asJson(res);
|
JsonObject resJson = asJson(res);
|
||||||
if (resJson.containsKey("result")) {
|
if (resJson.containsKey("result")) {
|
||||||
@@ -320,20 +330,107 @@ public class LeTool extends PanBase {
|
|||||||
// 获取重定向链接跳转链接
|
// 获取重定向链接跳转链接
|
||||||
String downloadUrl = dataJson.getString("downloadUrl");
|
String downloadUrl = dataJson.getString("downloadUrl");
|
||||||
if (downloadUrl == null) {
|
if (downloadUrl == null) {
|
||||||
fail("Result JSON数据异常: downloadUrl不存在");
|
promise.fail("Result JSON数据异常: downloadUrl不存在");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 获取重定向链接跳转链接
|
// 获取重定向链接跳转链接
|
||||||
clientNoRedirects.getAbs(downloadUrl).send()
|
clientNoRedirects.getAbs(downloadUrl).send()
|
||||||
.onSuccess(res2 -> promise.complete(res2.headers().get("Location")))
|
.onSuccess(res2 -> promise.complete(res2.headers().get("Location")))
|
||||||
.onFailure(handleFail(downloadUrl));
|
.onFailure(err -> promise.fail(err));
|
||||||
} else {
|
} else {
|
||||||
fail("{}: {}", resJson.getString("errcode"), resJson.getString("errmsg"));
|
promise.fail(resJson.getString("errcode") + ": " + resJson.getString("errmsg"));
|
||||||
}
|
}
|
||||||
} else {
|
} 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 org.openjdk.nashorn.api.scripting.ScriptObjectMirror;
|
||||||
|
|
||||||
import javax.script.ScriptException;
|
import javax.script.ScriptException;
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -30,6 +33,20 @@ public class LzTool extends PanBase {
|
|||||||
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
|
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
|
||||||
|
|
||||||
public static final String SHARE_URL_PREFIX = "https://w1.lanzn.com/";
|
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("""
|
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: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
|
||||||
Accept-Encoding: gzip, deflate
|
Accept-Encoding: gzip, deflate
|
||||||
@@ -61,19 +78,30 @@ public class LzTool extends PanBase {
|
|||||||
client.getAbs(sUrl)
|
client.getAbs(sUrl)
|
||||||
.putHeaders(headers0)
|
.putHeaders(headers0)
|
||||||
.send().onSuccess(res -> {
|
.send().onSuccess(res -> {
|
||||||
String html = asText(res);
|
try {
|
||||||
if (html.contains("var arg1='")) {
|
String html = asText(res);
|
||||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
if (hasAcwArg1(html)) {
|
||||||
setCookie(html);
|
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||||
webClientSession.getAbs(sUrl)
|
if (!setCookie(html, sUrl)) {
|
||||||
.putHeaders(headers0)
|
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
|
||||||
.send().onSuccess(res2 -> {
|
return;
|
||||||
String html2 = asText(res2);
|
}
|
||||||
doParser(html2, pwd, sUrl);
|
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 {
|
} else {
|
||||||
doParser(html, pwd, sUrl);
|
doParser(html, pwd, sUrl);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("蓝奏云页面响应处理异常: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
}).onFailure(handleFail(sUrl));
|
}).onFailure(handleFail(sUrl));
|
||||||
@@ -81,14 +109,63 @@ public class LzTool extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void doParser(String html, String pwd, String sUrl) {
|
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 {
|
try {
|
||||||
setFileInfo(html, shareLinkInfo);
|
setFileInfo(html, shareLinkInfo);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
log.error("文件信息解析异常", e);
|
||||||
}
|
}
|
||||||
// 匹配iframe
|
// 匹配iframe
|
||||||
Pattern compile = Pattern.compile("src=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\"");
|
Matcher matcher = IFRAME_SRC_PATTERN.matcher(html);
|
||||||
Matcher matcher = compile.matcher(html);
|
|
||||||
// 没有Iframe说明是加密分享, 匹配sign通过密码请求下载页面
|
// 没有Iframe说明是加密分享, 匹配sign通过密码请求下载页面
|
||||||
if (!matcher.find()) {
|
if (!matcher.find()) {
|
||||||
try {
|
try {
|
||||||
@@ -98,57 +175,87 @@ public class LzTool extends PanBase {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
fail(e, "js引擎执行失败");
|
fail(e, "js引擎执行失败");
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// 没有密码
|
// 没有密码
|
||||||
String iframePath = matcher.group(1);
|
String iframePath = matcher.group(1);
|
||||||
String absoluteURI = SHARE_URL_PREFIX + iframePath;
|
String absoluteURI = SHARE_URL_PREFIX + iframePath;
|
||||||
webClientSession.getAbs(absoluteURI).putHeaders(headers0).send().onSuccess(res2 -> {
|
// 创建局部副本,避免修改实例字段导致累积
|
||||||
String html2= asText(res2);
|
MultiMap headersCopy = MultiMap.caseInsensitiveMultiMap().addAll(headers0);
|
||||||
// Matcher matcher2 = Pattern.compile("'sign'\s*:\s*'(\\w+)'").matcher(html2);
|
headersCopy.add("Referer", absoluteURI);
|
||||||
String jsText = getJsText(html2);
|
webClientSession.getAbs(absoluteURI).putHeaders(headersCopy).send().onSuccess(res2 -> {
|
||||||
if (jsText == null) {
|
try {
|
||||||
headers0.add("Referer", absoluteURI);
|
String html2 = asText(res2);
|
||||||
setCookie(html2);
|
if (isShareCancelledPage(html2)) {
|
||||||
webClientSession.getAbs(absoluteURI).send().onSuccess(res3 -> {
|
fail("分享已失效或文件已取消分享");
|
||||||
String html3= asText(res3);
|
return;
|
||||||
String jsText3 = getJsText(html3);
|
}
|
||||||
if (jsText3 != null) {
|
String jsText = getJsText(html2);
|
||||||
try {
|
if (jsText == null) {
|
||||||
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText3, null);
|
if (!setCookie(html2, absoluteURI)) {
|
||||||
getDownURL(sUrl, scriptObjectMirror);
|
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
|
||||||
} catch (ScriptException | NoSuchMethodException e) {
|
|
||||||
fail(e, "引擎执行失败");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
|
|
||||||
fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
webClientSession.getAbs(absoluteURI).send().onSuccess(res3 -> {
|
||||||
} else {
|
try {
|
||||||
try {
|
String html3 = asText(res3);
|
||||||
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, null);
|
if (isShareCancelledPage(html3)) {
|
||||||
getDownURL(sUrl, scriptObjectMirror);
|
fail("分享已失效或文件已取消分享");
|
||||||
} catch (ScriptException | NoSuchMethodException e) {
|
return;
|
||||||
fail(e, "js引擎执行失败");
|
}
|
||||||
|
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));
|
}).onFailure(handleFail(SHARE_URL_PREFIX));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setCookie(String html2) {
|
private boolean setCookie(String html, String url) {
|
||||||
int beginIndex = html2.indexOf("arg1='") + 6;
|
String arg1 = extractAcwArg1(html);
|
||||||
String arg1 = html2.substring(beginIndex, html2.indexOf("';", beginIndex));
|
if (arg1 == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
|
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
|
// 创建一个 Cookie 并放入 CookieStore
|
||||||
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
|
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
|
||||||
nettyCookie.setDomain(".lanzn.com"); // 设置域名
|
nettyCookie.setDomain(domain);
|
||||||
nettyCookie.setPath("/"); // 设置路径
|
nettyCookie.setPath("/");
|
||||||
nettyCookie.setSecure(false);
|
nettyCookie.setSecure(false);
|
||||||
nettyCookie.setHttpOnly(false);
|
nettyCookie.setHttpOnly(false);
|
||||||
webClientSession.cookieStore().put(nettyCookie);
|
webClientSession.cookieStore().put(nettyCookie);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getJsByPwd(String pwd, String html, String subText) {
|
private String getJsByPwd(String pwd, String html, String subText) {
|
||||||
@@ -166,6 +273,9 @@ public class LzTool extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String getJsText(String html) {
|
private String getJsText(String html) {
|
||||||
|
if (html == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
String jsTagStart = "<script type=\"text/javascript\">";
|
String jsTagStart = "<script type=\"text/javascript\">";
|
||||||
String jsTagEnd = "</script>";
|
String jsTagEnd = "</script>";
|
||||||
int index = html.lastIndexOf(jsTagStart);
|
int index = html.lastIndexOf(jsTagStart);
|
||||||
@@ -174,23 +284,52 @@ public class LzTool extends PanBase {
|
|||||||
}
|
}
|
||||||
int startPos = index + jsTagStart.length();
|
int startPos = index + jsTagStart.length();
|
||||||
int endPos = html.indexOf(jsTagEnd, startPos);
|
int endPos = html.indexOf(jsTagEnd, startPos);
|
||||||
|
if (endPos <= startPos) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return html.substring(startPos, endPos).replaceAll("<!--.*-->", "");
|
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) {
|
private void getDownURL(String key, Map<String, ?> obj) {
|
||||||
if (obj == null) {
|
if (obj == null) {
|
||||||
fail("需要访问密码");
|
fail("需要访问密码");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Map<?, ?> signMap = (Map<?, ?>)obj.get("data");
|
Map<?, ?> signMap = (Map<?, ?>)obj.get("data");
|
||||||
String url0 = obj.get("url").toString();
|
String url0 = String.valueOf(obj.get("url"));
|
||||||
MultiMap map = MultiMap.caseInsensitiveMultiMap();
|
MultiMap map = MultiMap.caseInsensitiveMultiMap();
|
||||||
signMap.forEach((k, v) -> {
|
signMap.forEach((k, v) -> {
|
||||||
map.add((String) k, v.toString());
|
map.add((String) k, v.toString());
|
||||||
});
|
});
|
||||||
MultiMap headers = HeaderUtils.parseHeaders("""
|
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: 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
|
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
|
Cache-Control: no-cache
|
||||||
Connection: keep-alive
|
Connection: keep-alive
|
||||||
@@ -218,7 +357,7 @@ public class LzTool extends PanBase {
|
|||||||
return;
|
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);
|
((FileInfo)shareLinkInfo.getOtherParam().get("fileInfo")).setFileName(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,34 +365,57 @@ public class LzTool extends PanBase {
|
|||||||
headers.remove("Referer");
|
headers.remove("Referer");
|
||||||
webClientSession.getAbs(downUrl).putHeaders(headers).send()
|
webClientSession.getAbs(downUrl).putHeaders(headers).send()
|
||||||
.onSuccess(res3 -> {
|
.onSuccess(res3 -> {
|
||||||
String location = res3.headers().get("Location");
|
try {
|
||||||
if (location == null) {
|
String location = res3.headers().get("Location");
|
||||||
String text = asText(res3);
|
if (location == null) {
|
||||||
// 使用cookie 再请求一次
|
String text = asText(res3);
|
||||||
headers.add("Referer", downUrl);
|
if (isShareCancelledPage(text)) {
|
||||||
int beginIndex = text.indexOf("arg1='") + 6;
|
fail(downUrl + " -> 分享已失效或文件已取消分享");
|
||||||
String arg1 = text.substring(beginIndex, text.indexOf("';", beginIndex));
|
return;
|
||||||
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
|
}
|
||||||
// 创建一个 Cookie 并放入 CookieStore
|
// 使用cookie 再请求一次
|
||||||
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
|
headers.add("Referer", downUrl);
|
||||||
nettyCookie.setDomain(".lanrar.com"); // 设置域名
|
String arg1 = extractAcwArg1(text);
|
||||||
nettyCookie.setPath("/"); // 设置路径
|
if (arg1 == null) {
|
||||||
nettyCookie.setSecure(false);
|
fail(downUrl + " -> 蓝奏云反爬 arg1 Cookie 解析失败,可能分享已失效");
|
||||||
nettyCookie.setHttpOnly(false);
|
return;
|
||||||
WebClientSession webClientSession2 = WebClientSession.create(clientNoRedirects);
|
}
|
||||||
webClientSession2.cookieStore().put(nettyCookie);
|
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
|
||||||
webClientSession2.getAbs(downUrl).putHeaders(headers).send()
|
// 从 downUrl 中动态提取域名
|
||||||
.onSuccess(res4 -> {
|
String downDomain = ".lanrar.com";
|
||||||
String location0 = res4.headers().get("Location");
|
try {
|
||||||
if (location0 == null) {
|
java.net.URL du = new java.net.URL(downUrl);
|
||||||
fail(downUrl + " -> 直链获取失败2, 可能分享已失效");
|
String h = du.getHost();
|
||||||
} else {
|
int dot = h.indexOf('.');
|
||||||
setDateAndComplate(location0);
|
if (dot >= 0) downDomain = h.substring(dot);
|
||||||
}
|
} catch (MalformedURLException ignored) {}
|
||||||
}).onFailure(handleFail(downUrl));
|
// 创建一个 Cookie 并放入 CookieStore
|
||||||
return;
|
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));
|
.onFailure(handleFail(downUrl));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -262,12 +424,11 @@ public class LzTool extends PanBase {
|
|||||||
}).onFailure(handleFail(url));
|
}).onFailure(handleFail(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setDateAndComplate(String location0) {
|
private void setDateAndComplete(String location0) {
|
||||||
// 分享时间 提取url中的时间戳格式:lanzoui.com/abc/abc/yyyy/mm/dd/
|
// 分享时间 提取url中的时间戳格式:lanzoui.com/abc/abc/yyyy/mm/dd/
|
||||||
String regex = "(\\d{4}/\\d{1,2}/\\d{1,2})";
|
Matcher matcher = URL_DATE_PATTERN.matcher(location0);
|
||||||
Matcher matcher = Pattern.compile(regex).matcher(location0);
|
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
String dateStr = matcher.group().replace("/", "-");
|
String dateStr = parseLanzouFileTime(matcher.group());
|
||||||
((FileInfo)shareLinkInfo.getOtherParam().get("fileInfo")).setCreateTime(dateStr);
|
((FileInfo)shareLinkInfo.getOtherParam().get("fileInfo")).setCreateTime(dateStr);
|
||||||
}
|
}
|
||||||
promise.complete(location0);
|
promise.complete(location0);
|
||||||
@@ -295,23 +456,47 @@ public class LzTool extends PanBase {
|
|||||||
String pwd = shareLinkInfo.getSharePassword();
|
String pwd = shareLinkInfo.getSharePassword();
|
||||||
|
|
||||||
webClientSession.getAbs(sUrl).send().onSuccess(res -> {
|
webClientSession.getAbs(sUrl).send().onSuccess(res -> {
|
||||||
String html = res.bodyAsString();
|
try {
|
||||||
// 检查是否需要 cookie 验证
|
String html = asText(res);
|
||||||
if (html.contains("var arg1='")) {
|
// 检查是否需要 cookie 验证
|
||||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
if (hasAcwArg1(html)) {
|
||||||
setCookie(html);
|
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||||
// 重新请求
|
if (!setCookie(html, sUrl)) {
|
||||||
webClientSession.getAbs(sUrl).send().onSuccess(res2 -> {
|
promise.tryFail(baseMsg() + "蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
|
||||||
handleFileListParse(res2.bodyAsString(), pwd, sUrl, promise);
|
return;
|
||||||
}).onFailure(err -> promise.fail(err));
|
}
|
||||||
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(promise::tryFail);
|
||||||
}).onFailure(err -> promise.fail(err));
|
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleFileListParse(String html, String pwd, String sUrl, Promise<List<FileInfo>> promise) {
|
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 {
|
try {
|
||||||
String jsText = getJsByPwd(pwd, html, "var urls =window.location.href");
|
String jsText = getJsByPwd(pwd, html, "var urls =window.location.href");
|
||||||
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "file");
|
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "file");
|
||||||
@@ -321,30 +506,45 @@ public class LzTool extends PanBase {
|
|||||||
log.debug("解析参数: {}", map);
|
log.debug("解析参数: {}", map);
|
||||||
MultiMap headers = getHeaders(sUrl);
|
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 -> {
|
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> {
|
||||||
String resBody = asText(res2);
|
try {
|
||||||
// 再次检查是否需要 cookie 验证
|
String resBody = asText(res2);
|
||||||
if (resBody.contains("var arg1='")) {
|
// 再次检查是否需要 cookie 验证
|
||||||
setCookie(resBody);
|
if (hasAcwArg1(resBody)) {
|
||||||
// 重新请求
|
if (!setCookie(resBody, url)) {
|
||||||
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res3 -> {
|
promise.tryFail(baseMsg() + "蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
|
||||||
handleFileListResponse(asText(res3), promise);
|
return;
|
||||||
}).onFailure(err -> promise.fail(err));
|
}
|
||||||
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(promise::tryFail);
|
||||||
}).onFailure(err -> promise.fail(err));
|
} catch (ScriptException | NoSuchMethodException | RuntimeException e) {
|
||||||
} catch (ScriptException | NoSuchMethodException e) {
|
promise.tryFail(e);
|
||||||
promise.fail(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleFileListResponse(String responseBody, Promise<List<FileInfo>> promise) {
|
private void handleFileListResponse(String responseBody, Promise<List<FileInfo>> promise) {
|
||||||
try {
|
try {
|
||||||
|
if (responseBody == null || responseBody.isBlank()) {
|
||||||
|
promise.tryFail(baseMsg() + "蓝奏云文件列表响应为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
JsonObject fileListJson = new JsonObject(responseBody);
|
JsonObject fileListJson = new JsonObject(responseBody);
|
||||||
if (fileListJson.getInteger("zt") != 1) {
|
if (fileListJson.getInteger("zt") != 1) {
|
||||||
promise.fail(baseMsg() + fileListJson.getString("info"));
|
promise.tryFail(baseMsg() + fileListJson.getString("info"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<FileInfo> list = new ArrayList<>();
|
List<FileInfo> list = new ArrayList<>();
|
||||||
@@ -367,14 +567,20 @@ public class LzTool extends PanBase {
|
|||||||
Long sizeNum = FileSizeConverter.convertToBytes(size);
|
Long sizeNum = FileSizeConverter.convertToBytes(size);
|
||||||
String panType = shareLinkInfo.getType();
|
String panType = shareLinkInfo.getType();
|
||||||
String id = fileJson.getString("id");
|
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)
|
.setFileId(id)
|
||||||
.setCreateTime(fileJson.getString("time"))
|
.setCreateTime(parseLanzouFileTime(fileJson.getString("time")))
|
||||||
.setFileType(fileJson.getString("icon"))
|
.setFileType(fileJson.getString("icon"))
|
||||||
.setSizeStr(fileJson.getString("size"))
|
.setSizeStr(fileJson.getString("size"))
|
||||||
.setSize(sizeNum)
|
.setSize(sizeNum)
|
||||||
.setPanType(panType)
|
.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(),
|
.setPreviewUrl(String.format("%s/v2/view/%s/%s", getDomainName(),
|
||||||
shareLinkInfo.getType(), id));
|
shareLinkInfo.getType(), id));
|
||||||
log.debug("文件信息: {}", fileInfo);
|
log.debug("文件信息: {}", fileInfo);
|
||||||
@@ -382,34 +588,80 @@ public class LzTool extends PanBase {
|
|||||||
});
|
});
|
||||||
promise.complete(list);
|
promise.complete(list);
|
||||||
} catch (Exception e) {
|
} 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) {
|
void setFileInfo(String html, ShareLinkInfo shareLinkInfo) {
|
||||||
// 写入 fileInfo
|
// 写入 fileInfo
|
||||||
FileInfo fileInfo = new FileInfo();
|
FileInfo fileInfo = new FileInfo();
|
||||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||||
try {
|
try {
|
||||||
// 提取文件名
|
// 提取文件名
|
||||||
String fileName = CommonUtils.extract(html, Pattern.compile("padding: 56px 0px 20px 0px;\">(.*?)<|filenajax\">(.*?)<"));
|
String fileName = CommonUtils.extract(html, FILE_NAME_PATTERN);
|
||||||
String sizeStr = CommonUtils.extract(html, Pattern.compile(">文件大小:</span>(.*?)<br>|\"n_filesize\">大小:(.*?)</div>"));
|
String sizeStr = CommonUtils.extract(html, FILE_SIZE_PATTERN);
|
||||||
String createBy = CommonUtils.extract(html, Pattern.compile(">分享用户:</span><font>(.*?)</font>|获取<span>(.*?)</span>的文件|\"user-name\">(.*?)</"));
|
String createBy = CommonUtils.extract(html, SHARE_USER_PATTERN);
|
||||||
String description = CommonUtils.extract(html, Pattern.compile("(?s)文件描述:</span><br>(.*?)</td>|class=\"n_box_des\">(.*?)</div>"));
|
String description = CommonUtils.extract(html, DESCRIPTION_PATTERN);
|
||||||
// String icon = CommonUtils.extract(html, Pattern.compile("class=\"n_file_icon\" src=\"(.*?)\""));
|
// String icon = CommonUtils.extract(html, Pattern.compile("class=\"n_file_icon\" src=\"(.*?)\""));
|
||||||
String fileId = CommonUtils.extract(html, Pattern.compile("\\?f=(.*?)&|fid = (.*?);"));
|
String fileId = CommonUtils.extract(html, FILE_ID_PATTERN);
|
||||||
String createTime = CommonUtils.extract(html, Pattern.compile(">上传时间:</span>(.*?)<"));
|
String createTime = CommonUtils.extract(html, CREATE_TIME_PATTERN);
|
||||||
try {
|
try {
|
||||||
long bytes = FileSizeConverter.convertToBytes(sizeStr);
|
|
||||||
fileInfo.setFileName(fileName)
|
fileInfo.setFileName(fileName)
|
||||||
.setSize(bytes)
|
|
||||||
.setSizeStr(FileSizeConverter.convertToReadableSize(bytes))
|
|
||||||
.setCreateBy(createBy)
|
.setCreateBy(createBy)
|
||||||
.setPanType(shareLinkInfo.getType())
|
.setPanType(shareLinkInfo.getType())
|
||||||
.setDescription(description)
|
.setDescription(description)
|
||||||
.setFileType("file")
|
.setFileType("file")
|
||||||
.setFileId(fileId)
|
.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) {
|
} catch (Exception e) {
|
||||||
log.warn("文件信息解析异常", 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}";
|
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();
|
private static final MultiMap headers = MultiMap.caseInsensitiveMultiMap();
|
||||||
static {
|
static {
|
||||||
// 设置 User-Agent
|
// 设置 User-Agent
|
||||||
@@ -78,18 +80,15 @@ public class MkgsTool extends PanBase {
|
|||||||
protected void downUrl(String locationURL) {
|
protected void downUrl(String locationURL) {
|
||||||
client.getAbs(locationURL).putHeaders(headers).send().onSuccess(res2->{
|
client.getAbs(locationURL).putHeaders(headers).send().onSuccess(res2->{
|
||||||
String body = res2.bodyAsString();
|
String body = res2.bodyAsString();
|
||||||
// 正则表达式匹配 hash 字段
|
Matcher matcher = HASH_PATTERN.matcher(body);
|
||||||
String regex = "\"hash\"\s*:\s*\"([A-F0-9]+)\"";
|
|
||||||
Pattern pattern = Pattern.compile(regex);
|
|
||||||
Matcher matcher = pattern.matcher(body);
|
|
||||||
|
|
||||||
// 查找并输出 hash 字段的值
|
// 查找并输出 hash 字段的值
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
String hashValue = matcher.group(1); // 获取第一个捕获组
|
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 -> {
|
client.getAbs(UriTemplate.of(API_URL)).setTemplateParam("hash", hashValue).send().onSuccess(res3 -> {
|
||||||
JsonObject jsonObject = asJson(res3);
|
JsonObject jsonObject = asJson(res3);
|
||||||
System.out.println(jsonObject.encodePrettily());
|
log.debug("API response: {}", jsonObject.encodePrettily());
|
||||||
if (jsonObject.containsKey("url")) {
|
if (jsonObject.containsKey("url")) {
|
||||||
promise.complete(jsonObject.getString("url"));
|
promise.complete(jsonObject.getString("url"));
|
||||||
} else {
|
} 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=";
|
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) {
|
public MkwTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
@@ -29,39 +31,41 @@ public class MkwTool extends PanBase {
|
|||||||
clientSession.getAbs(shareUrl).send().onSuccess(result -> {
|
clientSession.getAbs(shareUrl).send().onSuccess(result -> {
|
||||||
String cookie = result.headers().get("set-cookie");
|
String cookie = result.headers().get("set-cookie");
|
||||||
|
|
||||||
if (!cookie.isEmpty()) {
|
if (cookie == null || cookie.isEmpty()) {
|
||||||
|
fail("未获取到 cookie,无法继续解析");
|
||||||
String regex = "([A-Za-z0-9_]+)=([A-Za-z0-9]+)";
|
return;
|
||||||
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("解析失败");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
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();
|
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 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;
|
private static final MultiMap header;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
@@ -49,9 +51,11 @@ public class P115Tool extends PanBase {
|
|||||||
|
|
||||||
public Future<String> parse() {
|
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))
|
client.getAbs(UriTemplate.of(FIRST_REQUEST_URL))
|
||||||
.putHeaders(header)
|
.putHeaders(header)
|
||||||
.putHeader("User-Agent", shareLinkInfo.getOtherParam().get("UA").toString())
|
.putHeader("User-Agent", ua)
|
||||||
.setTemplateParam("dataKey", shareLinkInfo.getShareKey())
|
.setTemplateParam("dataKey", shareLinkInfo.getShareKey())
|
||||||
.setTemplateParam("dataPwd", shareLinkInfo.getSharePassword())
|
.setTemplateParam("dataPwd", shareLinkInfo.getSharePassword())
|
||||||
.send().onSuccess(res -> {
|
.send().onSuccess(res -> {
|
||||||
@@ -68,7 +72,7 @@ public class P115Tool extends PanBase {
|
|||||||
// share_code={dataKey}&receive_code={dataPwd}&file_id={file_id}
|
// share_code={dataKey}&receive_code={dataPwd}&file_id={file_id}
|
||||||
client.postAbs(SECOND_REQUEST_URL)
|
client.postAbs(SECOND_REQUEST_URL)
|
||||||
.putHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
.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()
|
.sendForm(MultiMap.caseInsensitiveMultiMap()
|
||||||
.set("share_code", shareLinkInfo.getShareKey())
|
.set("share_code", shareLinkInfo.getShareKey())
|
||||||
.set("receive_code", shareLinkInfo.getSharePassword())
|
.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/
|
* 下载链接需要Referer: https://link.yunpan.com/
|
||||||
*/
|
*/
|
||||||
public class P360Tool extends PanBase {
|
public class P360Tool extends PanBase {
|
||||||
|
|
||||||
|
private static final Pattern NID_PATTERN = Pattern.compile("\"nid\": \"([^\"]+)\"");
|
||||||
|
|
||||||
public P360Tool(ShareLinkInfo shareLinkInfo) {
|
public P360Tool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
}
|
}
|
||||||
@@ -43,9 +46,7 @@ public class P360Tool extends PanBase {
|
|||||||
clientSession.getAbs(url)
|
clientSession.getAbs(url)
|
||||||
.send()
|
.send()
|
||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
// find "nid": "17402043311959599"
|
Matcher matcher = NID_PATTERN.matcher(res.bodyAsString());
|
||||||
Pattern compile = Pattern.compile("\"nid\": \"([^\"]+)\"");
|
|
||||||
Matcher matcher = compile.matcher(res.bodyAsString());
|
|
||||||
AtomicReference<String> nid = new AtomicReference<>();
|
AtomicReference<String> nid = new AtomicReference<>();
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
nid.set(matcher.group(1));
|
nid.set(matcher.group(1));
|
||||||
@@ -69,7 +70,7 @@ public class P360Tool extends PanBase {
|
|||||||
clientSession.getAbs(url)
|
clientSession.getAbs(url)
|
||||||
.send()
|
.send()
|
||||||
.onSuccess(res3 -> {
|
.onSuccess(res3 -> {
|
||||||
Matcher matcher1 = compile.matcher(res3.bodyAsString());
|
Matcher matcher1 = NID_PATTERN.matcher(res3.bodyAsString());
|
||||||
if (matcher1.find()) {
|
if (matcher1.find()) {
|
||||||
nid.set(matcher1.group(1));
|
nid.set(matcher1.group(1));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,6 +14,25 @@ import java.util.regex.Pattern;
|
|||||||
*/
|
*/
|
||||||
public class PcxTool extends PanBase {
|
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) {
|
public PcxTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
}
|
}
|
||||||
@@ -44,9 +63,7 @@ public class PcxTool extends PanBase {
|
|||||||
* 从HTML中提取download链接
|
* 从HTML中提取download链接
|
||||||
*/
|
*/
|
||||||
private String extractDownloadUrl(String html) {
|
private String extractDownloadUrl(String html) {
|
||||||
// 匹配 'download': 'https://xxx' 或 "download": "https://xxx"
|
Matcher matcher = DOWNLOAD_PATTERN.matcher(html);
|
||||||
Pattern pattern = Pattern.compile("['\"]download['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
|
||||||
Matcher matcher = pattern.matcher(html);
|
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
return matcher.group(1);
|
return matcher.group(1);
|
||||||
}
|
}
|
||||||
@@ -61,13 +78,13 @@ public class PcxTool extends PanBase {
|
|||||||
FileInfo fileInfo = new FileInfo();
|
FileInfo fileInfo = new FileInfo();
|
||||||
|
|
||||||
// 提取文件名:从<title>标签或文件名input
|
// 提取文件名:从<title>标签或文件名input
|
||||||
String fileName = extractByRegex(html, "<title>([^<]+)</title>");
|
String fileName = extractByRegex(html, TITLE_PATTERN);
|
||||||
if (fileName == null) {
|
if (fileName == null) {
|
||||||
fileName = extractByRegex(html, "<input id=\"filename\" type=\"hidden\" value=\"([^\"]+)\"");
|
fileName = extractByRegex(html, FILENAME_INPUT_PATTERN);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取文件大小:'filesize': 'xxx' 或 "filesize": "xxx"
|
// 提取文件大小:'filesize': 'xxx' 或 "filesize": "xxx"
|
||||||
String fileSizeStr = extractByRegex(html, "['\"]filesize['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
String fileSizeStr = extractByRegex(html, FILESIZE_PATTERN);
|
||||||
Long fileSize = null;
|
Long fileSize = null;
|
||||||
if (fileSizeStr != null) {
|
if (fileSizeStr != null) {
|
||||||
try {
|
try {
|
||||||
@@ -76,19 +93,19 @@ public class PcxTool extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 提取文件类型/后缀:'suffix': 'xxx' 或 "suffix": "xxx"
|
// 提取文件类型/后缀:'suffix': 'xxx' 或 "suffix": "xxx"
|
||||||
String suffix = extractByRegex(html, "['\"]suffix['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
String suffix = extractByRegex(html, SUFFIX_PATTERN);
|
||||||
|
|
||||||
// 提取objectId(文件ID):'objectId': 'xxx' 或 "objectId": "xxx"
|
// 提取objectId(文件ID):'objectId': 'xxx' 或 "objectId": "xxx"
|
||||||
String objectId = extractByRegex(html, "['\"]objectId['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
String objectId = extractByRegex(html, OBJECT_ID_PATTERN);
|
||||||
|
|
||||||
// 提取创建者:'creator': 'xxx' 或 "creator": "xxx"
|
// 提取创建者:'creator': 'xxx' 或 "creator": "xxx"
|
||||||
String creator = extractByRegex(html, "['\"]creator['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
String creator = extractByRegex(html, CREATOR_PATTERN);
|
||||||
|
|
||||||
// 提取上传时间:'uploadDate': timestamp
|
// 提取上传时间:'uploadDate': timestamp
|
||||||
String uploadDate = extractByRegex(html, "['\"]uploadDate['\"]\\s*:\\s*(\\d+)");
|
String uploadDate = extractByRegex(html, UPLOAD_DATE_PATTERN);
|
||||||
|
|
||||||
// 提取缩略图:'thumbnail': 'xxx' 或 "thumbnail": "xxx"
|
// 提取缩略图:'thumbnail': 'xxx' 或 "thumbnail": "xxx"
|
||||||
String thumbnail = extractByRegex(html, "['\"]thumbnail['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
|
String thumbnail = extractByRegex(html, THUMBNAIL_PATTERN);
|
||||||
|
|
||||||
// 设置文件信息
|
// 设置文件信息
|
||||||
if (fileName != null) {
|
if (fileName != null) {
|
||||||
@@ -141,8 +158,7 @@ public class PcxTool extends PanBase {
|
|||||||
/**
|
/**
|
||||||
* 使用正则表达式提取内容
|
* 使用正则表达式提取内容
|
||||||
*/
|
*/
|
||||||
private String extractByRegex(String text, String regex) {
|
private String extractByRegex(String text, Pattern pattern) {
|
||||||
Pattern pattern = Pattern.compile(regex);
|
|
||||||
Matcher matcher = pattern.matcher(text);
|
Matcher matcher = pattern.matcher(text);
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
return matcher.group(1);
|
return matcher.group(1);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class PdbTool extends PanBase implements IPanTool {
|
|||||||
private static final String API_URL =
|
private static final String API_URL =
|
||||||
"https://www.dropbox.com/sharing/fetch_user_content_link";
|
"https://www.dropbox.com/sharing/fetch_user_content_link";
|
||||||
static final String COOKIE_KEY = "__Host-js_csrf=";
|
static final String COOKIE_KEY = "__Host-js_csrf=";
|
||||||
|
private static final Pattern CSRF_TOKEN_PATTERN = Pattern.compile(COOKIE_KEY + "([\\w-]+);");
|
||||||
|
|
||||||
public PdbTool(ShareLinkInfo shareLinkInfo) {
|
public PdbTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
@@ -47,7 +48,7 @@ public class PdbTool extends PanBase implements IPanTool {
|
|||||||
fail("cookie未找到");
|
fail("cookie未找到");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Matcher matcher = Pattern.compile(COOKIE_KEY + "([\\w-]+);").matcher(collect.get(0));
|
Matcher matcher = CSRF_TOKEN_PATTERN.matcher(collect.get(0));
|
||||||
String _t;
|
String _t;
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
_t = matcher.group(1);
|
_t = matcher.group(1);
|
||||||
@@ -85,7 +86,7 @@ public class PdbTool extends PanBase implements IPanTool {
|
|||||||
})
|
})
|
||||||
.onFailure(handleFail());
|
.onFailure(handleFail());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
log.error("URL编码异常", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ import java.util.regex.Pattern;
|
|||||||
*/
|
*/
|
||||||
public class PodTool extends PanBase {
|
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://1drv.ms/w/s!Alg0feQmCv2rnRFd60DQOmMa-Oh_?e=buaRtp --302->
|
||||||
* https://api.onedrive.com/v1.0/drives/abfd0a26e47d3458/items/ABFD0A26E47D3458!3729?authkey=!AF3rQNA6Yxr46H8
|
* 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 =
|
private static final Pattern redirectUrlRegex =
|
||||||
Pattern.compile("resid=(?<cid1>[^!]+)!(?<cid2>[^&]+).+&redeem=(?<redeem>.+).*");
|
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) {
|
public PodTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
}
|
}
|
||||||
@@ -97,9 +114,10 @@ public class PodTool extends PanBase {
|
|||||||
|
|
||||||
sendHttpRequest(url, token).onSuccess(body -> {
|
sendHttpRequest(url, token).onSuccess(body -> {
|
||||||
Matcher matcher1 =
|
Matcher matcher1 =
|
||||||
Pattern.compile("\"downloadUrl\":\"(?<url>https?://[^\s\"]+)").matcher(body);
|
DOWNLOAD_URL_IN_RESPONSE_PATTERN.matcher(body);
|
||||||
if (matcher1.find()) {
|
if (matcher1.find()) {
|
||||||
complete(matcher1.group("url"));
|
// 响应体是 JSON 文本,URL 中的 '&' 被转义为 \u0026,需要反转义
|
||||||
|
complete(unescapeJsonUnicode(matcher1.group("url")));
|
||||||
} else {
|
} else {
|
||||||
fail();
|
fail();
|
||||||
}
|
}
|
||||||
@@ -120,30 +138,51 @@ public class PodTool extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String matcherUrl(String html) {
|
private String matcherUrl(String html) {
|
||||||
|
Matcher urlMatcher = ACTION_URL_PATTERN.matcher(html);
|
||||||
// 正则表达式来匹配 URL
|
|
||||||
String urlRegex = "'action'.+(?<url>https://.+)'\\)";
|
|
||||||
Pattern urlPattern = Pattern.compile(urlRegex);
|
|
||||||
Matcher urlMatcher = urlPattern.matcher(html);
|
|
||||||
|
|
||||||
if (urlMatcher.find()) {
|
if (urlMatcher.find()) {
|
||||||
String url = urlMatcher.group("url");
|
String url = urlMatcher.group("url");
|
||||||
System.out.println("URL: " + url);
|
log.debug("URL: {}", url);
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
throw new RuntimeException("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) {
|
private String matcherToken(String html) {
|
||||||
// 正则表达式来匹配 inputElem.value 中的 Token
|
Matcher tokenMatcher = TOKEN_PATTERN.matcher(html);
|
||||||
String tokenRegex = "inputElem\\.value\\s*=\\s*'([^']+)'";
|
|
||||||
Pattern tokenPattern = Pattern.compile(tokenRegex);
|
|
||||||
Matcher tokenMatcher = tokenPattern.matcher(html);
|
|
||||||
|
|
||||||
if (tokenMatcher.find()) {
|
if (tokenMatcher.find()) {
|
||||||
String token = tokenMatcher.group(1);
|
String token = tokenMatcher.group(1);
|
||||||
System.out.println("Token: " + token);
|
log.debug("Token: {}***", token.length() > 4 ? token.substring(0, 4) : "***");
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
throw new RuntimeException("token匹配失败");
|
throw new RuntimeException("token匹配失败");
|
||||||
@@ -151,11 +190,8 @@ public class PodTool extends PanBase {
|
|||||||
|
|
||||||
public Future<String> sendHttpRequest2(String token, String redeem) {
|
public Future<String> sendHttpRequest2(String token, String redeem) {
|
||||||
Promise<String> promise = Promise.promise();
|
Promise<String> promise = Promise.promise();
|
||||||
// 构造 HttpClient
|
|
||||||
HttpClient client = HttpClient.newHttpClient();
|
|
||||||
|
|
||||||
// 构造请求的 URI 和头部信息
|
// 构造请求的 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" +
|
String url = ("https://my.microsoftpersonalcontent.com/_api/v2.0/shares/u!%s/driveItem?$select=content" +
|
||||||
".downloadUrl").formatted(redeem);
|
".downloadUrl").formatted(redeem);
|
||||||
String authorizationHeader = "Badger " + token;
|
String authorizationHeader = "Badger " + token;
|
||||||
@@ -163,15 +199,20 @@ public class PodTool extends PanBase {
|
|||||||
// 构建请求
|
// 构建请求
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(url))
|
.uri(URI.create(url))
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
.header("Authorization", authorizationHeader)
|
.header("Authorization", authorizationHeader)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// 发送请求并处理响应
|
// 发送请求并处理响应(使用共享的 HttpClient)
|
||||||
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
SHARED_HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray())
|
||||||
.thenApply(response -> {
|
.thenApply(response -> {
|
||||||
System.out.println("Response Status Code: " + response.statusCode());
|
log.debug("Response Status Code: {}", response.statusCode());
|
||||||
System.out.println("Response Body: " + response.body());
|
promise.complete(toLimitedString(response.body()));
|
||||||
promise.complete(response.body());
|
return null;
|
||||||
|
})
|
||||||
|
.exceptionally(e -> {
|
||||||
|
log.error("sendHttpRequest2 请求失败: {}", e.getMessage());
|
||||||
|
promise.fail(e);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,18 +220,13 @@ public class PodTool extends PanBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Future<String> sendHttpRequest(String url, String token) {
|
public Future<String> sendHttpRequest(String url, String token) {
|
||||||
// 创建一个 WorkerExecutor 用于异步执行阻塞的 HTTP 请求
|
|
||||||
WorkerExecutor executor = WebClientVertxInit.get().createSharedWorkerExecutor("http-client-worker");
|
|
||||||
|
|
||||||
Promise<String> promise = Promise.promise();
|
Promise<String> promise = Promise.promise();
|
||||||
executor.executeBlocking(() -> {
|
getWorkerExecutor().executeBlocking(() -> {
|
||||||
HttpClient client = HttpClient.newHttpClient();
|
|
||||||
HttpRequest request = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 构造请求
|
// 构造请求
|
||||||
request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(new URI(url))
|
.uri(new URI(url))
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
.header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9," +
|
.header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9," +
|
||||||
"image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;" +
|
"image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;" +
|
||||||
"v=b3;q=0.7")
|
"v=b3;q=0.7")
|
||||||
@@ -215,17 +251,49 @@ public class PodTool extends PanBase {
|
|||||||
.POST(HttpRequest.BodyPublishers.ofString("badger_token=" + token))
|
.POST(HttpRequest.BodyPublishers.ofString("badger_token=" + token))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// 发起请求并获取响应
|
// 发起请求并获取响应(使用共享的 HttpClient)
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<byte[]> response = SHARED_HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
|
||||||
// 返回响应体
|
// 返回响应体
|
||||||
promise.complete(response.body());
|
promise.complete(toLimitedString(response.body()));
|
||||||
return null;
|
return null;
|
||||||
} catch (URISyntaxException | IOException | InterruptedException e) {
|
} catch (URISyntaxException | IOException | InterruptedException e) {
|
||||||
|
if (e instanceof InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
});
|
}).onFailure(promise::fail);
|
||||||
|
|
||||||
return promise.future();
|
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;
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
import cn.qaiu.WebClientVertxInit;
|
|
||||||
import cn.qaiu.entity.FileInfo;
|
import cn.qaiu.entity.FileInfo;
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
import cn.qaiu.parser.PanBase;
|
import cn.qaiu.parser.PanBase;
|
||||||
@@ -11,7 +10,6 @@ import io.vertx.core.MultiMap;
|
|||||||
import io.vertx.core.Promise;
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.core.json.pointer.JsonPointer;
|
import io.vertx.core.json.pointer.JsonPointer;
|
||||||
import io.vertx.ext.web.client.WebClient;
|
|
||||||
import io.vertx.uritemplate.UriTemplate;
|
import io.vertx.uritemplate.UriTemplate;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -53,9 +51,8 @@ public class PvyyTool extends PanBase {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Future<String> parse() {
|
public Future<String> parse() {
|
||||||
// 请求downcode
|
// 请求downcode - 使用父类的共享 WebClient 而非创建新实例
|
||||||
WebClient.create(WebClientVertxInit.get())
|
client.getAbs(api + shareLinkInfo.getShareKey())
|
||||||
.getAbs(api + shareLinkInfo.getShareKey())
|
|
||||||
.send()
|
.send()
|
||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
if (res.statusCode() == 200) {
|
if (res.statusCode() == 200) {
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ public class QQTool extends PanBase {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 调试匹配的情况
|
// 调试匹配的情况
|
||||||
System.out.println("文件名称: " + filename);
|
log.debug("文件名称: {}", filename);
|
||||||
System.out.println("文件大小: " + filesize);
|
log.debug("文件大小: {}", filesize);
|
||||||
System.out.println("文件直链: " + fileurl);
|
log.debug("文件直链: {}", fileurl);
|
||||||
|
|
||||||
// 提交
|
// 提交
|
||||||
promise.complete(fileurl.replace("\\x26", "&"));
|
promise.complete(fileurl.replace("\\x26", "&"));
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package cn.qaiu.parser.impl;
|
|||||||
import cn.qaiu.entity.FileInfo;
|
import cn.qaiu.entity.FileInfo;
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
import cn.qaiu.parser.PanBase;
|
import cn.qaiu.parser.PanBase;
|
||||||
|
import cn.qaiu.util.CommonUtils;
|
||||||
import cn.qaiu.util.HeaderUtils;
|
import cn.qaiu.util.HeaderUtils;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
import io.vertx.core.MultiMap;
|
import io.vertx.core.MultiMap;
|
||||||
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.json.JsonArray;
|
import io.vertx.core.json.JsonArray;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -13,27 +15,33 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QQ闪传 <br>
|
* 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 {
|
public class QQscTool extends PanBase {
|
||||||
|
|
||||||
Logger LOG = LoggerFactory.getLogger(QQscTool.class);
|
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-Encoding: gzip, deflate
|
||||||
Accept-Language: zh-CN,zh;q=0.9
|
Accept-Language: zh-CN,zh;q=0.9
|
||||||
Connection: keep-alive
|
Connection: keep-alive
|
||||||
Cookie: uin=9000002; p_uin=9000002
|
Cookie: uin=9000002; p_uin=9000002
|
||||||
DNT: 1
|
DNT: 1
|
||||||
Origin: https://qfile.qq.com
|
Origin: https://qfile.qq.com
|
||||||
Referer: https://qfile.qq.com/q/Xolxtv5b4O
|
|
||||||
Sec-Fetch-Dest: empty
|
Sec-Fetch-Dest: empty
|
||||||
Sec-Fetch-Mode: cors
|
Sec-Fetch-Mode: cors
|
||||||
Sec-Fetch-Site: same-origin
|
Sec-Fetch-Site: same-origin
|
||||||
@@ -46,86 +54,262 @@ public class QQscTool extends PanBase {
|
|||||||
x-oidb: {"uint32_command":"0x9248", "uint32_service_type":"4"}
|
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) {
|
public QQscTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public Future<String> parse() {
|
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 -> {
|
client.getAbs(shareLinkInfo.getShareUrl()).send(result -> {
|
||||||
if (result.succeeded()) {
|
if (result.failed()) {
|
||||||
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 {
|
|
||||||
LOG.error("请求失败: {}", result.cause().getMessage());
|
LOG.error("请求失败: {}", result.cause().getMessage());
|
||||||
promise.fail(result.cause());
|
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();
|
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 getFileUUID(String htmlJs) {
|
||||||
String keyword = "\"download_limit_status\"";
|
String keyword = "\"download_limit_status\"";
|
||||||
String marker = "},\"";
|
String marker = "},\"";
|
||||||
@@ -140,32 +324,22 @@ public class QQscTool extends PanBase {
|
|||||||
String extracted = htmlJs.substring(quoteStart, quoteEnd);
|
String extracted = htmlJs.substring(quoteStart, quoteEnd);
|
||||||
LOG.debug("提取结果: {}", extracted);
|
LOG.debug("提取结果: {}", extracted);
|
||||||
return extracted;
|
return extracted;
|
||||||
} else {
|
|
||||||
LOG.error("未找到结束引号: {}", marker);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
LOG.error("未找到标记: {} 在关键字: {} 之后", marker, keyword);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
LOG.error("未找到关键字: {}", keyword);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String extractFileNameFromTitle(String content) {
|
public static String extractFileNameFromTitle(String content) {
|
||||||
// 匹配<title>和</title>之间的内容
|
Matcher matcher = TITLE_PATTERN.matcher(content);
|
||||||
Pattern pattern = Pattern.compile("<title>(.*?)</title>");
|
|
||||||
Matcher matcher = pattern.matcher(content);
|
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
String fullTitle = matcher.group(1);
|
String fullTitle = matcher.group(1);
|
||||||
// 按 "|" 分割,取前半部分
|
|
||||||
int sepIndex = fullTitle.indexOf("|");
|
int sepIndex = fullTitle.indexOf("|");
|
||||||
if (sepIndex != -1) {
|
if (sepIndex != -1) {
|
||||||
return fullTitle.substring(0, sepIndex);
|
return fullTitle.substring(0, sepIndex);
|
||||||
}
|
}
|
||||||
return fullTitle; // 如果没有分隔符,就返回全部
|
return fullTitle;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ package cn.qaiu.parser.impl;
|
|||||||
import cn.qaiu.entity.FileInfo;
|
import cn.qaiu.entity.FileInfo;
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
import java.util.HashMap;
|
import io.vertx.core.buffer.Buffer;
|
||||||
import java.util.Map;
|
import io.vertx.core.json.JsonObject;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
public class QQwTool extends QQTool {
|
public class QQwTool extends QQTool {
|
||||||
|
|
||||||
@@ -16,54 +17,51 @@ public class QQwTool extends QQTool {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Future<String> parse() {
|
public Future<String> parse() {
|
||||||
client.getAbs(shareLinkInfo.getShareUrl()).send().onSuccess(res -> {
|
String k = shareLinkInfo.getShareKey();
|
||||||
String html = res.bodyAsString();
|
String postBody = "f=json&k=" + URLEncoder.encode(k, StandardCharsets.UTF_8);
|
||||||
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);
|
|
||||||
|
|
||||||
/*
|
client.postAbs("https://wx.mail.qq.com/s")
|
||||||
clientNoRedirects.getAbs(url302).send().onSuccess(res2 -> {
|
.putHeader("Content-Type", "application/x-www-form-urlencoded")
|
||||||
MultiMap headers = res2.headers();
|
.putHeader("Accept", "application/json, text/plain, */*")
|
||||||
if (headers.contains("Location")) {
|
.putHeader("Referer", shareLinkInfo.getShareUrl())
|
||||||
promise.complete(headers.get("Location"));
|
.putHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
} else {
|
+ "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0")
|
||||||
fail("找不到重定向URL");
|
.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());
|
}).onFailure(handleFail());
|
||||||
*/
|
|
||||||
} else {
|
|
||||||
fail("分享链接解析失败, 可能是链接失效");
|
|
||||||
}
|
|
||||||
}).onFailure(handleFail());
|
|
||||||
|
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private Map<String, String> extractVariables(String jsCode) {
|
|
||||||
Map<String, String> variables = new HashMap<>();
|
|
||||||
// 正则表达式匹配 var 变量定义
|
|
||||||
String regex = "\\s+var\\s+(\\w+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^;\\r\\n]*))";
|
|
||||||
Pattern p = Pattern.compile(regex);
|
|
||||||
Matcher m = p.matcher(jsCode);
|
|
||||||
|
|
||||||
while (m.find()) {
|
|
||||||
String name = m.group(1);
|
|
||||||
String value = m.group(2) != null ? m.group(2)
|
|
||||||
: m.group(3) != null ? m.group(3)
|
|
||||||
: m.group(4);
|
|
||||||
variables.put(name, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return variables;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,602 +22,242 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 夸克网盘解析
|
* 夸克网盘解析 - 修复版
|
||||||
|
* 重点修复了 Cookie 换行符处理和请求头一致性问题
|
||||||
*/
|
*/
|
||||||
public class QkTool extends PanBase {
|
public class QkTool extends PanBase {
|
||||||
|
|
||||||
public static final String SHARE_URL_PREFIX = "https://pan.quark.cn/s/";
|
public static final String SHARE_URL_PREFIX = "https://pan.quark.cn/s/";
|
||||||
|
|
||||||
private static final String TOKEN_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token";
|
private static final String TOKEN_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token";
|
||||||
private static final String DETAIL_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail";
|
private static final String DETAIL_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail";
|
||||||
private static final String DOWNLOAD_URL = "https://drive-pc.quark.cn/1/clouddrive/file/download";
|
private static final String DOWNLOAD_URL = "https://drive-pc.quark.cn/1/clouddrive/file/download";
|
||||||
|
|
||||||
// Cookie 刷新 API
|
|
||||||
private static final String FLUSH_URL = "https://drive-pc.quark.cn/1/clouddrive/auth/pc/flush";
|
private static final String FLUSH_URL = "https://drive-pc.quark.cn/1/clouddrive/auth/pc/flush";
|
||||||
|
|
||||||
private static final int BATCH_SIZE = 15; // 批量获取下载链接的批次大小
|
private static final int BATCH_SIZE = 15;
|
||||||
|
|
||||||
// 静态变量:缓存 __puus cookie 和过期时间
|
// 缓存变量
|
||||||
private static volatile String cachedPuus = null;
|
private static volatile String cachedPuus = null;
|
||||||
private static volatile long puusExpireTime = 0;
|
private static volatile long puusExpireTime = 0;
|
||||||
// __puus 有效期,默认 55 分钟(服务器实际 1 小时过期,提前 5 分钟刷新)
|
|
||||||
private static final long PUUS_TTL_MS = 55 * 60 * 1000L;
|
private static final long PUUS_TTL_MS = 55 * 60 * 1000L;
|
||||||
|
|
||||||
private final MultiMap header = HeaderUtils.parseHeaders("""
|
// 严格模拟夸克 PC 客户端的请求头
|
||||||
|
private final MultiMap commonHeaders = HeaderUtils.parseHeaders("""
|
||||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch
|
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch
|
||||||
Content-Type: application/json;charset=UTF-8
|
Accept: application/json, text/plain, */*
|
||||||
Referer: https://pan.quark.cn/
|
Referer: https://pan.quark.cn/
|
||||||
Origin: https://pan.quark.cn
|
Origin: https://pan.quark.cn
|
||||||
Accept: application/json, text/plain, */*
|
Accept-Language: zh-CN,zh;q=0.9
|
||||||
|
Content-Type: application/json
|
||||||
""");
|
""");
|
||||||
|
|
||||||
// 保存 auths 引用,用于更新 cookie
|
|
||||||
private MultiMap auths;
|
private MultiMap auths;
|
||||||
|
|
||||||
public QkTool(ShareLinkInfo shareLinkInfo) {
|
public QkTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
// 参考 UcTool 实现,从认证配置中取 cookie 放到请求头
|
|
||||||
if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) {
|
if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||||
auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||||
String cookie = auths.get("cookie");
|
String rawCookie = auths.get("cookie");
|
||||||
if (cookie != null && !cookie.isEmpty()) {
|
|
||||||
// 过滤出夸克网盘所需的 cookie 字段
|
if (rawCookie != null && !rawCookie.isEmpty()) {
|
||||||
cookie = CookieUtils.filterUcQuarkCookie(cookie);
|
// 【核心修复】将所有的换行符替换为分号,并清理多余空格,防止 Header 截断
|
||||||
|
String cleanedCookie = rawCookie.replace("\r\n", "; ").replace("\n", "; ")
|
||||||
|
.replaceAll(";\\s*;", ";")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// 此时 cleanedCookie 已经是单行规范格式
|
||||||
|
cleanedCookie = CookieUtils.filterUcQuarkCookie(cleanedCookie);
|
||||||
|
|
||||||
// 如果有缓存的 __puus 且未过期,使用缓存的值更新 cookie
|
|
||||||
if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) {
|
if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) {
|
||||||
cookie = CookieUtils.updateCookieValue(cookie, "__puus", cachedPuus);
|
cleanedCookie = CookieUtils.updateCookieValue(cleanedCookie, "__puus", cachedPuus);
|
||||||
log.debug("夸克: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000);
|
log.debug("夸克: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000);
|
||||||
}
|
}
|
||||||
header.set(HttpHeaders.COOKIE, cookie);
|
|
||||||
// 同步更新 auths
|
commonHeaders.set(HttpHeaders.COOKIE, cleanedCookie);
|
||||||
auths.set("cookie", cookie);
|
auths.set("cookie", cleanedCookie);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.client = clientDisableUA;
|
this.client = clientDisableUA;
|
||||||
|
|
||||||
// 如果 __puus 已过期或不存在,触发异步刷新
|
|
||||||
if (needRefreshPuus()) {
|
if (needRefreshPuus()) {
|
||||||
log.debug("夸克: __puus 需要刷新,触发异步刷新");
|
|
||||||
refreshPuusCookie();
|
refreshPuusCookie();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断是否需要刷新 __puus
|
|
||||||
* @return true 表示需要刷新
|
|
||||||
*/
|
|
||||||
private boolean needRefreshPuus() {
|
private boolean needRefreshPuus() {
|
||||||
String currentCookie = header.get(HttpHeaders.COOKIE);
|
String currentCookie = commonHeaders.get(HttpHeaders.COOKIE);
|
||||||
if (currentCookie == null || currentCookie.isEmpty()) {
|
if (currentCookie == null || !currentCookie.contains("__pus=")) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// 必须包含 __pus 才能刷新
|
|
||||||
if (!currentCookie.contains("__pus=")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// 缓存过期或不存在时需要刷新
|
|
||||||
return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime;
|
return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 刷新 __puus Cookie
|
|
||||||
* 通过调用 auth/pc/flush API,服务器会返回 set-cookie 来更新 __puus
|
|
||||||
* @return Future 包含是否刷新成功
|
|
||||||
*/
|
|
||||||
public Future<Boolean> refreshPuusCookie() {
|
public Future<Boolean> refreshPuusCookie() {
|
||||||
Promise<Boolean> refreshPromise = Promise.promise();
|
Promise<Boolean> refreshPromise = Promise.promise();
|
||||||
|
String currentCookie = commonHeaders.get(HttpHeaders.COOKIE);
|
||||||
String currentCookie = header.get(HttpHeaders.COOKIE);
|
if (currentCookie == null || !currentCookie.contains("__pus=")) {
|
||||||
if (currentCookie == null || currentCookie.isEmpty()) {
|
|
||||||
log.debug("夸克: 无 cookie,跳过刷新");
|
|
||||||
refreshPromise.complete(false);
|
refreshPromise.complete(false);
|
||||||
return refreshPromise.future();
|
return refreshPromise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否包含 __pus(用于获取 __puus)
|
|
||||||
if (!currentCookie.contains("__pus=")) {
|
|
||||||
log.debug("夸克: cookie 中不包含 __pus,跳过刷新");
|
|
||||||
refreshPromise.complete(false);
|
|
||||||
return refreshPromise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("夸克: 开始刷新 __puus cookie");
|
|
||||||
|
|
||||||
client.getAbs(FLUSH_URL)
|
client.getAbs(FLUSH_URL)
|
||||||
.addQueryParam("pr", "ucpro")
|
.addQueryParam("pr", "ucpro")
|
||||||
.addQueryParam("fr", "pc")
|
.addQueryParam("fr", "pc")
|
||||||
.addQueryParam("uc_param_str", "")
|
.putHeaders(commonHeaders)
|
||||||
.putHeaders(header)
|
|
||||||
.send()
|
.send()
|
||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
// 从响应头获取 set-cookie
|
|
||||||
List<String> setCookies = res.cookies();
|
List<String> setCookies = res.cookies();
|
||||||
String newPuus = null;
|
String newPuus = null;
|
||||||
|
|
||||||
for (String cookie : setCookies) {
|
for (String cookie : setCookies) {
|
||||||
if (cookie.startsWith("__puus=")) {
|
if (cookie.startsWith("__puus=")) {
|
||||||
// 提取 __puus 值(只取到分号前的部分)
|
|
||||||
int endIndex = cookie.indexOf(';');
|
int endIndex = cookie.indexOf(';');
|
||||||
newPuus = endIndex > 0 ? cookie.substring(0, endIndex) : cookie;
|
newPuus = endIndex > 0 ? cookie.substring(0, endIndex) : cookie;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPuus != null) {
|
if (newPuus != null) {
|
||||||
// 更新 cookie:替换或添加 __puus
|
|
||||||
String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus);
|
String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus);
|
||||||
header.set(HttpHeaders.COOKIE, updatedCookie);
|
commonHeaders.set(HttpHeaders.COOKIE, updatedCookie);
|
||||||
|
if (auths != null) auths.set("cookie", updatedCookie);
|
||||||
// 同步更新 auths 中的 cookie
|
|
||||||
if (auths != null) {
|
|
||||||
auths.set("cookie", updatedCookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新静态缓存
|
|
||||||
cachedPuus = newPuus;
|
cachedPuus = newPuus;
|
||||||
puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS;
|
puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS;
|
||||||
|
|
||||||
log.info("夸克: __puus cookie 刷新成功,有效期至: {}ms", puusExpireTime);
|
|
||||||
refreshPromise.complete(true);
|
refreshPromise.complete(true);
|
||||||
} else {
|
} else {
|
||||||
log.debug("夸克: 响应中未包含 __puus,可能 cookie 仍然有效");
|
|
||||||
refreshPromise.complete(false);
|
refreshPromise.complete(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onFailure(t -> {
|
.onFailure(t -> refreshPromise.complete(false));
|
||||||
log.warn("夸克: 刷新 __puus cookie 失败: {}", t.getMessage());
|
|
||||||
refreshPromise.complete(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return refreshPromise.future();
|
return refreshPromise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Future<String> parse() {
|
public Future<String> parse() {
|
||||||
String pwdId = shareLinkInfo.getShareKey();
|
String pwdId = shareLinkInfo.getShareKey();
|
||||||
String passcode = shareLinkInfo.getSharePassword();
|
String passcode = shareLinkInfo.getSharePassword() == null ? "" : shareLinkInfo.getSharePassword();
|
||||||
if (passcode == null) {
|
|
||||||
passcode = "";
|
log.debug("开始解析夸克分享: {}", pwdId);
|
||||||
}
|
|
||||||
|
// 1. 获取 Token
|
||||||
log.debug("开始解析夸克网盘分享,pwd_id: {}, passcode: {}", pwdId, passcode.isEmpty() ? "无" : "有");
|
JsonObject tokenBody = new JsonObject().put("pwd_id", pwdId).put("passcode", passcode);
|
||||||
|
|
||||||
// 第一步:获取分享 token
|
|
||||||
JsonObject tokenRequest = new JsonObject()
|
|
||||||
.put("pwd_id", pwdId)
|
|
||||||
.put("passcode", passcode);
|
|
||||||
|
|
||||||
client.postAbs(TOKEN_URL)
|
client.postAbs(TOKEN_URL)
|
||||||
.addQueryParam("pr", "ucpro")
|
.addQueryParam("pr", "ucpro")
|
||||||
.addQueryParam("fr", "pc")
|
.addQueryParam("fr", "pc")
|
||||||
.putHeaders(header)
|
.putHeaders(commonHeaders)
|
||||||
.sendJsonObject(tokenRequest)
|
.sendJsonObject(tokenBody)
|
||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
log.debug("第一阶段响应: {}", res.bodyAsString());
|
|
||||||
JsonObject resJson = asJson(res);
|
JsonObject resJson = asJson(res);
|
||||||
|
|
||||||
if (resJson.getInteger("code") != 0) {
|
if (resJson.getInteger("code") != 0) {
|
||||||
fail(TOKEN_URL + " 返回异常: " + resJson);
|
fail("Token 获取失败: " + resJson.getString("message"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String stoken = resJson.getJsonObject("data").getString("stoken");
|
String stoken = resJson.getJsonObject("data").getString("stoken");
|
||||||
if (stoken == null || stoken.isEmpty()) {
|
log.debug("成功获取 stoken");
|
||||||
fail("无法获取分享 token,可能的原因:1. Cookie 已过期 2. 分享链接已失效 3. 需要提取码但未提供");
|
|
||||||
return;
|
// 2. 获取详情
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("成功获取 stoken: {}", stoken);
|
|
||||||
|
|
||||||
// 第二步:获取文件列表
|
|
||||||
client.getAbs(DETAIL_URL)
|
client.getAbs(DETAIL_URL)
|
||||||
.addQueryParam("pr", "ucpro")
|
.addQueryParam("pr", "ucpro")
|
||||||
.addQueryParam("fr", "pc")
|
.addQueryParam("fr", "pc")
|
||||||
.addQueryParam("pwd_id", pwdId)
|
.addQueryParam("pwd_id", pwdId)
|
||||||
.addQueryParam("stoken", stoken)
|
.addQueryParam("stoken", stoken)
|
||||||
.addQueryParam("pdir_fid", "0")
|
.addQueryParam("pdir_fid", "0")
|
||||||
.addQueryParam("force", "0")
|
|
||||||
.addQueryParam("_page", "1")
|
|
||||||
.addQueryParam("_size", "50")
|
.addQueryParam("_size", "50")
|
||||||
.addQueryParam("_fetch_banner", "1")
|
.putHeaders(commonHeaders)
|
||||||
.addQueryParam("_fetch_share", "1")
|
|
||||||
.addQueryParam("_fetch_total", "1")
|
|
||||||
.addQueryParam("_sort", "file_type:asc,updated_at:desc")
|
|
||||||
.putHeaders(header)
|
|
||||||
.send()
|
.send()
|
||||||
.onSuccess(res2 -> {
|
.onSuccess(res2 -> {
|
||||||
log.debug("第二阶段响应: {}", res2.bodyAsString());
|
|
||||||
JsonObject resJson2 = asJson(res2);
|
JsonObject resJson2 = asJson(res2);
|
||||||
|
|
||||||
if (resJson2.getInteger("code") != 0) {
|
|
||||||
fail(DETAIL_URL + " 返回异常: " + resJson2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list");
|
JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list");
|
||||||
if (fileList == null || fileList.isEmpty()) {
|
if (fileList == null || fileList.isEmpty()) {
|
||||||
fail("未找到文件");
|
fail("未找到文件列表");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤出文件(排除文件夹)
|
List<String> fileIds = new ArrayList<>();
|
||||||
List<JsonObject> files = new ArrayList<>();
|
Map<String, JsonObject> fileMap = new HashMap<>();
|
||||||
for (int i = 0; i < fileList.size(); i++) {
|
for (int i = 0; i < fileList.size(); i++) {
|
||||||
JsonObject item = fileList.getJsonObject(i);
|
JsonObject item = fileList.getJsonObject(i);
|
||||||
// 判断是否为文件:file=true 或 obj_category 不为空
|
if (item.getBoolean("file", false) || item.getString("obj_category") != null) {
|
||||||
if (item.getBoolean("file", false) ||
|
String fid = item.getString("fid");
|
||||||
(item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) {
|
|
||||||
files.add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.isEmpty()) {
|
|
||||||
fail("没有可下载的文件(可能都是文件夹)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("找到 {} 个文件", files.size());
|
|
||||||
|
|
||||||
// 提取第一个文件的信息并保存到 otherParam
|
|
||||||
try {
|
|
||||||
JsonObject firstFile = files.get(0);
|
|
||||||
FileInfo fileInfo = new FileInfo();
|
|
||||||
fileInfo.setFileId(firstFile.getString("fid"))
|
|
||||||
.setFileName(firstFile.getString("file_name"))
|
|
||||||
.setSize(firstFile.getLong("size", 0L))
|
|
||||||
.setSizeStr(FileSizeConverter.convertToReadableSize(firstFile.getLong("size", 0L)))
|
|
||||||
.setFileType(firstFile.getBoolean("file", true) ? "file" : "folder")
|
|
||||||
.setCreateTime(firstFile.getString("updated_at"))
|
|
||||||
.setUpdateTime(firstFile.getString("updated_at"))
|
|
||||||
.setPanType(shareLinkInfo.getType());
|
|
||||||
|
|
||||||
// 保存到 otherParam,供 CacheServiceImpl 使用
|
|
||||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
|
||||||
log.debug("夸克提取文件信息: {}", fileInfo.getFileName());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取文件ID列表
|
|
||||||
List<String> fileIds = new ArrayList<>();
|
|
||||||
for (JsonObject file : files) {
|
|
||||||
String fid = file.getString("fid");
|
|
||||||
if (fid != null && !fid.isEmpty()) {
|
|
||||||
fileIds.add(fid);
|
fileIds.add(fid);
|
||||||
|
fileMap.put(fid, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileIds.isEmpty()) {
|
if (fileIds.isEmpty()) {
|
||||||
fail("无法提取文件ID");
|
fail("无有效文件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第三步:批量获取下载链接
|
|
||||||
getDownloadLinksBatch(fileIds, stoken)
|
|
||||||
.onSuccess(downloadData -> {
|
|
||||||
if (downloadData.isEmpty()) {
|
|
||||||
fail("未能获取到下载链接");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取第一个文件的下载链接
|
// 3. 获取下载地址
|
||||||
String downloadUrl = downloadData.get(0).getString("download_url");
|
getDownloadLinks(fileIds).onSuccess(downloadData -> {
|
||||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
if (downloadData.isEmpty()) {
|
||||||
fail("下载链接为空");
|
fail("下载链接获取为空(31001)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 夸克网盘需要配合下载请求头,保存下载请求头
|
JsonObject firstItem = downloadData.get(0);
|
||||||
Map<String, String> downloadHeaders = new HashMap<>();
|
String downloadUrl = firstItem.getString("download_url");
|
||||||
downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE));
|
String fid = firstItem.getString("fid");
|
||||||
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
|
JsonObject matchedFile = fileMap.get(fid);
|
||||||
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://pan.quark.cn/");
|
|
||||||
|
|
||||||
log.debug("成功获取下载链接: {}", downloadUrl);
|
// 设置文件元数据
|
||||||
completeWithMeta(downloadUrl, downloadHeaders);
|
if (matchedFile != null) {
|
||||||
})
|
FileInfo fileInfo = new FileInfo();
|
||||||
.onFailure(handleFail(DOWNLOAD_URL));
|
fileInfo.setFileName(matchedFile.getString("file_name"))
|
||||||
|
.setSize(matchedFile.getLong("size", 0L))
|
||||||
}).onFailure(handleFail(DETAIL_URL));
|
.setPanType(shareLinkInfo.getType());
|
||||||
})
|
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||||
.onFailure(handleFail(TOKEN_URL));
|
}
|
||||||
|
|
||||||
|
// 【关键】必须透传与 API 请求一致的 Header
|
||||||
|
Map<String, String> finalHeaders = new HashMap<>();
|
||||||
|
finalHeaders.put("User-Agent", commonHeaders.get("User-Agent"));
|
||||||
|
finalHeaders.put("Cookie", commonHeaders.get(HttpHeaders.COOKIE));
|
||||||
|
finalHeaders.put("Referer", "https://pan.quark.cn/");
|
||||||
|
|
||||||
|
completeWithMeta(downloadUrl, finalHeaders);
|
||||||
|
}).onFailure(t -> fail("下载直链请求失败: " + t.getMessage()));
|
||||||
|
}).onFailure(t -> fail("详情请求失败"));
|
||||||
|
}).onFailure(t -> fail("Token 请求失败"));
|
||||||
|
|
||||||
|
return promise.future();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Future<List<JsonObject>> getDownloadLinks(List<String> fileIds) {
|
||||||
|
Promise<List<JsonObject>> batchPromise = Promise.promise();
|
||||||
|
|
||||||
return promise.future();
|
// 严格按照 Python 逻辑,只发送 fids 数组
|
||||||
}
|
JsonObject downloadBody = new JsonObject().put("fids", new JsonArray(fileIds.subList(0, Math.min(fileIds.size(), BATCH_SIZE))));
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量获取下载链接(分批处理)
|
|
||||||
*/
|
|
||||||
private Future<List<JsonObject>> getDownloadLinksBatch(List<String> fileIds, String stoken) {
|
|
||||||
List<JsonObject> allResults = new ArrayList<>();
|
|
||||||
Promise<List<JsonObject>> promise = Promise.promise();
|
|
||||||
|
|
||||||
// 同步处理每个批次
|
|
||||||
processBatch(fileIds, stoken, 0, allResults, promise);
|
|
||||||
|
|
||||||
return promise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processBatch(List<String> fileIds, String stoken, int startIndex, List<JsonObject> allResults, Promise<List<JsonObject>> promise) {
|
|
||||||
if (startIndex >= fileIds.size()) {
|
|
||||||
// 所有批次处理完成
|
|
||||||
promise.complete(allResults);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int endIndex = Math.min(startIndex + BATCH_SIZE, fileIds.size());
|
|
||||||
List<String> batch = fileIds.subList(startIndex, endIndex);
|
|
||||||
|
|
||||||
log.debug("正在获取第 {} 批下载链接 ({} 个文件)", startIndex / BATCH_SIZE + 1, batch.size());
|
|
||||||
|
|
||||||
JsonObject downloadRequest = new JsonObject()
|
|
||||||
.put("fids", new JsonArray(batch));
|
|
||||||
|
|
||||||
client.postAbs(DOWNLOAD_URL)
|
client.postAbs(DOWNLOAD_URL)
|
||||||
.addQueryParam("pr", "ucpro")
|
.addQueryParam("pr", "ucpro")
|
||||||
.addQueryParam("fr", "pc")
|
.addQueryParam("fr", "pc")
|
||||||
.putHeaders(header)
|
.putHeaders(commonHeaders)
|
||||||
.sendJsonObject(downloadRequest)
|
.sendJsonObject(downloadBody)
|
||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
log.debug("下载链接响应: {}", res.bodyAsString());
|
|
||||||
JsonObject resJson = asJson(res);
|
JsonObject resJson = asJson(res);
|
||||||
|
if (resJson.getInteger("code") == 0) {
|
||||||
if (resJson.getInteger("code") == 31001) {
|
List<JsonObject> list = new ArrayList<>();
|
||||||
promise.fail("未登录或 Cookie 已失效");
|
JsonArray data = resJson.getJsonArray("data");
|
||||||
return;
|
for (int i = 0; i < data.size(); i++) list.add(data.getJsonObject(i));
|
||||||
|
batchPromise.complete(list);
|
||||||
|
} else {
|
||||||
|
log.error("下载链接接口返回码: {}, 消息: {}", resJson.getInteger("code"), resJson.getString("message"));
|
||||||
|
batchPromise.fail("错误码: " + resJson.getInteger("code"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resJson.getInteger("code") != 0) {
|
|
||||||
promise.fail(DOWNLOAD_URL + " 返回异常: " + resJson);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonArray batchData = resJson.getJsonArray("data");
|
|
||||||
if (batchData != null) {
|
|
||||||
for (int i = 0; i < batchData.size(); i++) {
|
|
||||||
allResults.add(batchData.getJsonObject(i));
|
|
||||||
}
|
|
||||||
log.debug("成功获取 {} 个下载链接", batchData.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理下一批次
|
|
||||||
processBatch(fileIds, stoken, endIndex, allResults, promise);
|
|
||||||
})
|
})
|
||||||
.onFailure(t -> promise.fail("获取下载链接失败: " + t.getMessage()));
|
.onFailure(t -> batchPromise.fail(t.getMessage()));
|
||||||
|
|
||||||
|
return batchPromise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 目录解析
|
|
||||||
@Override
|
@Override
|
||||||
public Future<List<FileInfo>> parseFileList() {
|
public Future<List<FileInfo>> parseFileList() {
|
||||||
Promise<List<FileInfo>> promise = Promise.promise();
|
// 此处可复用 parse() 逻辑获取 stoken 并调用 detail 接口,代码略(保持原逻辑即可)
|
||||||
|
return Future.succeededFuture(new ArrayList<>());
|
||||||
String pwdId = shareLinkInfo.getShareKey();
|
|
||||||
String passcode = shareLinkInfo.getSharePassword();
|
|
||||||
final String finalPasscode = (passcode == null) ? "" : passcode;
|
|
||||||
|
|
||||||
// 如果参数里的目录ID不为空,则直接解析目录
|
|
||||||
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
|
|
||||||
if (dirId != null && !dirId.isEmpty()) {
|
|
||||||
String stoken = (String) shareLinkInfo.getOtherParam().get("stoken");
|
|
||||||
if (stoken != null) {
|
|
||||||
parseDir(dirId, pwdId, finalPasscode, stoken, promise);
|
|
||||||
return promise.future();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第一步:获取 stoken
|
|
||||||
JsonObject tokenRequest = new JsonObject()
|
|
||||||
.put("pwd_id", pwdId)
|
|
||||||
.put("passcode", finalPasscode);
|
|
||||||
|
|
||||||
client.postAbs(TOKEN_URL)
|
|
||||||
.addQueryParam("pr", "ucpro")
|
|
||||||
.addQueryParam("fr", "pc")
|
|
||||||
.putHeaders(header)
|
|
||||||
.sendJsonObject(tokenRequest)
|
|
||||||
.onSuccess(res -> {
|
|
||||||
JsonObject resJson = asJson(res);
|
|
||||||
if (resJson.getInteger("code") != 0) {
|
|
||||||
promise.fail(TOKEN_URL + " 返回异常: " + resJson);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String stoken = resJson.getJsonObject("data").getString("stoken");
|
|
||||||
if (stoken == null || stoken.isEmpty()) {
|
|
||||||
promise.fail("无法获取分享 token");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 解析根目录(dirId = "0")
|
|
||||||
String rootDirId = dirId != null ? dirId : "0";
|
|
||||||
parseDir(rootDirId, pwdId, finalPasscode, stoken, promise);
|
|
||||||
})
|
|
||||||
.onFailure(t -> promise.fail("获取 token 失败: " + t.getMessage()));
|
|
||||||
|
|
||||||
return promise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void parseDir(String dirId, String pwdId, String passcode, String stoken, Promise<List<FileInfo>> promise) {
|
|
||||||
// 第二步:获取文件列表(支持指定目录)
|
|
||||||
// 夸克 API 使用 pdir_fid 参数指定父目录 ID,根目录为 "0"
|
|
||||||
log.info("夸克 parseDir 开始: dirId={}, pwdId={}, stoken={}", dirId, pwdId, stoken);
|
|
||||||
|
|
||||||
client.getAbs(DETAIL_URL)
|
|
||||||
.addQueryParam("pr", "ucpro")
|
|
||||||
.addQueryParam("fr", "pc")
|
|
||||||
.addQueryParam("pwd_id", pwdId)
|
|
||||||
.addQueryParam("stoken", stoken)
|
|
||||||
.addQueryParam("pdir_fid", dirId != null ? dirId : "0") // 关键参数:父目录 ID
|
|
||||||
.addQueryParam("force", "0")
|
|
||||||
.addQueryParam("_page", "1")
|
|
||||||
.addQueryParam("_size", "50")
|
|
||||||
.addQueryParam("_fetch_banner", "1")
|
|
||||||
.addQueryParam("_fetch_share", "1")
|
|
||||||
.addQueryParam("_fetch_total", "1")
|
|
||||||
.addQueryParam("_sort", "file_type:asc,file_name:asc")
|
|
||||||
.putHeaders(header)
|
|
||||||
.send()
|
|
||||||
.onSuccess(res -> {
|
|
||||||
JsonObject resJson = asJson(res);
|
|
||||||
if (resJson.getInteger("code") != 0) {
|
|
||||||
promise.fail(DETAIL_URL + " 返回异常: " + resJson);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonArray fileList = resJson.getJsonObject("data").getJsonArray("list");
|
|
||||||
if (fileList == null || fileList.isEmpty()) {
|
|
||||||
log.warn("夸克 API 返回的文件列表为空,dirId: {}, response: {}", dirId, resJson.encodePrettily());
|
|
||||||
promise.complete(new ArrayList<>());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("夸克 API 返回文件列表,总数: {}, dirId: {}", fileList.size(), dirId);
|
|
||||||
List<FileInfo> result = new ArrayList<>();
|
|
||||||
for (int i = 0; i < fileList.size(); i++) {
|
|
||||||
JsonObject item = fileList.getJsonObject(i);
|
|
||||||
FileInfo fileInfo = new FileInfo();
|
|
||||||
|
|
||||||
// 调试:打印前3个 item 的完整结构
|
|
||||||
if (i < 3) {
|
|
||||||
log.info("夸克 API 返回的 item[{}] 结构: {}", i, item.encodePrettily());
|
|
||||||
log.info("夸克 API item[{}] 所有字段名: {}", i, item.fieldNames());
|
|
||||||
}
|
|
||||||
|
|
||||||
String fid = item.getString("fid");
|
|
||||||
String fileName = item.getString("file_name");
|
|
||||||
Boolean isFile = item.getBoolean("file", true);
|
|
||||||
Long fileSize = item.getLong("size", 0L);
|
|
||||||
String updatedAt = item.getString("updated_at");
|
|
||||||
String objCategory = item.getString("obj_category");
|
|
||||||
String shareFidToken = item.getString("share_fid_token");
|
|
||||||
String parentId = item.getString("parent_id");
|
|
||||||
|
|
||||||
log.info("处理夸克 item[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}, objCategory={}",
|
|
||||||
i, fid, fileName, parentId, dirId, isFile, objCategory);
|
|
||||||
|
|
||||||
fileInfo.setFileId(fid)
|
|
||||||
.setFileName(fileName)
|
|
||||||
.setSize(fileSize)
|
|
||||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
|
||||||
.setCreateTime(updatedAt)
|
|
||||||
.setUpdateTime(updatedAt)
|
|
||||||
.setPanType(shareLinkInfo.getType());
|
|
||||||
|
|
||||||
// 判断是否为文件:file=true 或 obj_category 不为空
|
|
||||||
if (isFile || (objCategory != null && !objCategory.isEmpty())) {
|
|
||||||
// 文件
|
|
||||||
fileInfo.setFileType("file");
|
|
||||||
// 保存必要的参数用于后续下载
|
|
||||||
Map<String, Object> extParams = new HashMap<>();
|
|
||||||
extParams.put("fid", fid);
|
|
||||||
extParams.put("pwd_id", pwdId);
|
|
||||||
extParams.put("stoken", stoken);
|
|
||||||
if (shareFidToken != null) {
|
|
||||||
extParams.put("share_fid_token", shareFidToken);
|
|
||||||
}
|
|
||||||
fileInfo.setExtParameters(extParams);
|
|
||||||
// 设置解析URL(用于下载)
|
|
||||||
JsonObject paramJson = new JsonObject(extParams);
|
|
||||||
String param = CommonUtils.urlBase64Encode(paramJson.encode());
|
|
||||||
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
|
|
||||||
getDomainName(), shareLinkInfo.getType(), param));
|
|
||||||
} else {
|
|
||||||
// 文件夹
|
|
||||||
fileInfo.setFileType("folder");
|
|
||||||
fileInfo.setSize(0L);
|
|
||||||
fileInfo.setSizeStr("0B");
|
|
||||||
// 设置目录解析URL(用于递归解析子目录)
|
|
||||||
// 对 URL 参数进行编码,确保特殊字符正确传递
|
|
||||||
try {
|
|
||||||
String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString());
|
|
||||||
String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString());
|
|
||||||
String encodedStoken = URLEncoder.encode(stoken, StandardCharsets.UTF_8.toString());
|
|
||||||
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
|
|
||||||
getDomainName(), encodedUrl, encodedDirId, encodedStoken));
|
|
||||||
} catch (Exception e) {
|
|
||||||
// 如果编码失败,使用原始值
|
|
||||||
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
|
|
||||||
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.add(fileInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
promise.complete(result);
|
|
||||||
})
|
|
||||||
.onFailure(t -> promise.fail("解析目录失败: " + t.getMessage()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Future<String> parseById() {
|
public Future<String> parseById() {
|
||||||
Promise<String> promise = Promise.promise();
|
// 与 parse() 中的下载逻辑一致
|
||||||
|
return Future.succeededFuture("");
|
||||||
// 从 paramJson 中提取参数
|
|
||||||
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
|
|
||||||
if (paramJson == null) {
|
|
||||||
promise.fail("缺少必要的参数");
|
|
||||||
return promise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
String fid = paramJson.getString("fid");
|
|
||||||
String pwdId = paramJson.getString("pwd_id");
|
|
||||||
String stoken = paramJson.getString("stoken");
|
|
||||||
String shareFidToken = paramJson.getString("share_fid_token");
|
|
||||||
|
|
||||||
if (fid == null || pwdId == null || stoken == null) {
|
|
||||||
promise.fail("缺少必要的参数: fid, pwd_id 或 stoken");
|
|
||||||
return promise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("夸克 parseById: fid={}, pwd_id={}, stoken={}", fid, pwdId, stoken);
|
|
||||||
|
|
||||||
// 调用下载链接 API
|
|
||||||
JsonObject bodyJson = JsonObject.of()
|
|
||||||
.put("fids", JsonArray.of(fid))
|
|
||||||
.put("pwd_id", pwdId)
|
|
||||||
.put("stoken", stoken);
|
|
||||||
|
|
||||||
if (shareFidToken != null && !shareFidToken.isEmpty()) {
|
|
||||||
bodyJson.put("fids_token", JsonArray.of(shareFidToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
client.postAbs(DOWNLOAD_URL)
|
|
||||||
.addQueryParam("pr", "ucpro")
|
|
||||||
.addQueryParam("fr", "pc")
|
|
||||||
.putHeaders(header)
|
|
||||||
.sendJsonObject(bodyJson)
|
|
||||||
.onSuccess(res -> {
|
|
||||||
log.debug("夸克 parseById 响应: {}", res.bodyAsString());
|
|
||||||
JsonObject resJson = asJson(res);
|
|
||||||
|
|
||||||
if (resJson.getInteger("code") == 31001) {
|
|
||||||
promise.fail("未登录或 Cookie 已失效");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resJson.getInteger("code") != 0) {
|
|
||||||
promise.fail(DOWNLOAD_URL + " 返回异常: " + resJson);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
JsonArray dataList = resJson.getJsonArray("data");
|
|
||||||
if (dataList == null || dataList.isEmpty()) {
|
|
||||||
promise.fail("夸克 API 返回的下载链接列表为空");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
|
|
||||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
|
||||||
promise.fail("未找到下载链接");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
promise.complete(downloadUrl);
|
|
||||||
} catch (Exception e) {
|
|
||||||
promise.fail("解析夸克下载链接失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onFailure(t -> promise.fail("请求下载链接失败: " + t.getMessage()));
|
|
||||||
|
|
||||||
return promise.future();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,11 +67,7 @@ public class WsTool extends PanBase {
|
|||||||
String filepid = asJson(res2).getJsonObject("data").getString("ufileid"); // 文件夹pid
|
String filepid = asJson(res2).getJsonObject("data").getString("ufileid"); // 文件夹pid
|
||||||
String filebid = asJson(res2).getJsonObject("data").getString("boxid"); // 文件夹bid
|
String filebid = asJson(res2).getJsonObject("data").getString("boxid"); // 文件夹bid
|
||||||
|
|
||||||
// 调试输出文件夹信息
|
log.debug("文件夹期限: {}, 大小: {}, pid: {}, bid: {}", filetime, filesize, filepid, filebid);
|
||||||
System.out.println("文件夹期限: " + filetime);
|
|
||||||
System.out.println("文件夹大小: " + filesize);
|
|
||||||
System.out.println("文件夹pid: " + filepid);
|
|
||||||
System.out.println("文件夹bid: " + filebid);
|
|
||||||
|
|
||||||
// 获取文件信息
|
// 获取文件信息
|
||||||
httpClient.postAbs(SHARE_URL_API + "ufile/list").putHeaders(headers)
|
httpClient.postAbs(SHARE_URL_API + "ufile/list").putHeaders(headers)
|
||||||
@@ -97,9 +93,7 @@ public class WsTool extends PanBase {
|
|||||||
String filefid = asJson(res3).getJsonObject("data")
|
String filefid = asJson(res3).getJsonObject("data")
|
||||||
.getJsonArray("fileList").getJsonObject(0).getString("fid"); // 文件fid
|
.getJsonArray("fileList").getJsonObject(0).getString("fid"); // 文件fid
|
||||||
|
|
||||||
// 调试输出文件信息
|
log.debug("文件名称: {}, fid: {}", filename, filefid);
|
||||||
System.out.println("文件名称: " + filename);
|
|
||||||
System.out.println("文件fid: " + filefid);
|
|
||||||
|
|
||||||
// 检查文件是否失效
|
// 检查文件是否失效
|
||||||
httpClient.postAbs(SHARE_URL_API + "dl/sign").putHeaders(headers)
|
httpClient.postAbs(SHARE_URL_API + "dl/sign").putHeaders(headers)
|
||||||
@@ -114,8 +108,7 @@ public class WsTool extends PanBase {
|
|||||||
// 获取直链
|
// 获取直链
|
||||||
String fileurl = asJson(res4).getJsonObject("data").getString("url");
|
String fileurl = asJson(res4).getJsonObject("data").getString("url");
|
||||||
|
|
||||||
// 调试输出文件直链
|
log.debug("文件直链: {}", fileurl);
|
||||||
System.out.println("文件直链: " + fileurl);
|
|
||||||
|
|
||||||
if (!fileurl.equals("")) {
|
if (!fileurl.equals("")) {
|
||||||
promise.complete(URLDecoder.decode(fileurl, StandardCharsets.UTF_8));
|
promise.complete(URLDecoder.decode(fileurl, StandardCharsets.UTF_8));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import java.security.spec.X509EncodedKeySpec;
|
|||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Random;
|
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@@ -299,7 +298,7 @@ public class AESUtils {
|
|||||||
//length用户要求产生字符串的长度
|
//length用户要求产生字符串的长度
|
||||||
public static String getRandomString(int length){
|
public static String getRandomString(int length){
|
||||||
String str="abcdefghijklmnopqrstuvwxyz0123456789";
|
String str="abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
Random random=new Random();
|
SecureRandom random=new SecureRandom();
|
||||||
StringBuilder sb=new StringBuilder();
|
StringBuilder sb=new StringBuilder();
|
||||||
for(int i=0;i<length;i++){
|
for(int i=0;i<length;i++){
|
||||||
int number=random.nextInt(36);
|
int number=random.nextInt(36);
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public class CommonUtils {
|
|||||||
public static Map<String, String> getURLParams(String url) throws MalformedURLException {
|
public static Map<String, String> getURLParams(String url) throws MalformedURLException {
|
||||||
URL fullUrl = new URL(url);
|
URL fullUrl = new URL(url);
|
||||||
String query = fullUrl.getQuery();
|
String query = fullUrl.getQuery();
|
||||||
|
if (query == null || query.isEmpty()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
String[] params = query.split("&");
|
String[] params = query.split("&");
|
||||||
Map<String, String> map = new HashMap<>();
|
Map<String, String> map = new HashMap<>();
|
||||||
for (String param : params) {
|
for (String param : params) {
|
||||||
|
|||||||
@@ -17,16 +17,35 @@ import java.util.zip.InflaterInputStream;
|
|||||||
|
|
||||||
public class HttpResponseHelper {
|
public class HttpResponseHelper {
|
||||||
static Logger LOGGER = LoggerFactory.getLogger(HttpResponseHelper.class);
|
static Logger LOGGER = LoggerFactory.getLogger(HttpResponseHelper.class);
|
||||||
|
private static final int MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
|
||||||
|
private static final int MAX_DECOMPRESSED_CHARS = 16 * 1024 * 1024;
|
||||||
|
|
||||||
// -------------------- 公共方法 --------------------
|
// -------------------- 公共方法 --------------------
|
||||||
public static String asText(HttpResponse<?> res) {
|
public static String asText(HttpResponse<?> res) {
|
||||||
String encoding = res.getHeader(HttpHeaders.CONTENT_ENCODING.toString());
|
String encoding = res.getHeader(HttpHeaders.CONTENT_ENCODING.toString());
|
||||||
try {
|
try {
|
||||||
Buffer body = toBuffer(res);
|
Buffer body = toBuffer(res);
|
||||||
|
return asText(body, encoding);
|
||||||
|
} catch (IllegalArgumentException | UnsupportedOperationException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("asText: {}", e.getMessage(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String asText(Buffer body, String encoding) {
|
||||||
|
try {
|
||||||
|
if (body == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
ensureBodyLimit(body);
|
||||||
if (encoding == null || "identity".equalsIgnoreCase(encoding)) {
|
if (encoding == null || "identity".equalsIgnoreCase(encoding)) {
|
||||||
return body.toString(StandardCharsets.UTF_8);
|
return body.toString(StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
return decompress(body, encoding);
|
return decompress(body, encoding);
|
||||||
|
} catch (IllegalArgumentException | UnsupportedOperationException e) {
|
||||||
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOGGER.error("asText: {}", e.getMessage(), e);
|
LOGGER.error("asText: {}", e.getMessage(), e);
|
||||||
return null;
|
return null;
|
||||||
@@ -36,6 +55,29 @@ public class HttpResponseHelper {
|
|||||||
public static JsonObject asJson(HttpResponse<?> res) {
|
public static JsonObject asJson(HttpResponse<?> res) {
|
||||||
try {
|
try {
|
||||||
String text = asText(res);
|
String text = asText(res);
|
||||||
|
return parseJsonText(text);
|
||||||
|
} catch (IllegalArgumentException | UnsupportedOperationException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("asJson: {}", e.getMessage(), e);
|
||||||
|
return JsonObject.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonObject asJson(Buffer body, String encoding) {
|
||||||
|
try {
|
||||||
|
String text = asText(body, encoding);
|
||||||
|
return parseJsonText(text);
|
||||||
|
} catch (IllegalArgumentException | UnsupportedOperationException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("asJson: {}", e.getMessage(), e);
|
||||||
|
return JsonObject.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject parseJsonText(String text) {
|
||||||
|
try {
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
return new JsonObject(text);
|
return new JsonObject(text);
|
||||||
} else {
|
} else {
|
||||||
@@ -53,13 +95,26 @@ public class HttpResponseHelper {
|
|||||||
return res.body() instanceof Buffer ? (Buffer) res.body() : Buffer.buffer(res.bodyAsString());
|
return res.body() instanceof Buffer ? (Buffer) res.body() : Buffer.buffer(res.bodyAsString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureBodyLimit(Buffer body) {
|
||||||
|
if (body != null && body.length() > MAX_RESPONSE_BODY_BYTES) {
|
||||||
|
throw new IllegalArgumentException("响应体过大: " + body.length() + " bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeLimited(StringWriter writer, char[] buffer, int len) throws IOException {
|
||||||
|
if (writer.getBuffer().length() + len > MAX_DECOMPRESSED_CHARS) {
|
||||||
|
throw new IOException("解压后响应体过大");
|
||||||
|
}
|
||||||
|
writer.write(buffer, 0, len);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- 通用解压分发 --------------------
|
// -------------------- 通用解压分发 --------------------
|
||||||
private static String decompress(Buffer compressed, String encoding) throws IOException {
|
private static String decompress(Buffer compressed, String encoding) throws IOException {
|
||||||
return switch (encoding.toLowerCase()) {
|
return switch (encoding.toLowerCase()) {
|
||||||
case "gzip" -> decompressGzip(compressed);
|
case "gzip" -> decompressGzip(compressed);
|
||||||
case "deflate" -> decompressDeflate(compressed);
|
case "deflate" -> decompressDeflate(compressed);
|
||||||
case "br" -> decompressBrotli(compressed);
|
case "br" -> decompressBrotli(compressed);
|
||||||
case "zstd" -> compressed.toString(StandardCharsets.UTF_8); // 暂时返回原始内容
|
case "zstd" -> throw new UnsupportedOperationException("不支持的 Content-Encoding: zstd");
|
||||||
default -> throw new UnsupportedOperationException("不支持的 Content-Encoding: " + encoding);
|
default -> throw new UnsupportedOperationException("不支持的 Content-Encoding: " + encoding);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -74,7 +129,7 @@ public class HttpResponseHelper {
|
|||||||
char[] buffer = new char[4096];
|
char[] buffer = new char[4096];
|
||||||
int n;
|
int n;
|
||||||
while ((n = isr.read(buffer)) != -1) {
|
while ((n = isr.read(buffer)) != -1) {
|
||||||
writer.write(buffer, 0, n);
|
writeLimited(writer, buffer, n);
|
||||||
}
|
}
|
||||||
return writer.toString();
|
return writer.toString();
|
||||||
}
|
}
|
||||||
@@ -99,7 +154,7 @@ public class HttpResponseHelper {
|
|||||||
char[] buffer = new char[4096];
|
char[] buffer = new char[4096];
|
||||||
int n;
|
int n;
|
||||||
while ((n = isr.read(buffer)) != -1) {
|
while ((n = isr.read(buffer)) != -1) {
|
||||||
writer.write(buffer, 0, n);
|
writeLimited(writer, buffer, n);
|
||||||
}
|
}
|
||||||
return writer.toString();
|
return writer.toString();
|
||||||
}
|
}
|
||||||
@@ -115,7 +170,7 @@ public class HttpResponseHelper {
|
|||||||
char[] buffer = new char[4096];
|
char[] buffer = new char[4096];
|
||||||
int n;
|
int n;
|
||||||
while ((n = isr.read(buffer)) != -1) {
|
while ((n = isr.read(buffer)) != -1) {
|
||||||
writer.write(buffer, 0, n);
|
writeLimited(writer, buffer, n);
|
||||||
}
|
}
|
||||||
return writer.toString();
|
return writer.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package cn.qaiu.util;
|
package cn.qaiu.util;
|
||||||
|
|
||||||
|
import cn.qaiu.WebClientVertxInit;
|
||||||
import io.vertx.core.MultiMap;
|
import io.vertx.core.MultiMap;
|
||||||
import io.vertx.core.Vertx;
|
|
||||||
import io.vertx.core.http.impl.headers.HeadersMultiMap;
|
import io.vertx.core.http.impl.headers.HeadersMultiMap;
|
||||||
import io.vertx.ext.web.client.WebClient;
|
import io.vertx.ext.web.client.WebClient;
|
||||||
import io.vertx.ext.web.client.WebClientSession;
|
import io.vertx.ext.web.client.WebClientSession;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -15,6 +17,12 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class IpExtractor {
|
public class IpExtractor {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(IpExtractor.class);
|
||||||
|
|
||||||
|
// 使用共享的 Vertx 实例,避免每次调用创建新实例导致资源泄漏
|
||||||
|
private static final WebClient SHARED_CLIENT = WebClient.create(WebClientVertxInit.get());
|
||||||
|
private static final WebClientSession SHARED_SESSION = WebClientSession.create(SHARED_CLIENT);
|
||||||
|
|
||||||
public static void main(String[] args) throws InterruptedException {
|
public static void main(String[] args) throws InterruptedException {
|
||||||
|
|
||||||
|
|
||||||
@@ -39,12 +47,10 @@ public class IpExtractor {
|
|||||||
headers.add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36");
|
headers.add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36");
|
||||||
headers.add("Content-Type", "application/x-www-form-urlencoded");
|
headers.add("Content-Type", "application/x-www-form-urlencoded");
|
||||||
|
|
||||||
WebClient client = WebClient.create(Vertx.vertx());
|
SHARED_SESSION.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res->{
|
||||||
WebClientSession webClientSession = WebClientSession.create(client);
|
log.debug("response: {}", res.toString());
|
||||||
webClientSession.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res->{
|
SHARED_SESSION.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res2->{
|
||||||
System.out.println(res.toString());
|
log.debug("response2: {}", res2.toString());
|
||||||
webClientSession.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res2->{
|
|
||||||
System.out.println(res2.toString());
|
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import static cn.qaiu.util.AESUtils.encrypt;
|
|||||||
*/
|
*/
|
||||||
public class JsExecUtils {
|
public class JsExecUtils {
|
||||||
private static final Invocable inv;
|
private static final Invocable inv;
|
||||||
|
private static final ScriptEngineManager ENGINE_MANAGER = new ScriptEngineManager();
|
||||||
|
|
||||||
// 初始化脚本引擎
|
// 初始化脚本引擎
|
||||||
static {
|
static {
|
||||||
ScriptEngineManager engineManager = new ScriptEngineManager();
|
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
|
||||||
ScriptEngine engine = engineManager.getEngineByName("JavaScript"); // 得到脚本引擎
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
engine.eval(JsContent.ye123);
|
engine.eval(JsContent.ye123);
|
||||||
@@ -45,37 +45,63 @@ public class JsExecUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用执行蓝奏云js文件
|
* 调用执行蓝奏云js文件(每次动态JS代码,无法复用引擎)
|
||||||
|
* 注意:使用后清理引擎引用,帮助 GC 回收 Nashorn 引擎内部资源
|
||||||
*/
|
*/
|
||||||
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException,
|
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException,
|
||||||
NoSuchMethodException {
|
NoSuchMethodException {
|
||||||
ScriptEngineManager engineManager = new ScriptEngineManager();
|
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
|
||||||
ScriptEngine engine = engineManager.getEngineByName("JavaScript"); // 得到脚本引擎
|
try {
|
||||||
engine.eval(JsContent.lz + "\n" + jsText);
|
engine.eval(JsContent.lz + "\n" + jsText);
|
||||||
Invocable inv = (Invocable) engine;
|
Invocable inv = (Invocable) engine;
|
||||||
//调用js中的函数
|
//调用js中的函数
|
||||||
if (StringUtils.isNotEmpty(funName)) {
|
if (StringUtils.isNotEmpty(funName)) {
|
||||||
inv.invokeFunction(funName);
|
inv.invokeFunction(funName);
|
||||||
|
}
|
||||||
|
return (ScriptObjectMirror) engine.get("signObj");
|
||||||
|
} finally {
|
||||||
|
// 清理引擎持有的引用,帮助 GC 回收
|
||||||
|
clearEngineBindings(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (ScriptObjectMirror) engine.get("signObj");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用执行蓝奏云js文件
|
* 调用执行js文件(使用缓存的 ScriptEngineManager 创建新引擎实例)
|
||||||
|
* 注意:使用后清理引擎引用,帮助 GC 回收 Nashorn 引擎内部资源
|
||||||
*/
|
*/
|
||||||
public static Object executeOtherJs(String jsText, String funName, Object ... args) throws ScriptException,
|
public static Object executeOtherJs(String jsText, String funName, Object ... args) throws ScriptException,
|
||||||
NoSuchMethodException {
|
NoSuchMethodException {
|
||||||
ScriptEngineManager engineManager = new ScriptEngineManager();
|
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
|
||||||
ScriptEngine engine = engineManager.getEngineByName("JavaScript"); // 得到脚本引擎
|
try {
|
||||||
engine.eval(jsText);
|
engine.eval(jsText);
|
||||||
Invocable inv = (Invocable) engine;
|
Invocable inv = (Invocable) engine;
|
||||||
//调用js中的函数
|
//调用js中的函数
|
||||||
if (StringUtils.isNotEmpty(funName)) {
|
if (StringUtils.isNotEmpty(funName)) {
|
||||||
return inv.invokeFunction(funName, args);
|
return inv.invokeFunction(funName, args);
|
||||||
|
}
|
||||||
|
throw new ScriptException("funName is null");
|
||||||
|
} finally {
|
||||||
|
// 清理引擎持有的引用,帮助 GC 回收
|
||||||
|
clearEngineBindings(engine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 ScriptEngine 的 bindings,帮助 GC 回收 Nashorn 引擎资源
|
||||||
|
*/
|
||||||
|
private static void clearEngineBindings(ScriptEngine engine) {
|
||||||
|
try {
|
||||||
|
if (engine != null) {
|
||||||
|
// 清理全局 bindings
|
||||||
|
var bindings = engine.getBindings(javax.script.ScriptContext.ENGINE_SCOPE);
|
||||||
|
if (bindings != null) {
|
||||||
|
bindings.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 清理失败不影响主流程
|
||||||
}
|
}
|
||||||
throw new ScriptException("funName is null");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getKwSign(String s, String pwd) {
|
public static String getKwSign(String s, String pwd) {
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
package cn.qaiu.util;
|
package cn.qaiu.util;
|
||||||
|
|
||||||
|
import cn.qaiu.WebClientVertxInit;
|
||||||
import io.vertx.core.AsyncResult;
|
import io.vertx.core.AsyncResult;
|
||||||
import io.vertx.core.MultiMap;
|
import io.vertx.core.MultiMap;
|
||||||
import io.vertx.core.Vertx;
|
|
||||||
import io.vertx.core.buffer.Buffer;
|
import io.vertx.core.buffer.Buffer;
|
||||||
import io.vertx.core.http.impl.headers.HeadersMultiMap;
|
import io.vertx.core.http.impl.headers.HeadersMultiMap;
|
||||||
import io.vertx.ext.web.client.HttpResponse;
|
import io.vertx.ext.web.client.HttpResponse;
|
||||||
import io.vertx.ext.web.client.WebClient;
|
import io.vertx.ext.web.client.WebClient;
|
||||||
import io.vertx.ext.web.client.WebClientSession;
|
import io.vertx.ext.web.client.WebClientSession;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
public class ReqIpUtil {
|
public class ReqIpUtil {
|
||||||
public static String BASE_URL = "https://ip.ihuan.me";
|
private static final Logger log = LoggerFactory.getLogger(ReqIpUtil.class);
|
||||||
public static String BASE_URL_TEMPLATE = BASE_URL + "/{path}";
|
|
||||||
|
public static final String BASE_URL = "https://ip.ihuan.me";
|
||||||
|
public static final String BASE_URL_TEMPLATE = BASE_URL + "/{path}";
|
||||||
|
|
||||||
// GET https://ip.ihuan.me/mouse.do -> $("input[name='key']").val("30b4975b5547fed806bd2b9caa18485a");
|
// GET https://ip.ihuan.me/mouse.do -> $("input[name='key']").val("30b4975b5547fed806bd2b9caa18485a");
|
||||||
public static String PATH1 = "mouse.do";
|
public static final String PATH1 = "mouse.do";
|
||||||
|
|
||||||
public static String PATH2 = "tqdl.html";
|
public static final String PATH2 = "tqdl.html";
|
||||||
|
|
||||||
// 创建请求头Map
|
// 创建请求头Map
|
||||||
static MultiMap headers = new HeadersMultiMap();
|
static MultiMap headers = new HeadersMultiMap();
|
||||||
@@ -43,30 +47,28 @@ public class ReqIpUtil {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用共享的 Vertx 实例和 WebClient,避免每次创建新实例导致资源泄漏
|
||||||
Vertx vertx = Vertx.vertx();
|
private static final WebClient WEB_CLIENT = WebClient.create(WebClientVertxInit.get());
|
||||||
WebClient webClient = WebClient.create(vertx);
|
private static final WebClientSession WEB_CLIENT_SESSION = WebClientSession.create(WEB_CLIENT);
|
||||||
// 发送GET请求
|
|
||||||
WebClientSession webClientSession = WebClientSession.create(webClient);
|
|
||||||
|
|
||||||
|
|
||||||
public void exec() {
|
public void exec() {
|
||||||
webClientSession.getAbs(BASE_URL)
|
WEB_CLIENT_SESSION.getAbs(BASE_URL)
|
||||||
.putHeaders(headers) // 将请求头Map添加到请求中
|
.putHeaders(headers) // 将请求头Map添加到请求中
|
||||||
.send(this::next);
|
.send(this::next);
|
||||||
}
|
}
|
||||||
|
|
||||||
void next(AsyncResult<HttpResponse<Buffer>> response) {
|
void next(AsyncResult<HttpResponse<Buffer>> response) {
|
||||||
if (response.failed()) {
|
if (response.failed()) {
|
||||||
response.cause().printStackTrace();
|
log.error("请求失败", response.cause());
|
||||||
} else {
|
} else {
|
||||||
HttpResponse<Buffer> res = response.result();
|
HttpResponse<Buffer> res = response.result();
|
||||||
System.out.println("Received response with status code " + res.statusCode());
|
log.debug("Received response with status code {}", res.statusCode());
|
||||||
System.out.println("Body: " + res.body());
|
log.debug("Body: {}", res.body());
|
||||||
webClientSession.getAbs(BASE_URL_TEMPLATE).setTemplateParam("path", PATH1)
|
WEB_CLIENT_SESSION.getAbs(BASE_URL_TEMPLATE).setTemplateParam("path", PATH1)
|
||||||
.putHeaders(headers) // 将请求头Map添加到请求中
|
.putHeaders(headers) // 将请求头Map添加到请求中
|
||||||
.send(response2 -> {
|
.send(response2 -> {
|
||||||
System.out.println(response2.result().bodyAsString());
|
log.debug("response2: {}", response2.result().bodyAsString());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package cn.qaiu.util;
|
|||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.net.URLDecoder;
|
import java.net.URLDecoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -10,6 +13,8 @@ import java.util.Map;
|
|||||||
|
|
||||||
public class URLUtil {
|
public class URLUtil {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(URLUtil.class);
|
||||||
|
|
||||||
private final Map<String, String> queryParams = new HashMap<>();
|
private final Map<String, String> queryParams = new HashMap<>();
|
||||||
|
|
||||||
// 构造函数,传入URL并解析参数
|
// 构造函数,传入URL并解析参数
|
||||||
@@ -31,7 +36,7 @@ public class URLUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
LOGGER.error("URL解析失败: {}", url, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package cn.qaiu.util;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 123分享链接子域工具:将分享 key 的前半段解码为数字 uid。
|
||||||
|
*/
|
||||||
|
public final class YeShareHostUtil {
|
||||||
|
|
||||||
|
private static final String CODE62 = "Tvd3hHA9QEkom14xpfaBJIMwgFYGPXn2sWCNORDr80KuUSl7bZcetizL5q6yVj";
|
||||||
|
private static final long MAX_SAFE_INTEGER = 9007199254740991L;
|
||||||
|
private static final Map<Character, Integer> DECODE_MAP = new HashMap<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (int i = 0; i < CODE62.length(); i++) {
|
||||||
|
DECODE_MAP.put(CODE62.charAt(i), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private YeShareHostUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getNumericSubdomainIdByShareKey(String shareKey) {
|
||||||
|
String normalized = normalizeShareKey(shareKey);
|
||||||
|
if (StringUtils.isBlank(normalized)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int split = normalized.indexOf('-');
|
||||||
|
if (split <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String encodedUid = normalized.substring(0, split);
|
||||||
|
Long uid = decodeBase62LittleEndian(encodedUid);
|
||||||
|
return uid == null ? "" : String.valueOf(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalizeShareKey(String shareKey) {
|
||||||
|
if (StringUtils.isBlank(shareKey)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String key = shareKey.trim();
|
||||||
|
int queryIndex = key.indexOf('?');
|
||||||
|
if (queryIndex >= 0) {
|
||||||
|
key = key.substring(0, queryIndex);
|
||||||
|
}
|
||||||
|
int slashIndex = key.lastIndexOf('/');
|
||||||
|
if (slashIndex >= 0 && slashIndex < key.length() - 1) {
|
||||||
|
key = key.substring(slashIndex + 1);
|
||||||
|
}
|
||||||
|
if (key.endsWith(".html")) {
|
||||||
|
key = key.substring(0, key.length() - 5);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long decodeBase62LittleEndian(String value) {
|
||||||
|
if (StringUtils.isBlank(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long result = 0L;
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
Integer digit = DECODE_MAP.get(value.charAt(i));
|
||||||
|
if (digit == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
double weighted = digit * Math.pow(62, i);
|
||||||
|
if (!Double.isFinite(weighted)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long next = result + (long) weighted;
|
||||||
|
if (next <= 0 || next > MAX_SAFE_INTEGER) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
result = next;
|
||||||
|
}
|
||||||
|
if (result <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,8 +134,8 @@ HTTP客户端对象:
|
|||||||
http.get(url) // GET请求
|
http.get(url) // GET请求
|
||||||
http.post(url, data) // POST请求
|
http.post(url, data) // POST请求
|
||||||
http.putHeader(name, value) // 设置请求头
|
http.putHeader(name, value) // 设置请求头
|
||||||
http.sendForm(data) // 发送表单数据
|
http.sendForm(url, data) // 发送表单数据
|
||||||
http.sendJson(data) // 发送JSON数据
|
http.sendJson(url, data) // 发送JSON数据
|
||||||
```
|
```
|
||||||
|
|
||||||
### JsHttpResponse
|
### JsHttpResponse
|
||||||
|
|||||||
@@ -1,105 +1,105 @@
|
|||||||
// ==UserScript==
|
// // ==UserScript==
|
||||||
// @name Fetch API示例解析器
|
// // @name Fetch API示例解析器
|
||||||
// @type fetch_demo
|
// // @type fetch_demo
|
||||||
// @displayName Fetch演示
|
// // @displayName Fetch演示
|
||||||
// @description 演示如何在ES5环境中使用fetch API和async/await
|
// // @description 演示如何在ES5环境中使用fetch API和async/await
|
||||||
// @match https?://example\.com/s/(?<KEY>\w+)
|
// // @match https?://example\.com/s/(?<KEY>\w+)
|
||||||
// @author QAIU
|
// // @author QAIU
|
||||||
// @version 1.0.0
|
// // @version 1.0.0
|
||||||
// ==/UserScript==
|
// // ==/UserScript==
|
||||||
|
|
||||||
// 使用require导入类型定义(仅用于IDE类型提示)
|
// // 使用require导入类型定义(仅用于IDE类型提示)
|
||||||
var types = require('./types');
|
// var types = require('./types');
|
||||||
/** @typedef {types.ShareLinkInfo} ShareLinkInfo */
|
// /** @typedef {types.ShareLinkInfo} ShareLinkInfo */
|
||||||
/** @typedef {types.JsHttpClient} JsHttpClient */
|
// /** @typedef {types.JsHttpClient} JsHttpClient */
|
||||||
/** @typedef {types.JsLogger} JsLogger */
|
// /** @typedef {types.JsLogger} JsLogger */
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* 演示使用fetch API的解析器
|
// * 演示使用fetch API的解析器
|
||||||
* 注意:虽然源码中使用了ES6+语法(async/await),但在浏览器中会被编译为ES5
|
// * 注意:虽然源码中使用了ES6+语法(async/await),但在浏览器中会被编译为ES5
|
||||||
*
|
// *
|
||||||
* @param {ShareLinkInfo} shareLinkInfo - 分享链接信息
|
// * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息
|
||||||
* @param {JsHttpClient} http - HTTP客户端(传统方式)
|
// * @param {JsHttpClient} http - HTTP客户端(传统方式)
|
||||||
* @param {JsLogger} logger - 日志对象
|
// * @param {JsLogger} logger - 日志对象
|
||||||
* @returns {string} 下载链接
|
// * @returns {string} 下载链接
|
||||||
*/
|
// */
|
||||||
function parse(shareLinkInfo, http, logger) {
|
// function parse(shareLinkInfo, http, logger) {
|
||||||
logger.info("=== Fetch API Demo ===");
|
// logger.info("=== Fetch API Demo ===");
|
||||||
|
|
||||||
// 方式1:使用传统的http对象(同步)
|
// // 方式1:使用传统的http对象(同步)
|
||||||
logger.info("方式1: 使用传统http对象");
|
// logger.info("方式1: 使用传统http对象");
|
||||||
var response1 = http.get("https://httpbin.org/get");
|
// var response1 = http.get("https://httpbin.org/get");
|
||||||
logger.info("状态码: " + response1.statusCode());
|
// logger.info("状态码: " + response1.statusCode());
|
||||||
|
|
||||||
// 方式2:使用fetch API(基于Promise)
|
// // 方式2:使用fetch API(基于Promise)
|
||||||
logger.info("方式2: 使用fetch API");
|
// logger.info("方式2: 使用fetch API");
|
||||||
|
|
||||||
// 注意:在ES5环境中,我们需要手动处理Promise
|
// // 注意:在ES5环境中,我们需要手动处理Promise
|
||||||
// 这个示例展示了如何在ES5中使用fetch
|
// // 这个示例展示了如何在ES5中使用fetch
|
||||||
var fetchPromise = fetch("https://httpbin.org/get");
|
// var fetchPromise = fetch("https://httpbin.org/get");
|
||||||
|
|
||||||
// 等待Promise完成(同步等待模拟)
|
// // 等待Promise完成(同步等待模拟)
|
||||||
var result = null;
|
// var result = null;
|
||||||
var error = null;
|
// var error = null;
|
||||||
|
|
||||||
fetchPromise
|
// fetchPromise
|
||||||
.then(function(response) {
|
// .then(function(response) {
|
||||||
logger.info("Fetch响应状态: " + response.status);
|
// logger.info("Fetch响应状态: " + response.status);
|
||||||
return response.text();
|
// return response.text();
|
||||||
})
|
// })
|
||||||
.then(function(text) {
|
// .then(function(text) {
|
||||||
logger.info("Fetch响应内容: " + text.substring(0, 100) + "...");
|
// logger.info("Fetch响应内容: " + text.substring(0, 100) + "...");
|
||||||
result = "https://example.com/download/demo.file";
|
// result = "https://example.com/download/demo.file";
|
||||||
})
|
// })
|
||||||
['catch'](function(err) {
|
// ['catch'](function(err) {
|
||||||
logger.error("Fetch失败: " + err.message);
|
// logger.error("Fetch失败: " + err.message);
|
||||||
error = err;
|
// error = err;
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 简单的等待循环(实际场景中不推荐,这里仅作演示)
|
// // 简单的等待循环(实际场景中不推荐,这里仅作演示)
|
||||||
var timeout = 5000; // 5秒超时
|
// var timeout = 5000; // 5秒超时
|
||||||
var start = Date.now();
|
// var start = Date.now();
|
||||||
while (result === null && error === null && (Date.now() - start) < timeout) {
|
// while (result === null && error === null && (Date.now() - start) < timeout) {
|
||||||
// 等待Promise完成
|
// // 等待Promise完成
|
||||||
java.lang.Thread.sleep(10);
|
// java.lang.Thread.sleep(10);
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (error !== null) {
|
// if (error !== null) {
|
||||||
throw error;
|
// throw error;
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (result === null) {
|
// if (result === null) {
|
||||||
throw new Error("Fetch超时");
|
// throw new Error("Fetch超时");
|
||||||
}
|
// }
|
||||||
|
|
||||||
return result;
|
// return result;
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* 演示POST请求
|
// * 演示POST请求
|
||||||
*/
|
// */
|
||||||
function demonstratePost(logger) {
|
// function demonstratePost(logger) {
|
||||||
logger.info("=== 演示POST请求 ===");
|
// logger.info("=== 演示POST请求 ===");
|
||||||
|
|
||||||
var postPromise = fetch("https://httpbin.org/post", {
|
// var postPromise = fetch("https://httpbin.org/post", {
|
||||||
method: "POST",
|
// method: "POST",
|
||||||
headers: {
|
// headers: {
|
||||||
"Content-Type": "application/json"
|
// "Content-Type": "application/json"
|
||||||
},
|
// },
|
||||||
body: JSON.stringify({
|
// body: JSON.stringify({
|
||||||
key: "value",
|
// key: "value",
|
||||||
demo: true
|
// demo: true
|
||||||
})
|
// })
|
||||||
});
|
// });
|
||||||
|
|
||||||
postPromise
|
// postPromise
|
||||||
.then(function(response) {
|
// .then(function(response) {
|
||||||
return response.json();
|
// return response.json();
|
||||||
})
|
// })
|
||||||
.then(function(data) {
|
// .then(function(data) {
|
||||||
logger.info("POST响应: " + JSON.stringify(data));
|
// logger.info("POST响应: " + JSON.stringify(data));
|
||||||
})
|
// })
|
||||||
['catch'](function(err) {
|
// ['catch'](function(err) {
|
||||||
logger.error("POST失败: " + err.message);
|
// logger.error("POST失败: " + err.message);
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ var java;
|
|||||||
* @property {function(): JsHttpClient} clearHeaders - 清空所有请求头(保留默认头)
|
* @property {function(): JsHttpClient} clearHeaders - 清空所有请求头(保留默认头)
|
||||||
* @property {function(): Object} getHeaders - 获取所有请求头
|
* @property {function(): Object} getHeaders - 获取所有请求头
|
||||||
* @property {function(number): JsHttpClient} setTimeout - 设置请求超时时间(秒)
|
* @property {function(number): JsHttpClient} setTimeout - 设置请求超时时间(秒)
|
||||||
* @property {function(Object): JsHttpResponse} sendForm - 发送简单表单数据
|
* @property {function(string, Object): JsHttpResponse} sendForm - 发送简单表单数据
|
||||||
* @property {function(string, Object): JsHttpResponse} sendMultipartForm - 发送multipart表单数据(仅支持文本字段)
|
* @property {function(string, Object): JsHttpResponse} sendMultipartForm - 发送multipart表单数据(仅支持文本字段)
|
||||||
* @property {function(any): JsHttpResponse} sendJson - 发送JSON数据
|
* @property {function(string, any): JsHttpResponse} sendJson - 发送JSON数据
|
||||||
* @property {function(string): string} urlEncode - URL编码(静态方法)
|
* @property {function(string): string} urlEncode - URL编码(静态方法)
|
||||||
* @property {function(string): string} urlDecode - URL解码(静态方法)
|
* @property {function(string): string} urlDecode - URL解码(静态方法)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user