stnh70 commited on
Commit
aacd14b
·
verified ·
1 Parent(s): 6d2e615

Create web.ts

Browse files
Files changed (1) hide show
  1. web.ts +2195 -0
web.ts ADDED
@@ -0,0 +1,2195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // PikPak Deno API Client and Server - Combined File
2
+ // This file contains:
3
+ // 1. Type definitions
4
+ // 2. Exception classes
5
+ // 3. Utility functions
6
+ // 4. PikPakApi class implementation
7
+ // 5. Server implementation with Oak
8
+ // fuck
9
+
10
+ import { Application, Router, Context, Status } from "https://deno.land/x/oak/mod.ts";
11
+ import { oakCors } from "https://deno.land/x/cors/mod.ts";
12
+ import { delay } from "https://deno.land/std/async/mod.ts";
13
+ // import { createHash } from "https://deno.land/std/hash/mod.ts";
14
+ const { createHash } = await import('node:crypto');
15
+ // 在thunderapi.ts文件顶部添加以下导入
16
+ // import { Bot, InlineKeyboard } from "https://deno.land/x/grammy@v1.15.3/mod.ts";
17
+
18
+
19
+ // ===============================================================
20
+ // 1. Type definitions (originally types.ts)
21
+ // ===============================================================
22
+
23
+
24
+ // 在类外部添加这些变量
25
+ let isRefreshing = false;
26
+ let lastRefreshTime = 0;
27
+
28
+ export enum DownloadStatus {
29
+ NOT_DOWNLOADING = "not_downloading",
30
+ DOWNLOADING = "downloading",
31
+ DONE = "done",
32
+ ERROR = "error",
33
+ NOT_FOUND = "not_found"
34
+ }
35
+
36
+ export interface UserInfo {
37
+ username?: string;
38
+ user_id?: string;
39
+ access_token?: string;
40
+ refresh_token?: string;
41
+ encoded_token?: string;
42
+ }
43
+
44
+ export interface FileInfo {
45
+ id: string;
46
+ name: string;
47
+ file_type: string;
48
+ }
49
+
50
+ export interface TokenRefreshCallbackFn {
51
+ (client: any, ...args: any[]): Promise<void>;
52
+ }
53
+
54
+ export interface HttpxClientArgs {
55
+ timeout?: number;
56
+ [key: string]: any;
57
+ }
58
+
59
+ export interface FilterOptions {
60
+ [key: string]: any;
61
+ }
62
+
63
+ export interface FileListResponse {
64
+ files: any[];
65
+ next_page_token?: string;
66
+ [key: string]: any;
67
+ }
68
+
69
+ export interface OfflineListResponse {
70
+ tasks: any[];
71
+ next_page_token?: string;
72
+ [key: string]: any;
73
+ }
74
+
75
+ export interface FileRequest {
76
+ size: number;
77
+ parent_id?: string | null;
78
+ next_page_token?: string | null;
79
+ additional_filters?: Record<string, any> | null;
80
+ }
81
+
82
+ export interface OfflineRequest {
83
+ file_url: string;
84
+ parent_id?: string | null;
85
+ name?: string | null;
86
+ }
87
+
88
+ // ===============================================================
89
+ // 2. Exception classes (originally exceptions.ts)
90
+ // ===============================================================
91
+
92
+ export class PikpakException extends Error {
93
+ constructor(message: string) {
94
+ super(message);
95
+ this.name = "PikpakException";
96
+ }
97
+ }
98
+
99
+ export class PikpakRetryException extends PikpakException {
100
+ constructor(message: string) {
101
+ super(message);
102
+ this.name = "PikpakRetryException";
103
+ }
104
+ }
105
+
106
+ // ===============================================================
107
+ // 3. Utility functions (originally utils.ts)
108
+ // ===============================================================
109
+
110
+ export const CLIENT_ID = "ZQL_zwA4qhHcoe_2";
111
+ export const CLIENT_SECRET = "Og9Vr1L8Ee6bh0olFxFDRg";
112
+ export const CLIENT_VERSION = "1.06.0.2132";
113
+ export const PACKAG_ENAME = "com.thunder.downloader";
114
+ export const SDK_VERSION = "2.0.3.203100 ";
115
+ export const APP_NAME = PACKAG_ENAME;
116
+
117
+ /**
118
+ * Get current timestamp in milliseconds
119
+ */
120
+ // export function getTimestamp(): number {
121
+ // return Date.now();
122
+ // }
123
+ export function getTimestamp(): string {
124
+ // 确保返回毫秒级时间戳,并且是字符串格式
125
+ return Math.floor(Date.now()).toString();
126
+ }
127
+
128
+ /**
129
+ * Generate a random device ID
130
+ */
131
+ export function deviceIdGenerator(): string {
132
+ return crypto.randomUUID().replace(/-/g, "");
133
+ }
134
+
135
+ const SALTS = [
136
+ "kVy0WbPhiE4v6oxXZ88DvoA3Q",
137
+ "lON/AUoZKj8/nBtcE85mVbkOaVdVa",
138
+ "rLGffQrfBKH0BgwQ33yZofvO3Or",
139
+ "FO6HWqw",
140
+ "GbgvyA2",
141
+ "L1NU9QvIQIH7DTRt",
142
+ "y7llk4Y8WfYflt6",
143
+ "iuDp1WPbV3HRZudZtoXChxH4HNVBX5ZALe",
144
+ "8C28RTXmVcco0",
145
+ "X5Xh",
146
+ "7xe25YUgfGgD0xW3ezFS",
147
+ "",
148
+ "CKCR",
149
+ "8EmDjBo6h3eLaK7U6vU2Qys0NsMx",
150
+ "t2TeZBXKqbdP09Arh9C3",
151
+ ];
152
+
153
+ /**
154
+ * Generate a captcha sign
155
+ */
156
+ // export function captchaSign(deviceId: string, timestamp: string): string {
157
+ // let sign = CLIENT_ID + CLIENT_VERSION + PACKAG_ENAME + deviceId + timestamp;
158
+
159
+ // for (const salt of SALTS) {
160
+ // const encoder = new TextEncoder();
161
+ // const data = encoder.encode(sign + salt);
162
+ // sign = createHash("md5").update(data).toString();
163
+ // }
164
+
165
+ // return `1.${sign}`;
166
+ // }
167
+
168
+ export function captchaSign(deviceId: string, timestamp: string): string {
169
+ let sign = CLIENT_ID + CLIENT_VERSION + PACKAG_ENAME + deviceId + timestamp;
170
+
171
+ for (const salt of SALTS) {
172
+ // 在 Node.js 兼容模式下使用 createHash
173
+ const md5Hash = createHash('md5');
174
+ md5Hash.update(sign + salt);
175
+ sign = md5Hash.digest('hex');
176
+ }
177
+
178
+ return `1.${sign}`;
179
+ }
180
+
181
+ /**
182
+ * Generate device sign
183
+ */
184
+ export function generateDeviceSign(deviceId: string, packageName: string): string {
185
+ const signatureBase = `${deviceId}${packageName}1appkey`;
186
+
187
+ // Calculate SHA-1 hash
188
+ const encoder = new TextEncoder();
189
+ const sha1Result = createHash("sha1").update(encoder.encode(signatureBase)).toString();
190
+
191
+ // Calculate MD5 hash
192
+ const md5Result = createHash("md5").update(encoder.encode(sha1Result)).toString();
193
+
194
+ return `div101.${deviceId}${md5Result}`;
195
+ }
196
+
197
+ /**
198
+ * Build a custom user agent string
199
+ */
200
+ export function buildCustomUserAgent(deviceId: string, userId: string): string {
201
+ const deviceSign = generateDeviceSign(deviceId, PACKAG_ENAME);
202
+
203
+ const userAgentParts = [
204
+ `ANDROID-${APP_NAME}/${CLIENT_VERSION}`,
205
+ "protocolVersion/200",
206
+ "accesstype/",
207
+ `clientid/${CLIENT_ID}`,
208
+ `clientversion/${CLIENT_VERSION}`,
209
+ "action_type/",
210
+ "networktype/WIFI",
211
+ "sessionid/",
212
+ `deviceid/${deviceId}`,
213
+ "providername/NONE",
214
+ `devicesign/${deviceSign}`,
215
+ "refresh_token/",
216
+ `sdkversion/${SDK_VERSION}`,
217
+ `datetime/${getTimestamp()}`,
218
+ `usrno/${userId}`,
219
+ `appname/${APP_NAME}`,
220
+ "session_origin/",
221
+ "grant_type/",
222
+ "appid/",
223
+ "clientip/",
224
+ "devicename/Xiaomi_M2004j7ac",
225
+ "osversion/13",
226
+ "platformversion/10",
227
+ "accessmode/",
228
+ "devicemodel/M2004J7AC"
229
+ ];
230
+
231
+ return userAgentParts.join(" ");
232
+ }
233
+
234
+ /**
235
+ * Base64 encoding and decoding functions
236
+ */
237
+ export function b64encode(str: string): string {
238
+ return btoa(str);
239
+ }
240
+
241
+ export function b64decode(str: string): string {
242
+ return atob(str);
243
+ }
244
+
245
+ // ===============================================================
246
+ // 4. PikPakApi class implementation (originally pikpak_api.ts)
247
+ // ===============================================================
248
+
249
+ export class PikPakApi {
250
+ /**
251
+ * PikPakApi class
252
+ *
253
+ * Attributes similar to the Python version
254
+ */
255
+ static readonly PIKPAK_API_HOST = "api-pan.xunleix.com";
256
+ static readonly PIKPAK_USER_HOST = "xluser-ssl.xunleix.com";
257
+
258
+ // User credentials and tokens
259
+ username?: string;
260
+ password?: string;
261
+ encoded_token?: string;
262
+ access_token?: string;
263
+ refresh_token?: string;
264
+ user_id?: string;
265
+
266
+ // Request configuration
267
+ max_retries: number;
268
+ initial_backoff: number;
269
+ device_id: string;
270
+ captcha_token?: string;
271
+ data_response?: any;
272
+ user_agent?: string;
273
+
274
+ // Callback for token refresh
275
+ token_refresh_callback?: TokenRefreshCallbackFn;
276
+ token_refresh_callback_kwargs: Record<string, any>;
277
+
278
+ // Path-to-ID cache
279
+ private _path_id_cache: Record<string, any> = {};
280
+
281
+ constructor(
282
+ options: {
283
+ username?: string;
284
+ password?: string;
285
+ encoded_token?: string;
286
+ httpx_client_args?: HttpxClientArgs;
287
+ device_id?: string;
288
+ request_max_retries?: number;
289
+ request_initial_backoff?: number;
290
+ token_refresh_callback?: TokenRefreshCallbackFn;
291
+ token_refresh_callback_kwargs?: Record<string, any>;
292
+ } = {}
293
+ ) {
294
+ this.username = options.username;
295
+ this.password = options.password;
296
+ this.encoded_token = options.encoded_token;
297
+ this.max_retries = options.request_max_retries || 3;
298
+ this.initial_backoff = options.request_initial_backoff || 3.0;
299
+ this.token_refresh_callback = options.token_refresh_callback;
300
+ this.token_refresh_callback_kwargs = options.token_refresh_callback_kwargs || {};
301
+
302
+ // Generate device_id if not provided
303
+ this.device_id = options.device_id || this.generateDeviceId();
304
+
305
+ if (this.encoded_token) {
306
+ this.decodeToken();
307
+ } else if (this.username && this.password) {
308
+ // Login will be done later
309
+ } else {
310
+ throw new PikpakException("username and password or encoded_token is required");
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Generate device ID based on username and password
316
+ */
317
+ private generateDeviceId(): string {
318
+ const idBase = `${this.username || ""}${this.password || ""}`;
319
+ return createHash("md5").update(new TextEncoder().encode(idBase)).toString();
320
+ }
321
+
322
+ /**
323
+ * Create PikPakApi object from a dictionary/object
324
+ */
325
+ // static fromDict(data: Record<string, any>): PikPakApi {
326
+ // const client = new PikPakApi();
327
+
328
+ // // Copy all properties from data to client
329
+ // for (const [key, value] of Object.entries(data)) {
330
+ // if (typeof value !== 'function') {
331
+ // (client as any)[key] = value;
332
+ // }
333
+ // }
334
+
335
+ // return client;
336
+ // }
337
+
338
+ static fromDict(data: Record<string, any>): PikPakApi {
339
+ // 创建一个临时客户端,绕过构造函数验证
340
+ const client = new PikPakApi({
341
+ encoded_token: "temporary" // 临时值,稍后会被覆盖
342
+ });
343
+
344
+ // 从数据中复制所有属性
345
+ for (const [key, value] of Object.entries(data)) {
346
+ if (typeof value !== 'function') {
347
+ (client as any)[key] = value;
348
+ }
349
+ }
350
+
351
+ // 手动解码令牌
352
+ if (client.encoded_token && client.encoded_token !== "temporary") {
353
+ try {
354
+ client.decodeToken();
355
+ } catch (error) {
356
+ console.log("Warning: Failed to decode token");
357
+ }
358
+ }
359
+
360
+ return client;
361
+ }
362
+
363
+ /**
364
+ * Returns the PikPakApi object as a dictionary/object
365
+ */
366
+ toDict(): Record<string, any> {
367
+ const data: Record<string, any> = {};
368
+
369
+ // Copy all properties that can be serialized
370
+ for (const [key, value] of Object.entries(this)) {
371
+ if (
372
+ typeof value === 'string' ||
373
+ typeof value === 'number' ||
374
+ typeof value === 'boolean' ||
375
+ Array.isArray(value) ||
376
+ (typeof value === 'object' && value !== null) ||
377
+ value === null ||
378
+ value === undefined
379
+ ) {
380
+ if (typeof value !== 'function') {
381
+ data[key] = value;
382
+ }
383
+ }
384
+ }
385
+
386
+ return data;
387
+ }
388
+
389
+ /**
390
+ * Build custom user agent
391
+ */
392
+ buildCustomUserAgent(): string {
393
+ this.user_agent = buildCustomUserAgent(
394
+ this.device_id,
395
+ this.user_id || ""
396
+ );
397
+ return this.user_agent;
398
+ }
399
+
400
+ /**
401
+ * Get headers for API requests
402
+ */
403
+ getHeaders(access_token?: string): Record<string, string> {
404
+ const headers: Record<string, string> = {
405
+ "User-Agent": this.captcha_token
406
+ ? this.buildCustomUserAgent()
407
+ : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
408
+ "Content-Type": "application/json; charset=utf-8"
409
+ };
410
+
411
+ if (this.access_token) {
412
+ headers["Authorization"] = `Bearer ${this.access_token}`;
413
+ }
414
+ if (access_token) {
415
+ headers["Authorization"] = `Bearer ${access_token}`;
416
+ }
417
+ if (this.captcha_token) {
418
+ headers["X-Captcha-Token"] = this.captcha_token;
419
+ }
420
+ if (this.device_id) {
421
+ headers["X-Device-Id"] = this.device_id;
422
+ }
423
+
424
+ return headers;
425
+ }
426
+
427
+ /**
428
+ * Make an HTTP request with retry logic
429
+ */
430
+ private async _makeRequest(
431
+ method: string,
432
+ url: string,
433
+ data?: Record<string, any>,
434
+ params?: Record<string, any>,
435
+ headers?: Record<string, string>
436
+ ): Promise<any> {
437
+ let lastError: Error | null = null;
438
+
439
+ for (let attempt = 0; attempt < this.max_retries; attempt++) {
440
+ try {
441
+ const response = await this._sendRequest(method, url, data, params, headers);
442
+ return await this._handleResponse(response);
443
+ } catch (error) {
444
+ if (error instanceof PikpakRetryException) {
445
+ console.info(`Retry attempt ${attempt + 1}/${this.max_retries}`);
446
+ lastError = error;
447
+ } else if (error instanceof PikpakException) {
448
+ throw error;
449
+ } else {
450
+ console.error(`Unexpected error on attempt ${attempt + 1}/${this.max_retries}: ${String(error)}`);
451
+ lastError = error instanceof Error ? error : new Error(String(error));
452
+ }
453
+ }
454
+
455
+ await delay(this.initial_backoff * Math.pow(2, attempt) * 1000);
456
+ }
457
+
458
+ // If we've exhausted all retries, raise an exception with the last error
459
+ throw new PikpakException(`Max retries reached. Last error: ${lastError?.message || "Unknown error"}`);
460
+ }
461
+
462
+ /**
463
+ * Send an HTTP request
464
+ */
465
+ private async _sendRequest(
466
+ method: string,
467
+ url: string,
468
+ data?: Record<string, any>,
469
+ params?: Record<string, any>,
470
+ headers?: Record<string, string>
471
+ ): Promise<Response> {
472
+ const reqHeaders = headers || this.getHeaders();
473
+
474
+ const options: RequestInit = {
475
+ method,
476
+ headers: reqHeaders,
477
+ };
478
+
479
+ // Add request body if data is provided
480
+ if (data && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
481
+ options.body = JSON.stringify(data);
482
+ }
483
+
484
+ // Add URL parameters if provided
485
+ if (params) {
486
+ const queryParams = new URLSearchParams();
487
+ for (const [key, value] of Object.entries(params)) {
488
+ if (value !== undefined && value !== null) {
489
+ if (typeof value === 'object') {
490
+ queryParams.set(key, JSON.stringify(value));
491
+ } else {
492
+ queryParams.set(key, String(value));
493
+ }
494
+ }
495
+ }
496
+ const queryString = queryParams.toString();
497
+ if (queryString) {
498
+ url = `${url}${url.includes('?') ? '&' : '?'}${queryString}`;
499
+ }
500
+ }
501
+
502
+ return await fetch(url, options);
503
+ }
504
+
505
+ /**
506
+ * Handle HTTP response
507
+ */
508
+
509
+
510
+ private async _handleResponse(response: Response): Promise<any> {
511
+ // Check if response is empty or not JSON
512
+ let jsonData: any;
513
+ try {
514
+ jsonData = await response.json();
515
+ } catch (error) {
516
+ if (response.status === 200) {
517
+ return {};
518
+ }
519
+ throw new PikpakRetryException("Empty JSON data");
520
+ }
521
+
522
+ this.data_response = jsonData;
523
+
524
+ if (!jsonData) {
525
+ if (response.status === 200) {
526
+ return {};
527
+ }
528
+ throw new PikpakRetryException("Empty JSON data");
529
+ }
530
+
531
+ if (!("error" in jsonData)) {
532
+ return jsonData;
533
+ }
534
+
535
+ if ("captcha_token" in jsonData) {
536
+ this.captcha_token = jsonData.captcha_token;
537
+ }
538
+
539
+ if (jsonData.error === "invalid_account_or_password") {
540
+ throw new PikpakException("Invalid username or password");
541
+ }
542
+
543
+ // if (jsonData.error_code === 16) {
544
+ // await this.refreshAccessToken();
545
+ // throw new PikpakRetryException("Token refreshed, please retry");
546
+ // }
547
+ if (jsonData.error_code === 16) {
548
+ const now = Date.now();
549
+ // 防止30秒内多次刷新
550
+ if (isRefreshing && now - lastRefreshTime < 30000) {
551
+ console.log("Token refresh already in progress, waiting...");
552
+ // 等待一小段时间后重试
553
+ await delay(2000);
554
+ throw new PikpakRetryException("Token refresh in progress, please retry");
555
+ }
556
+
557
+ isRefreshing = true;
558
+ try {
559
+ console.log("Refreshing access token on demand...");
560
+ await this.refreshAccessToken();
561
+ lastRefreshTime = Date.now();
562
+ console.log("Token refreshed successfully");
563
+ } catch (refreshError) {
564
+ console.error("Failed to refresh token:", refreshError);
565
+ try {
566
+ console.log("Attempting to re-login...");
567
+ await this.login();
568
+ console.log("Re-login successful");
569
+ } catch (loginError) {
570
+ console.error("Re-login failed:", loginError);
571
+ throw new PikpakException("Authentication failed, unable to refresh token or login");
572
+ }
573
+ } finally {
574
+ isRefreshing = false;
575
+ }
576
+ throw new PikpakRetryException("Token refreshed, please retry");
577
+ }
578
+
579
+ throw new PikpakException(jsonData.error_description || "Unknown Error");
580
+ }
581
+
582
+ /**
583
+ * HTTP GET request
584
+ */
585
+ private async _requestGet(url: string, params?: Record<string, any>): Promise<any> {
586
+ return await this._makeRequest("GET", url, undefined, params);
587
+ }
588
+
589
+ /**
590
+ * HTTP POST request
591
+ */
592
+ private async _requestPost(url: string, data?: Record<string, any>, headers?: Record<string, string>): Promise<any> {
593
+ return await this._makeRequest("POST", url, data, undefined, headers);
594
+ }
595
+
596
+ /**
597
+ * HTTP PATCH request
598
+ */
599
+ private async _requestPatch(url: string, data?: Record<string, any>): Promise<any> {
600
+ return await this._makeRequest("PATCH", url, data);
601
+ }
602
+
603
+ /**
604
+ * HTTP DELETE request
605
+ */
606
+ private async _requestDelete(url: string, params?: Record<string, any>, data?: Record<string, any>): Promise<any> {
607
+ return await this._makeRequest("DELETE", url, data, params);
608
+ }
609
+
610
+ /**
611
+ * Decode encoded token
612
+ */
613
+ decodeToken(): void {
614
+ try {
615
+ const decodedData = JSON.parse(b64decode(this.encoded_token || ""));
616
+
617
+ if (!decodedData.access_token || !decodedData.refresh_token) {
618
+ throw new PikpakException("Invalid encoded token");
619
+ }
620
+
621
+ this.access_token = decodedData.access_token;
622
+ this.refresh_token = decodedData.refresh_token;
623
+ } catch (error) {
624
+ throw new PikpakException("Invalid encoded token");
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Encode access and refresh tokens
630
+ */
631
+ encodeToken(): void {
632
+ const tokenData = {
633
+ access_token: this.access_token,
634
+ refresh_token: this.refresh_token
635
+ };
636
+
637
+ this.encoded_token = b64encode(JSON.stringify(tokenData));
638
+ }
639
+
640
+ /**
641
+ * Initialize captcha
642
+ */
643
+ async captchaInit(action: string, meta?: Record<string, any>): Promise<any> {
644
+ const url = `https://${PikPakApi.PIKPAK_USER_HOST}/v1/shield/captcha/init`;
645
+
646
+ if (!meta) {
647
+ const t = `${getTimestamp()}`;
648
+ meta = {
649
+ captcha_sign: captchaSign(this.device_id, t),
650
+ client_version: CLIENT_VERSION,
651
+ package_name: PACKAG_ENAME,
652
+ user_id: this.user_id || "",
653
+ timestamp: t
654
+ };
655
+ }
656
+
657
+ const params = {
658
+ client_id: CLIENT_ID,
659
+ action: action,
660
+ device_id: this.device_id,
661
+ meta: meta
662
+ };
663
+
664
+ return await this._requestPost(url, params);
665
+ }
666
+
667
+ /**
668
+ * Login to PikPak
669
+ */
670
+ async login(): Promise<void> {
671
+ const loginUrl = `https://${PikPakApi.PIKPAK_USER_HOST}/v1/auth/signin`;
672
+ const metas: Record<string, string> = {};
673
+
674
+ if (!this.username || !this.password) {
675
+ throw new PikpakException("username and password are required");
676
+ }
677
+
678
+ // Determine login method based on username format
679
+ if (/\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*/.test(this.username)) {
680
+ metas.email = this.username;
681
+ } else if (/\d{11,18}/.test(this.username)) {
682
+ metas.phone_number = this.username;
683
+ } else {
684
+ metas.username = this.username;
685
+ }
686
+
687
+ const result = await this.captchaInit(`POST:${loginUrl}`, metas);
688
+ const captchaToken = result.captcha_token || "";
689
+
690
+ if (!captchaToken) {
691
+ throw new PikpakException("captcha_token get failed");
692
+ }
693
+
694
+ // Prepare login data
695
+ const loginData = new URLSearchParams();
696
+ loginData.append("client_id", CLIENT_ID);
697
+ loginData.append("client_secret", CLIENT_SECRET);
698
+ loginData.append("password", this.password);
699
+ loginData.append("username", this.username);
700
+ loginData.append("captcha_token", captchaToken);
701
+
702
+ // Custom headers for form data
703
+ const headers = {
704
+ "Content-Type": "application/x-www-form-urlencoded",
705
+ };
706
+
707
+ // Send login request with form data
708
+ const response = await fetch(loginUrl, {
709
+ method: "POST",
710
+ headers: headers,
711
+ body: loginData
712
+ });
713
+
714
+ const userInfo = await response.json();
715
+
716
+ if (userInfo.error) {
717
+ throw new PikpakException(userInfo.error_description || "Login failed");
718
+ }
719
+
720
+ this.access_token = userInfo.access_token;
721
+ this.refresh_token = userInfo.refresh_token;
722
+ this.user_id = userInfo.sub;
723
+ this.encodeToken();
724
+ }
725
+
726
+ /**
727
+ * Refresh access token
728
+ */
729
+ async refreshAccessToken(): Promise<void> {
730
+ const refreshUrl = `https://${PikPakApi.PIKPAK_USER_HOST}/v1/auth/token`;
731
+
732
+ const refreshData = {
733
+ client_id: CLIENT_ID,
734
+ refresh_token: this.refresh_token,
735
+ grant_type: "refresh_token"
736
+ };
737
+
738
+ const userInfo = await this._requestPost(refreshUrl, refreshData);
739
+
740
+ this.access_token = userInfo.access_token;
741
+ this.refresh_token = userInfo.refresh_token;
742
+ this.user_id = userInfo.sub;
743
+ this.encodeToken();
744
+
745
+ if (this.token_refresh_callback) {
746
+ await this.token_refresh_callback(this, ...Object.values(this.token_refresh_callback_kwargs));
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Get user info
752
+ */
753
+ getUserInfo(): UserInfo {
754
+ return {
755
+ username: this.username,
756
+ user_id: this.user_id,
757
+ access_token: this.access_token,
758
+ refresh_token: this.refresh_token,
759
+ encoded_token: this.encoded_token
760
+ };
761
+ }
762
+
763
+ /**
764
+ * Create a folder
765
+ */
766
+ async createFolder(name: string = "新建文件夹", parent_id?: string): Promise<any> {
767
+ const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files`;
768
+
769
+ const data = {
770
+ kind: "drive#folder",
771
+ name: name,
772
+ parent_id: parent_id
773
+ };
774
+
775
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files");
776
+ this.captcha_token = captchaResult.captcha_token;
777
+ const result = await this._requestPost(url, data);
778
+
779
+ return result;
780
+ }
781
+
782
+ /**
783
+ * Move files/folders to trash
784
+ */
785
+ async deleteToTrash(ids: string[]): Promise<any> {
786
+ const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchTrash`;
787
+
788
+ const data = {
789
+ ids: ids
790
+ };
791
+
792
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchTrash");
793
+ this.captcha_token = captchaResult.captcha_token;
794
+ const result = await this._requestPost(url, data);
795
+
796
+ return result;
797
+ }
798
+
799
+ /**
800
+ * Restore files/folders from trash
801
+ */
802
+ async untrash(ids: string[]): Promise<any> {
803
+ const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchUntrash`;
804
+
805
+ const data = {
806
+ ids: ids
807
+ };
808
+
809
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchUntrash");
810
+ this.captcha_token = captchaResult.captcha_token;
811
+ const result = await this._requestPost(url, data);
812
+
813
+ return result;
814
+ }
815
+
816
+ /**
817
+ * Empty trash
818
+ */
819
+ async emptytrash(): Promise<any> {
820
+ const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files/trash:empty`;
821
+
822
+ const data = {};
823
+
824
+ const captchaResult = await this.captchaInit("PATCH:/drive/v1/files/trash:empty");
825
+ this.captcha_token = captchaResult.captcha_token;
826
+ const result = await this._requestPatch(url, data);
827
+
828
+ return result;
829
+ }
830
+
831
+ /**
832
+ * Permanently delete files/folders
833
+ */
834
+ async deleteForever(ids: string[]): Promise<any> {
835
+ const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchDelete`;
836
+
837
+ const data = {
838
+ ids: ids
839
+ };
840
+
841
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchDelete");
842
+ this.captcha_token = captchaResult.captcha_token;
843
+ const result = await this._requestPost(url, data);
844
+
845
+ return result;
846
+ }
847
+
848
+ /**
849
+ * Offline download
850
+ */
851
+ async offlineDownload(file_url: string, parent_id?: string, name?: string): Promise<any> {
852
+ const downloadUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files`;
853
+
854
+ const downloadData = {
855
+ kind: "drive#file",
856
+ name: name,
857
+ upload_type: "UPLOAD_TYPE_URL",
858
+ url: {
859
+ url: file_url,
860
+ parent_id: parent_id
861
+ },
862
+ parent_id: parent_id
863
+ };
864
+
865
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files");
866
+ this.captcha_token = captchaResult.captcha_token;
867
+ const result = await this._requestPost(downloadUrl, downloadData);
868
+
869
+ return result;
870
+ }
871
+
872
+ /**
873
+ * Get offline download list
874
+ */
875
+ async offlineList(
876
+ size: number = 10000,
877
+ next_page_token?: string,
878
+ phase?: string[]
879
+ ): Promise<any> {
880
+ if (!phase) {
881
+ phase = ["PHASE_TYPE_RUNNING", "PHASE_TYPE_ERROR"];
882
+ }
883
+
884
+ const listUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/tasks`;
885
+
886
+ const listData = {
887
+ type: "offline",
888
+ thumbnail_size: "SIZE_SMALL",
889
+ limit: size,
890
+ page_token: next_page_token,
891
+ filters: JSON.stringify({ phase: { in: phase.join(",") } }),
892
+ with: "reference_resource"
893
+ };
894
+
895
+ const captchaResult = await this.captchaInit("GET:/drive/v1/tasks");
896
+ this.captcha_token = captchaResult.captcha_token;
897
+ const result = await this._requestGet(listUrl, listData);
898
+
899
+ return result;
900
+ }
901
+
902
+ /**
903
+ * Get offline file info
904
+ */
905
+ async offlineFileInfo(file_id: string): Promise<any> {
906
+ const captchaResult = await this.captchaInit(`GET:/drive/v1/files/${file_id}`);
907
+ this.captcha_token = captchaResult.captcha_token;
908
+
909
+ const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files/${file_id}`;
910
+ const result = await this._requestGet(url, { thumbnail_size: "SIZE_LARGE" });
911
+
912
+ return result;
913
+ }
914
+
915
+ /**
916
+ * Get file list
917
+ */
918
+ async fileList(
919
+ size: number = 100,
920
+ parent_id?: string,
921
+ next_page_token?: string,
922
+ additional_filters?: Record<string, any>
923
+ ): Promise<any> {
924
+ const defaultFilters: Record<string, any> = {
925
+ trashed: { eq: false },
926
+ phase: { eq: "PHASE_TYPE_COMPLETE" }
927
+ };
928
+
929
+ if (additional_filters) {
930
+ Object.assign(defaultFilters, additional_filters);
931
+ }
932
+
933
+ const listUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files`;
934
+
935
+ const listData = {
936
+ parent_id: parent_id,
937
+ thumbnail_size: "SIZE_MEDIUM",
938
+ limit: size,
939
+ with_audit: "true",
940
+ page_token: next_page_token,
941
+ filters: JSON.stringify(defaultFilters)
942
+ };
943
+
944
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files");
945
+ this.captcha_token = captchaResult.captcha_token;
946
+ const result = await this._requestGet(listUrl, listData);
947
+
948
+ return result;
949
+ }
950
+
951
+ /**
952
+ * Get events list
953
+ */
954
+ async events(size: number = 100, next_page_token?: string): Promise<any> {
955
+ const listUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/events`;
956
+
957
+ const listData = {
958
+ thumbnail_size: "SIZE_MEDIUM",
959
+ limit: size,
960
+ next_page_token: next_page_token
961
+ };
962
+
963
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files");
964
+ this.captcha_token = captchaResult.captcha_token;
965
+ const result = await this._requestGet(listUrl, listData);
966
+
967
+ return result;
968
+ }
969
+
970
+ /**
971
+ * Retry offline download task
972
+ */
973
+ async offlineTaskRetry(task_id: string): Promise<any> {
974
+ const listUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/task`;
975
+
976
+ const listData = {
977
+ type: "offline",
978
+ create_type: "RETRY",
979
+ id: task_id
980
+ };
981
+
982
+ try {
983
+ const captchaResult = await this.captchaInit("GET:/drive/v1/task");
984
+ this.captcha_token = captchaResult.captcha_token;
985
+ const result = await this._requestPost(listUrl, listData);
986
+ return result;
987
+ } catch (error) {
988
+ throw new PikpakException(`Failed to retry offline task: ${task_id}. ${error}`);
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Delete tasks
994
+ */
995
+ async deleteTasks(task_ids: string[], delete_files: boolean = false): Promise<void> {
996
+ const deleteUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/tasks`;
997
+
998
+ const params = {
999
+ task_ids: task_ids,
1000
+ delete_files: delete_files
1001
+ };
1002
+
1003
+ try {
1004
+ const captchaResult = await this.captchaInit("GET:/drive/v1/tasks");
1005
+ this.captcha_token = captchaResult.captcha_token;
1006
+ await this._requestDelete(deleteUrl, params);
1007
+ } catch (error) {
1008
+ throw new PikpakException(`Failing to delete tasks: ${task_ids}. ${error}`);
1009
+ }
1010
+ }
1011
+
1012
+ /**
1013
+ * Get task status
1014
+ */
1015
+ async getTaskStatus(task_id: string, file_id: string): Promise<DownloadStatus> {
1016
+ try {
1017
+ const infos = await this.offlineList();
1018
+ if (infos && infos.tasks) {
1019
+ for (const task of infos.tasks) {
1020
+ if (task_id === task.id) {
1021
+ return DownloadStatus.DOWNLOADING;
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ const fileInfo = await this.offlineFileInfo(file_id);
1027
+ if (fileInfo) {
1028
+ return DownloadStatus.DONE;
1029
+ } else {
1030
+ return DownloadStatus.NOT_FOUND;
1031
+ }
1032
+ } catch (error) {
1033
+ return DownloadStatus.ERROR;
1034
+ }
1035
+ }
1036
+
1037
+ /**
1038
+ * Convert path to ID
1039
+ */
1040
+ async pathToId(path: string, create: boolean = false): Promise<FileInfo[]> {
1041
+ if (!path || path.length <= 0) {
1042
+ return [];
1043
+ }
1044
+
1045
+ const paths = path.split('/').filter(p => p.trim().length > 0);
1046
+
1047
+ // Construct multi-level paths for cache lookup
1048
+ const multiLevelPaths = [];
1049
+ for (let i = 0; i < paths.length; i++) {
1050
+ multiLevelPaths.push('/' + paths.slice(0, i + 1).join('/'));
1051
+ }
1052
+
1053
+ // Check cache hits
1054
+ const pathIds: FileInfo[] = [];
1055
+ for (const p of multiLevelPaths) {
1056
+ if (this._path_id_cache[p]) {
1057
+ pathIds.push(this._path_id_cache[p]);
1058
+ } else {
1059
+ break;
1060
+ }
1061
+ }
1062
+
1063
+ // Determine how much of the path we've already found
1064
+ let count = pathIds.length;
1065
+ let parentId = count > 0 ? pathIds[count - 1].id : null;
1066
+
1067
+ // Find or create the remaining path components
1068
+ let nextPageToken: string | null = null;
1069
+
1070
+ while (count < paths.length) {
1071
+ const data = await this.fileList(100, parentId, nextPageToken);
1072
+
1073
+ let recordOfTargetPath = null;
1074
+
1075
+ for (const f of data.files || []) {
1076
+ const currentPath = '/' + [...paths.slice(0, count), f.name].join('/');
1077
+ const fileType = f.kind.includes('folder') ? 'folder' : 'file';
1078
+
1079
+ const record: FileInfo = {
1080
+ id: f.id,
1081
+ name: f.name,
1082
+ file_type: fileType
1083
+ };
1084
+
1085
+ this._path_id_cache[currentPath] = record;
1086
+
1087
+ if (f.name === paths[count]) {
1088
+ recordOfTargetPath = record;
1089
+ }
1090
+ }
1091
+
1092
+ if (recordOfTargetPath) {
1093
+ pathIds.push(recordOfTargetPath);
1094
+ count++;
1095
+ parentId = recordOfTargetPath.id;
1096
+ } else if (data.next_page_token && (!nextPageToken || nextPageToken !== data.next_page_token)) {
1097
+ nextPageToken = data.next_page_token;
1098
+ } else if (create) {
1099
+ const createdData = await this.createFolder(paths[count], parentId);
1100
+ const fileId = createdData.file.id;
1101
+
1102
+ const record: FileInfo = {
1103
+ id: fileId,
1104
+ name: paths[count],
1105
+ file_type: 'folder'
1106
+ };
1107
+
1108
+ pathIds.push(record);
1109
+
1110
+ const currentPath = '/' + paths.slice(0, count + 1).join('/');
1111
+ this._path_id_cache[currentPath] = record;
1112
+
1113
+ count++;
1114
+ parentId = fileId;
1115
+ } else {
1116
+ break;
1117
+ }
1118
+ }
1119
+
1120
+ return pathIds;
1121
+ }
1122
+
1123
+ /**
1124
+ * Batch move files
1125
+ */
1126
+ async fileBatchMove(ids: string[], to_parent_id?: string): Promise<any> {
1127
+ const to = to_parent_id ? { parent_id: to_parent_id } : {};
1128
+
1129
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchMove");
1130
+ this.captcha_token = captchaResult.captcha_token;
1131
+
1132
+ const result = await this._requestPost(
1133
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchMove`,
1134
+ {
1135
+ ids: ids,
1136
+ to: to
1137
+ }
1138
+ );
1139
+
1140
+ return result;
1141
+ }
1142
+
1143
+ /**
1144
+ * Batch copy files
1145
+ */
1146
+ async fileBatchCopy(ids: string[], to_parent_id?: string): Promise<any> {
1147
+ const to = to_parent_id ? { parent_id: to_parent_id } : {};
1148
+
1149
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchCopy");
1150
+ this.captcha_token = captchaResult.captcha_token;
1151
+
1152
+ const result = await this._requestPost(
1153
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchCopy`,
1154
+ {
1155
+ ids: ids,
1156
+ to: to
1157
+ }
1158
+ );
1159
+
1160
+ return result;
1161
+ }
1162
+
1163
+ /**
1164
+ * Move or copy files by path
1165
+ */
1166
+ async fileMoveOrCopyByPath(
1167
+ from_path: string[],
1168
+ to_path: string,
1169
+ move: boolean = false,
1170
+ create: boolean = false
1171
+ ): Promise<any> {
1172
+ const fromIds: string[] = [];
1173
+
1174
+ for (const path of from_path) {
1175
+ const pathIds = await this.pathToId(path);
1176
+ if (pathIds.length > 0) {
1177
+ const fileId = pathIds[pathIds.length - 1].id;
1178
+ if (fileId) {
1179
+ fromIds.push(fileId);
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ if (fromIds.length === 0) {
1185
+ throw new PikpakException("Files to move do not exist");
1186
+ }
1187
+
1188
+ const toPathIds = await this.pathToId(to_path, create);
1189
+ const toParentId = toPathIds.length > 0 ? toPathIds[toPathIds.length - 1].id : undefined;
1190
+
1191
+ let result;
1192
+ if (move) {
1193
+ result = await this.fileBatchMove(fromIds, toParentId);
1194
+ } else {
1195
+ result = await this.fileBatchCopy(fromIds, toParentId);
1196
+ }
1197
+
1198
+ return result;
1199
+ }
1200
+
1201
+ /**
1202
+ * Get download URL
1203
+ */
1204
+ async getDownloadUrl(file_id: string): Promise<any> {
1205
+ const result = await this.captchaInit(`GET:/drive/v1/files/${file_id}`);
1206
+ this.captcha_token = result.captcha_token;
1207
+
1208
+ const fileResult = await this._requestGet(
1209
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files/${file_id}?`
1210
+ );
1211
+
1212
+ this.captcha_token = null;
1213
+ return fileResult;
1214
+ }
1215
+
1216
+ /**
1217
+ * Rename file
1218
+ */
1219
+ async fileRename(id: string, new_file_name: string): Promise<any> {
1220
+ const data = {
1221
+ name: new_file_name
1222
+ };
1223
+
1224
+ const captchaResult = await this.captchaInit(`GET:/drive/v1/files/${id}`);
1225
+ this.captcha_token = captchaResult.captcha_token;
1226
+
1227
+ const result = await this._requestPatch(
1228
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files/${id}`,
1229
+ data
1230
+ );
1231
+
1232
+ return result;
1233
+ }
1234
+
1235
+ /**
1236
+ * Batch star files
1237
+ */
1238
+ async fileBatchStar(ids: string[]): Promise<any> {
1239
+ const data = {
1240
+ ids: ids
1241
+ };
1242
+
1243
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files/star");
1244
+ this.captcha_token = captchaResult.captcha_token;
1245
+
1246
+ const result = await this._requestPost(
1247
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:star`,
1248
+ data
1249
+ );
1250
+
1251
+ return result;
1252
+ }
1253
+
1254
+ /**
1255
+ * Batch unstar files
1256
+ */
1257
+ async fileBatchUnstar(ids: string[]): Promise<any> {
1258
+ const data = {
1259
+ ids: ids
1260
+ };
1261
+
1262
+ const captchaResult = await this.captchaInit("GET:/drive/v1/files/unstar");
1263
+ this.captcha_token = captchaResult.captcha_token;
1264
+
1265
+ const result = await this._requestPost(
1266
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:unstar`,
1267
+ data
1268
+ );
1269
+
1270
+ return result;
1271
+ }
1272
+
1273
+ /**
1274
+ * Get starred file list
1275
+ */
1276
+ async fileStarList(size: number = 100, next_page_token?: string): Promise<any> {
1277
+ const additionalFilters = {
1278
+ system_tag: { in: "STAR" }
1279
+ };
1280
+
1281
+ const result = await this.fileList(
1282
+ size,
1283
+ "*",
1284
+ next_page_token,
1285
+ additionalFilters
1286
+ );
1287
+
1288
+ return result;
1289
+ }
1290
+
1291
+ /**
1292
+ * Batch share files
1293
+ */
1294
+ // async fileBatchShare(
1295
+ // ids: string[],
1296
+ // need_password: boolean = false,
1297
+ // expiration_days: number = -1
1298
+ // ): Promise<any> {
1299
+ // const data = {
1300
+ // file_ids: ids,
1301
+ // share_to: need_password ? "encryptedlink" : "publiclink",
1302
+ // expiration_days: expiration_days,
1303
+ // pass_code_option: need_password ? "REQUIRED" : "NOT_REQUIRED"
1304
+ // };
1305
+
1306
+ // const captchaResult = await this.captchaInit("GET:/drive/v1/share");
1307
+ // this.captcha_token = captchaResult.captcha_token;
1308
+
1309
+ // const result = await this._requestPost(
1310
+ // `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share`,
1311
+ // data
1312
+ // );
1313
+
1314
+ // return result;
1315
+ // }
1316
+
1317
+ async fileBatchShare(
1318
+ ids: string[],
1319
+ need_password: boolean = false,
1320
+ expiration_days: number = -1
1321
+ ): Promise<any> {
1322
+ const data = {
1323
+ file_ids: ids,
1324
+ share_to: "copy", // 修改为"copy"
1325
+ restore_limit: "-1", // 添加这个参数
1326
+ expiration_days: expiration_days,
1327
+ pass_code_option: need_password ? "REQUIRED" : "NOT_REQUIRED"
1328
+ };
1329
+
1330
+ const captchaResult = await this.captchaInit("POST:/drive/v1/share");
1331
+ this.captcha_token = captchaResult.captcha_token;
1332
+
1333
+ const result = await this._requestPost(
1334
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share`,
1335
+ data
1336
+ );
1337
+
1338
+ return result;
1339
+ }
1340
+
1341
+ /**
1342
+ * Get quota info
1343
+ */
1344
+ async getQuotaInfo(): Promise<any> {
1345
+ const result = await this._requestGet(
1346
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/about`
1347
+ );
1348
+
1349
+ return result;
1350
+ }
1351
+
1352
+ /**
1353
+ * Get invite code
1354
+ */
1355
+ async getInviteCode(): Promise<string> {
1356
+ const captchaResult = await this.captchaInit("GET:/vip/v1/activity/inviteCode");
1357
+ this.captcha_token = captchaResult.captcha_token;
1358
+
1359
+ const result = await this._requestGet(
1360
+ `https://${PikPakApi.PIKPAK_API_HOST}/vip/v1/activity/inviteCode`
1361
+ );
1362
+
1363
+ return result.code;
1364
+ }
1365
+
1366
+ /**
1367
+ * Get VIP info
1368
+ */
1369
+ async vipInfo(): Promise<any> {
1370
+ const captchaResult = await this.captchaInit("GET:/drive/v1/privilege/vip");
1371
+ this.captcha_token = captchaResult.captcha_token;
1372
+
1373
+ const result = await this._requestGet(
1374
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/privilege/vip`
1375
+ );
1376
+
1377
+ return result;
1378
+ }
1379
+
1380
+ /**
1381
+ * Get transfer quota
1382
+ */
1383
+ async getTransferQuota(): Promise<any> {
1384
+ const url = `https://${PikPakApi.PIKPAK_API_HOST}/vip/v1/quantity/list?type=transfer`;
1385
+
1386
+ const captchaResult = await this.captchaInit("GET:/vip/v1/quantity/list?type=transfer");
1387
+ this.captcha_token = captchaResult.captcha_token;
1388
+
1389
+ const result = await this._requestGet(url);
1390
+
1391
+ return result;
1392
+ }
1393
+
1394
+ /**
1395
+ * Get share folder
1396
+ */
1397
+ async getShareFolder(share_id: string, pass_code_token: string, parent_id?: string): Promise<any> {
1398
+ const data = {
1399
+ limit: "100",
1400
+ thumbnail_size: "SIZE_LARGE",
1401
+ order: "6",
1402
+ share_id: share_id,
1403
+ parent_id: parent_id,
1404
+ pass_code_token: pass_code_token
1405
+ };
1406
+
1407
+ const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share/detail`;
1408
+
1409
+ const captchaResult = await this.captchaInit("GET:/drive/v1/share/detail");
1410
+ this.captcha_token = captchaResult.captcha_token;
1411
+
1412
+ return await this._requestGet(url, data);
1413
+ }
1414
+
1415
+ /**
1416
+ * Get share info
1417
+ */
1418
+ async getShareInfo(share_link: string, pass_code?: string): Promise<any> {
1419
+ const match = share_link.match(/\/s\/([^/]+)(?:.*\/([^/]+))?$/);
1420
+
1421
+ if (!match) {
1422
+ throw new Error("Share Link Is Not Right");
1423
+ }
1424
+
1425
+ const share_id = match[1];
1426
+ const parent_id = match[2] || null;
1427
+
1428
+ const data = {
1429
+ limit: "100",
1430
+ thumbnail_size: "SIZE_LARGE",
1431
+ order: "3",
1432
+ share_id: share_id,
1433
+ parent_id: parent_id,
1434
+ pass_code: pass_code
1435
+ };
1436
+
1437
+ const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share`;
1438
+
1439
+ const captchaResult = await this.captchaInit("GET:/drive/v1/share");
1440
+ this.captcha_token = captchaResult.captcha_token;
1441
+
1442
+ return await this._requestGet(url, data);
1443
+ }
1444
+
1445
+ /**
1446
+ * Restore shared files
1447
+ */
1448
+ async restore(share_id: string, pass_code_token: string, file_ids: string[]): Promise<any> {
1449
+ const data = {
1450
+ share_id: share_id,
1451
+ pass_code_token: pass_code_token,
1452
+ file_ids: file_ids,
1453
+ folder_type: "NORMAL", // 添加此字段
1454
+ specify_parent_id: true, // 添加此字段
1455
+ parent_id: "" // 添加此字段
1456
+ };
1457
+
1458
+ const captchaResult = await this.captchaInit("GET:/drive/v1/share/restore");
1459
+ this.captcha_token = captchaResult.captcha_token;
1460
+
1461
+ const result = await this._requestPost(
1462
+ `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share/restore`,
1463
+ data
1464
+ );
1465
+
1466
+ return result;
1467
+ }
1468
+ }
1469
+
1470
+ // ===============================================================
1471
+ // 5. Server implementation with Oak (originally main.ts)
1472
+ // ===============================================================
1473
+
1474
+ // Environment variables handling
1475
+ const SECRET_TOKEN = Deno.env.get("SECRET_TOKEN");
1476
+ if (!SECRET_TOKEN) {
1477
+ throw new Error("Please set SECRET_TOKEN environment variable for security!");
1478
+ }
1479
+
1480
+ const THUNDERX_USERNAME = Deno.env.get("THUNDERX_USERNAME");
1481
+ if (!THUNDERX_USERNAME) {
1482
+ throw new Error("Please set THUNDERX_USERNAME environment variable for login!");
1483
+ }
1484
+
1485
+ const THUNDERX_PASSWORD = Deno.env.get("THUNDERX_PASSWORD");
1486
+ if (!THUNDERX_PASSWORD) {
1487
+ throw new Error("Please set THUNDERX_PASSWORD environment variable for login!");
1488
+ }
1489
+
1490
+
1491
+
1492
+ const PROXY_URL = Deno.env.get("PROXY_URL");
1493
+
1494
+ // Global client instance
1495
+ let THUNDERX_CLIENT: PikPakApi | null = null;
1496
+
1497
+ // Token logger function
1498
+ // async function logToken(client: PikPakApi, extraData: any): Promise<void> {
1499
+ // console.log(`Token: ${client.encoded_token}, Extra Data: ${JSON.stringify(extraData)}`);
1500
+ // }
1501
+
1502
+
1503
+ async function logToken(client: PikPakApi, extraData: any): Promise<void> {
1504
+ console.log(`Token: ${client.encoded_token}, Extra Data: ${JSON.stringify(extraData)}`);
1505
+ }
1506
+
1507
+ // Create application
1508
+ const app = new Application();
1509
+
1510
+ // Add CORS middleware
1511
+ app.use(oakCors({ origin: "*", optionsSuccessStatus: 200 }));
1512
+
1513
+ // Middleware for token verification
1514
+ async function verifyToken(ctx: Context, next: () => Promise<unknown>): Promise<void> {
1515
+ const authHeader = ctx.request.headers.get("Authorization");
1516
+
1517
+ if (!authHeader) {
1518
+ ctx.response.status = Status.Unauthorized;
1519
+ ctx.response.body = { detail: "Authorization header missing" };
1520
+ return;
1521
+ }
1522
+
1523
+ const [scheme, token] = authHeader.split(" ");
1524
+
1525
+ if (scheme !== "Bearer" || !token) {
1526
+ ctx.response.status = Status.Unauthorized;
1527
+ ctx.response.body = { detail: "Invalid authentication scheme" };
1528
+ return;
1529
+ }
1530
+
1531
+ if (token !== SECRET_TOKEN) {
1532
+ ctx.response.status = Status.Unauthorized;
1533
+ ctx.response.body = { detail: "Invalid or expired token" };
1534
+ return;
1535
+ }
1536
+
1537
+ await next();
1538
+ }
1539
+
1540
+ // Initialize client
1541
+
1542
+ app.addEventListener("listen", async () => {
1543
+ try {
1544
+ console.log("Creating client with username/password");
1545
+ THUNDERX_CLIENT = new PikPakApi({
1546
+ username: THUNDERX_USERNAME,
1547
+ password: THUNDERX_PASSWORD,
1548
+ token_refresh_callback: logToken,
1549
+ token_refresh_callback_kwargs: { extra_data: "test" }
1550
+ });
1551
+
1552
+ await THUNDERX_CLIENT.login();
1553
+ await THUNDERX_CLIENT.refreshAccessToken();
1554
+
1555
+ console.log("Client initialized successfully");
1556
+ console.log("Using on-demand token refresh strategy");
1557
+
1558
+ } catch (error) {
1559
+ console.error("Failed to initialize client:", error);
1560
+ }
1561
+ });
1562
+
1563
+ // Create routers
1564
+ const apiRouter = new Router();
1565
+ const frontRouter = new Router();
1566
+
1567
+ // Front-end routes
1568
+ frontRouter.get("/", async (ctx) => {
1569
+ try {
1570
+ // A very simple HTML template for demonstration
1571
+ const html = `
1572
+ <!DOCTYPE html>
1573
+ <html lang="en">
1574
+ <head>
1575
+ <meta charset="UTF-8">
1576
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1577
+ <title>迅雷X API Client</title>
1578
+ <style>
1579
+ body { font-family: Arial, sans-serif; margin: 20px; }
1580
+ h1 { color: #333; }
1581
+ .container { max-width: 800px; margin: 0 auto; }
1582
+ .card { border: 1px solid #ddd; padding: 20px; margin-bottom: 20px; border-radius: 5px; }
1583
+ button { background: #4CAF50; color: white; border: none; padding: 10px 15px; border-radius: 4px; cursor: pointer; }
1584
+ button:disabled { background: #cccccc; cursor: not-allowed; }
1585
+ button:hover:not(:disabled) { background: #45a049; }
1586
+ pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
1587
+ .status { color: #666; font-style: italic; margin-bottom: 10px; }
1588
+ </style>
1589
+ </head>
1590
+ <body>
1591
+ <div class="container">
1592
+ <h1>迅雷X API Client</h1>
1593
+ <div class="status" id="authStatus">验证中...</div>
1594
+ <div class="card">
1595
+ <h2>User Info</h2>
1596
+ <button id="getUserInfo" disabled>Get User Info</button>
1597
+ <pre id="userInfoResult"></pre>
1598
+ </div>
1599
+ <div class="card">
1600
+ <h2>File List</h2>
1601
+ <button id="getFileList" disabled>Get File List</button>
1602
+ <pre id="fileListResult"></pre>
1603
+ </div>
1604
+ <div class="card">
1605
+ <h2>Offline Download</h2>
1606
+ <input type="text" id="fileUrl" placeholder="Enter file URL" style="width: 70%; padding: 8px;">
1607
+ <button id="offlineDownload" disabled>Download</button>
1608
+ <pre id="offlineResult"></pre>
1609
+ </div>
1610
+ </div>
1611
+ <script>
1612
+ // 初始禁用所有按钮
1613
+ document.querySelectorAll('button').forEach(btn => btn.disabled = true);
1614
+
1615
+ // 获取token - 每次都提示
1616
+ const token = prompt('Enter your API token:');
1617
+
1618
+ if (!token) {
1619
+ document.getElementById('authStatus').textContent = '未提供Token,功能已禁用';
1620
+ document.getElementById('authStatus').style.color = '#ff0000';
1621
+ } else {
1622
+ // 设置验证头
1623
+ const headers = {
1624
+ 'Authorization': 'Bearer ' + token,
1625
+ 'Content-Type': 'application/json'
1626
+ };
1627
+
1628
+ // 验证token是否有效
1629
+ async function verifyToken() {
1630
+ try {
1631
+ document.getElementById('authStatus').textContent = '验证Token中...';
1632
+
1633
+ // 尝试获取用户信息来验证token
1634
+ const response = await fetch('/userinfo', { headers });
1635
+
1636
+ if (response.ok) {
1637
+ const data = await response.json();
1638
+ // token有效,启用按钮
1639
+ document.querySelectorAll('button').forEach(btn => btn.disabled = false);
1640
+ document.getElementById('authStatus').textContent = \`已验证成功 - 欢迎 \${data.username || "用户"}\`;
1641
+ document.getElementById('authStatus').style.color = '#4CAF50';
1642
+ } else {
1643
+ document.getElementById('authStatus').textContent = 'Token无效,功能已禁用';
1644
+ document.getElementById('authStatus').style.color = '#ff0000';
1645
+ }
1646
+ } catch (error) {
1647
+ document.getElementById('authStatus').textContent = \`验证错误: \${error.message}\`;
1648
+ document.getElementById('authStatus').style.color = '#ff0000';
1649
+ }
1650
+ }
1651
+
1652
+ // 页面加载后验证token
1653
+ verifyToken();
1654
+
1655
+ // 设置API调用事件监听器
1656
+ document.getElementById('getUserInfo').addEventListener('click', async () => {
1657
+ try {
1658
+ const response = await fetch('/userinfo', { headers });
1659
+ const data = await response.json();
1660
+ document.getElementById('userInfoResult').textContent = JSON.stringify(data, null, 2);
1661
+ } catch (error) {
1662
+ document.getElementById('userInfoResult').textContent = 'Error: ' + error.message;
1663
+ }
1664
+ });
1665
+
1666
+ document.getElementById('getFileList').addEventListener('click', async () => {
1667
+ try {
1668
+ const response = await fetch('/files', {
1669
+ method: 'POST',
1670
+ headers,
1671
+ body: JSON.stringify({ size: 10 })
1672
+ });
1673
+ const data = await response.json();
1674
+ document.getElementById('fileListResult').textContent = JSON.stringify(data, null, 2);
1675
+ } catch (error) {
1676
+ document.getElementById('fileListResult').textContent = 'Error: ' + error.message;
1677
+ }
1678
+ });
1679
+
1680
+ document.getElementById('offlineDownload').addEventListener('click', async () => {
1681
+ const fileUrl = document.getElementById('fileUrl').value;
1682
+ if (!fileUrl) {
1683
+ alert('Please enter a file URL');
1684
+ return;
1685
+ }
1686
+
1687
+ try {
1688
+ const response = await fetch('/offline', {
1689
+ method: 'POST',
1690
+ headers,
1691
+ body: JSON.stringify({ file_url: fileUrl })
1692
+ });
1693
+ const data = await response.json();
1694
+ document.getElementById('offlineResult').textContent = JSON.stringify(data, null, 2);
1695
+ } catch (error) {
1696
+ document.getElementById('offlineResult').textContent = 'Error: ' + error.message;
1697
+ }
1698
+ });
1699
+ }
1700
+
1701
+ // 添加刷新页面按钮,相当于重新登录
1702
+ const refreshButton = document.createElement('button');
1703
+ refreshButton.textContent = '重新验证';
1704
+ refreshButton.style.marginTop = '20px';
1705
+ refreshButton.style.backgroundColor = '#f44336';
1706
+ refreshButton.addEventListener('click', () => {
1707
+ location.reload();
1708
+ });
1709
+ document.querySelector('.container').appendChild(refreshButton);
1710
+ </script>
1711
+ </body>
1712
+ </html>
1713
+ `;
1714
+
1715
+ ctx.response.headers.set("Content-Type", "text/html");
1716
+ ctx.response.body = html;
1717
+ } catch (error) {
1718
+ ctx.response.status = Status.InternalServerError;
1719
+ ctx.response.body = { error: "Template error" };
1720
+ }
1721
+ });
1722
+
1723
+ // Helper function to ensure client is initialized
1724
+ function ensureClient(ctx: Context): boolean {
1725
+ if (!THUNDERX_CLIENT) {
1726
+ ctx.response.status = Status.InternalServerError;
1727
+ ctx.response.body = { error: "API client not initialized" };
1728
+ return false;
1729
+ }
1730
+ return true;
1731
+ }
1732
+
1733
+
1734
+ // 添加一个包装函数来处理验证码错误并自动重试
1735
+ async function withCaptchaRetry(captchaPath, apiCall) {
1736
+ // 首先尝试刷新验证码
1737
+ try {
1738
+ await THUNDERX_CLIENT.captchaInit(captchaPath);
1739
+ console.log(`Captcha refreshed for ${captchaPath}`);
1740
+ } catch (captchaError) {
1741
+ console.error(`Failed to refresh captcha for ${captchaPath}:`, captchaError);
1742
+ }
1743
+
1744
+ try {
1745
+ // 尝试执行API调用
1746
+ return await apiCall();
1747
+ } catch (error) {
1748
+ // 检查是否是验证码错误
1749
+ if (error.message && (
1750
+ error.message.includes("Verification code is invalid") ||
1751
+ error.message.includes("invalid captcha_sign"))) {
1752
+ console.log(`Detected invalid verification code, retrying ${captchaPath}...`);
1753
+
1754
+ // 再次刷新验证码
1755
+ await THUNDERX_CLIENT.captchaInit(captchaPath);
1756
+
1757
+ // 重试API调用
1758
+ return await apiCall();
1759
+ }
1760
+
1761
+ // 其他类型的错误则继续抛出
1762
+ throw error;
1763
+ }
1764
+ }
1765
+
1766
+ apiRouter.use(verifyToken);
1767
+
1768
+ // 文件列表路由
1769
+ apiRouter.post("/files", async (ctx) => {
1770
+ if (!ensureClient(ctx)) return;
1771
+
1772
+ try {
1773
+ const body = await ctx.request.body.json();
1774
+ const size = body.size || 100;
1775
+ const parent_id = body.parent_id || undefined;
1776
+ const next_page_token = body.next_page_token || undefined;
1777
+ const additional_filters = body.additional_filters || {};
1778
+
1779
+ const result = await withCaptchaRetry(
1780
+ "GET:/drive/v1/files",
1781
+ () => THUNDERX_CLIENT.fileList(
1782
+ size,
1783
+ parent_id,
1784
+ next_page_token,
1785
+ additional_filters
1786
+ )
1787
+ );
1788
+
1789
+ ctx.response.body = result;
1790
+ } catch (error) {
1791
+ ctx.response.status = Status.InternalServerError;
1792
+ ctx.response.body = { error: error.message };
1793
+ }
1794
+ });
1795
+
1796
+ // 文件详情路由
1797
+ apiRouter.get("/files/:file_id", async (ctx) => {
1798
+ if (!ensureClient(ctx)) return;
1799
+
1800
+ const { file_id } = ctx.params;
1801
+
1802
+ try {
1803
+ const result = await withCaptchaRetry(
1804
+ `GET:/drive/v1/files/${file_id}`,
1805
+ () => THUNDERX_CLIENT.getDownloadUrl(file_id)
1806
+ );
1807
+
1808
+ ctx.response.body = result;
1809
+ } catch (error) {
1810
+ ctx.response.status = Status.InternalServerError;
1811
+ ctx.response.body = { error: error.message };
1812
+ }
1813
+ });
1814
+
1815
+ // 清空回收站路由
1816
+ apiRouter.post("/emptytrash", async (ctx) => {
1817
+ if (!ensureClient(ctx)) return;
1818
+
1819
+ try {
1820
+ const result = await withCaptchaRetry(
1821
+ "PATCH:/drive/v1/files/trash:empty",
1822
+ () => THUNDERX_CLIENT.emptytrash()
1823
+ );
1824
+
1825
+ ctx.response.body = result;
1826
+ } catch (error) {
1827
+ ctx.response.status = Status.InternalServerError;
1828
+ ctx.response.body = { error: error.message };
1829
+ }
1830
+ });
1831
+
1832
+ // 离线任务列表路由
1833
+ apiRouter.get("/offline", async (ctx) => {
1834
+ if (!ensureClient(ctx)) return;
1835
+
1836
+ try {
1837
+ const url = new URL(ctx.request.url);
1838
+ const size = parseInt(url.searchParams.get("size") || "10000");
1839
+ const next_page_token = url.searchParams.get("next_page_token") || undefined;
1840
+
1841
+ const result = await withCaptchaRetry(
1842
+ "GET:/drive/v1/tasks",
1843
+ () => THUNDERX_CLIENT.offlineList(
1844
+ size,
1845
+ next_page_token
1846
+ )
1847
+ );
1848
+
1849
+ ctx.response.body = result;
1850
+ } catch (error) {
1851
+ ctx.response.status = Status.InternalServerError;
1852
+ ctx.response.body = { error: error.message };
1853
+ }
1854
+ });
1855
+
1856
+ // 添加离线任务路由
1857
+ apiRouter.post("/offline", async (ctx) => {
1858
+ if (!ensureClient(ctx)) return;
1859
+
1860
+ try {
1861
+ const body = await ctx.request.body.json();
1862
+
1863
+ const result = await withCaptchaRetry(
1864
+ "GET:/drive/v1/files",
1865
+ () => THUNDERX_CLIENT.offlineDownload(
1866
+ body.file_url,
1867
+ body.parent_id,
1868
+ body.name
1869
+ )
1870
+ );
1871
+
1872
+ ctx.response.body = result;
1873
+ } catch (error) {
1874
+ ctx.response.status = Status.InternalServerError;
1875
+ ctx.response.body = { error: error.message };
1876
+ }
1877
+ });
1878
+
1879
+ // 用户信息路由 (不需要验证码刷新)
1880
+ apiRouter.get("/userinfo", (ctx) => {
1881
+ if (!ensureClient(ctx)) return;
1882
+
1883
+ // 用户信息不需要验证码刷新,因为它只是返回客户端中已有的信息
1884
+ ctx.response.body = THUNDERX_CLIENT.getUserInfo();
1885
+ });
1886
+
1887
+ // 配额信息路由
1888
+ apiRouter.get("/quota", async (ctx) => {
1889
+ if (!ensureClient(ctx)) return;
1890
+
1891
+ try {
1892
+ const result = await withCaptchaRetry(
1893
+ "GET:/drive/v1/about",
1894
+ () => THUNDERX_CLIENT.getQuotaInfo()
1895
+ );
1896
+
1897
+ ctx.response.body = result;
1898
+ } catch (error) {
1899
+ ctx.response.status = Status.InternalServerError;
1900
+ ctx.response.body = { error: error.message };
1901
+ }
1902
+ });
1903
+
1904
+ // 添加移动到回收站接口
1905
+ // delete_to_trash
1906
+ // apiRouter.post("/delete_to_trash", async (ctx) => {
1907
+ // if (!ensureClient(ctx)) return;
1908
+
1909
+ // try {
1910
+ // const body = await ctx.request.body.json();
1911
+ // const ids = body.ids || [];
1912
+
1913
+ // const result = await withCaptchaRetry(
1914
+ // "GET:/drive/v1/files:batchTrash",
1915
+ // () => THUNDERX_CLIENT.deleteToTrash(ids)
1916
+ // );
1917
+
1918
+ // ctx.response.body = result;
1919
+ // } catch (error) {
1920
+ // ctx.response.status = Status.InternalServerError;
1921
+ // ctx.response.body = { error: error.message };
1922
+ // }
1923
+ // });
1924
+ apiRouter.post("/delete_to_trash", async (ctx) => {
1925
+ if (!ensureClient(ctx)) return;
1926
+
1927
+ try {
1928
+ // 直接将请求体解析为JSON数组
1929
+ const ids = await ctx.request.body.json();
1930
+
1931
+ const result = await withCaptchaRetry(
1932
+ "GET:/drive/v1/files:batchTrash",
1933
+ () => THUNDERX_CLIENT.deleteToTrash(ids)
1934
+ );
1935
+
1936
+ ctx.response.body = result;
1937
+ } catch (error) {
1938
+ ctx.response.status = Status.InternalServerError;
1939
+ ctx.response.body = { error: error.message };
1940
+ }
1941
+ });
1942
+
1943
+ // 添加彻底删除接口
1944
+ // apiRouter.post("/delete_forever", async (ctx) => {
1945
+ // if (!ensureClient(ctx)) return;
1946
+
1947
+ // try {
1948
+ // const body = await ctx.request.body.json();
1949
+ // const ids = body.ids || [];
1950
+
1951
+ // const result = await withCaptchaRetry(
1952
+ // "GET:/drive/v1/files:batchDelete",
1953
+ // () => THUNDERX_CLIENT.deleteToTrash(ids)
1954
+ // );
1955
+
1956
+ // ctx.response.body = result;
1957
+ // } catch (error) {
1958
+ // ctx.response.status = Status.InternalServerError;
1959
+ // ctx.response.body = { error: error.message };
1960
+ // }
1961
+ // });
1962
+ apiRouter.post("/delete_forever", async (ctx) => {
1963
+ if (!ensureClient(ctx)) return;
1964
+
1965
+ try {
1966
+ // 直接将请求体解析为JSON数组
1967
+ const ids = await ctx.request.body.json();
1968
+
1969
+ const result = await withCaptchaRetry(
1970
+ "GET:/drive/v1/files:batchTrash",
1971
+ () => THUNDERX_CLIENT.deleteToTrash(ids)
1972
+ );
1973
+
1974
+ ctx.response.body = result;
1975
+ } catch (error) {
1976
+ ctx.response.status = Status.InternalServerError;
1977
+ ctx.response.body = { error: error.message };
1978
+ }
1979
+ });
1980
+
1981
+ // 批量分享文件
1982
+ // apiRouter.post("/file_batch_share", async (ctx) => {
1983
+ // if (!ensureClient(ctx)) return;
1984
+
1985
+ // try {
1986
+ // // 解析请求体
1987
+ // const requestBody = await ctx.request.body.json();
1988
+ // const fileId = requestBody.file_id; // 单个文件ID
1989
+ // const duration = requestBody.duration || -1; // 过期时间(秒),默认永久
1990
+ // const no_password = requestBody.no_password !== undefined ? requestBody.no_password : true; // 是否不需要密码
1991
+
1992
+ // // 构造文件ID数组(即使只有一个ID也用数组)
1993
+ // const ids = Array.isArray(fileId) ? fileId : [fileId];
1994
+
1995
+ // // 计算天数(如果提供的是秒数)
1996
+ // let expiration_days = -1;
1997
+ // if (duration > 0) {
1998
+ // expiration_days = Math.ceil(duration / (24 * 60 * 60));
1999
+ // }
2000
+
2001
+ // // 调用迅雷分享API
2002
+ // const result = await withCaptchaRetry(
2003
+ // "POST:/drive/v1/files:batchShare",
2004
+ // () => THUNDERX_CLIENT.fileBatchShare(ids, !no_password, expiration_days)
2005
+ // );
2006
+
2007
+ // ctx.response.body = result;
2008
+ // } catch (error) {
2009
+ // ctx.response.status = Status.InternalServerError;
2010
+ // ctx.response.body = { error: error.message };
2011
+ // }
2012
+ // });
2013
+
2014
+ apiRouter.post("/file_batch_share", async (ctx) => {
2015
+ if (!ensureClient(ctx)) return;
2016
+
2017
+ try {
2018
+ // 从请求体获取文件ID数组
2019
+ const ids = await ctx.request.body.json();
2020
+
2021
+ // 从查询参数获取其他选项
2022
+ const url = new URL(ctx.request.url);
2023
+ const needPassword = url.searchParams.get("need_password") === "true";
2024
+ const expirationDays = parseInt(url.searchParams.get("expiration_days") || "-1");
2025
+
2026
+ console.log("分享请求参数:", {
2027
+ ids,
2028
+ needPassword,
2029
+ expirationDays
2030
+ });
2031
+
2032
+ // 调用迅雷分享API
2033
+ const result = await withCaptchaRetry(
2034
+ "POST:/drive/v1/share",
2035
+ () => THUNDERX_CLIENT.fileBatchShare(ids, needPassword, expirationDays)
2036
+ );
2037
+
2038
+ ctx.response.body = result;
2039
+ } catch (error) {
2040
+ console.error("分享失败:", error);
2041
+ ctx.response.status = Status.InternalServerError;
2042
+ ctx.response.body = { error: error.message };
2043
+ }
2044
+ });
2045
+
2046
+ // 转存分享文件
2047
+ apiRouter.post("/restore", async (ctx) => {
2048
+ if (!ensureClient(ctx)) return;
2049
+
2050
+ try {
2051
+ // 解析请求体
2052
+ const requestBody = await ctx.request.body.json();
2053
+
2054
+ // 提取必要参数
2055
+ const shareId = requestBody.share_id;
2056
+ const passCodeToken = requestBody.pass_code_token || null;
2057
+ const fileIds = requestBody.file_ids || null;
2058
+
2059
+ // 验证必要参数
2060
+ if (!shareId) {
2061
+ ctx.response.status = Status.BadRequest;
2062
+ ctx.response.body = { error: "share_id is required" };
2063
+ return;
2064
+ }
2065
+
2066
+ // 调用PikPak API
2067
+ const result = await withCaptchaRetry(
2068
+ "GET:/drive/v1/share/restore",
2069
+ () => THUNDERX_CLIENT.restore(shareId, passCodeToken, fileIds)
2070
+ );
2071
+
2072
+ ctx.response.body = result;
2073
+ } catch (error) {
2074
+ console.error("转存分享文件失败:", error);
2075
+ ctx.response.status = Status.InternalServerError;
2076
+ ctx.response.body = { error: error.message };
2077
+ }
2078
+ });
2079
+
2080
+ // 获取分享信息 - 对应Python版本的get_share_folder
2081
+ apiRouter.post("/get_share_folder", async (ctx) => {
2082
+ if (!ensureClient(ctx)) return;
2083
+
2084
+ try {
2085
+ // 从查询参数中获取参数
2086
+ const url = new URL(ctx.request.url);
2087
+ const shareId = url.searchParams.get("share_id");
2088
+ const passCodeToken = url.searchParams.get("pass_code_token") || null;
2089
+ const parentId = url.searchParams.get("parent_id") || null;
2090
+
2091
+ // 验证必要参数
2092
+ if (!shareId) {
2093
+ ctx.response.status = Status.BadRequest;
2094
+ ctx.response.body = { error: "share_id is required" };
2095
+ return;
2096
+ }
2097
+
2098
+ // 调用PikPak API - 使用迅雷网盘链接格式
2099
+ const result = await withCaptchaRetry(
2100
+ "GET:/drive/v1/share/detail",
2101
+ () => THUNDERX_CLIENT.getShareFolder(shareId, passCodeToken, parentId)
2102
+ );
2103
+
2104
+ ctx.response.body = result;
2105
+ } catch (error) {
2106
+ console.error("获取分享信息失败:", error);
2107
+ ctx.response.status = Status.InternalServerError;
2108
+ ctx.response.body = { error: error.message };
2109
+ }
2110
+ });
2111
+
2112
+ // 验证分享密码 - 获取pass_code_token
2113
+ apiRouter.post("/verify_share_password", async (ctx) => {
2114
+ if (!ensureClient(ctx)) return;
2115
+
2116
+ try {
2117
+ const requestBody = await ctx.request.body.json();
2118
+ const shareId = requestBody.share_id;
2119
+ const passCode = requestBody.pass_code;
2120
+
2121
+ if (!shareId || !passCode) {
2122
+ ctx.response.status = Status.BadRequest;
2123
+ ctx.response.body = { error: "share_id and pass_code are required" };
2124
+ return;
2125
+ }
2126
+
2127
+ // 构造分享链接 - 使用迅雷网盘的格式
2128
+ const shareLink = `https://pan.xunlei.com/s/${shareId}`;
2129
+
2130
+ // 调用验证密码的API
2131
+ const result = await withCaptchaRetry(
2132
+ "GET:/drive/v1/share",
2133
+ () => THUNDERX_CLIENT.getShareInfo(shareLink, passCode)
2134
+ );
2135
+
2136
+ // 如果成功,返回pass_code_token
2137
+ if (result && result.pass_code_token) {
2138
+ ctx.response.body = {
2139
+ pass_code_token: result.pass_code_token,
2140
+ success: true
2141
+ };
2142
+ } else {
2143
+ throw new Error("Failed to get pass_code_token");
2144
+ }
2145
+ } catch (error) {
2146
+ console.error("验证分享密码失败:", error);
2147
+ ctx.response.status = Status.InternalServerError;
2148
+ ctx.response.body = { error: error.message };
2149
+ }
2150
+ });
2151
+
2152
+ // 添加健康检查路由
2153
+ apiRouter.get("/health", (ctx) => {
2154
+ ctx.response.body = {
2155
+ status: "ok",
2156
+ client_initialized: !!THUNDERX_CLIENT,
2157
+ last_token_refresh: lastRefreshTime ? new Date(lastRefreshTime).toISOString() : "never"
2158
+ };
2159
+ });
2160
+
2161
+ // Add routes to application
2162
+ app.use(frontRouter.routes());
2163
+ app.use(frontRouter.allowedMethods());
2164
+
2165
+ // Apply verification middleware to API routes
2166
+
2167
+ app.use(apiRouter.routes());
2168
+ app.use(apiRouter.allowedMethods());
2169
+
2170
+ // Error handler
2171
+ app.use(async (ctx, next) => {
2172
+ try {
2173
+ await next();
2174
+ } catch (err) {
2175
+ ctx.response.status = Status.InternalServerError;
2176
+ ctx.response.body = { error: err.message };
2177
+ console.error(err);
2178
+ }
2179
+ });
2180
+
2181
+ // Start the server if this is the main module
2182
+ if (import.meta.main) {
2183
+ const port = parseInt(Deno.env.get("PORT") || "8000");
2184
+ console.log(`Starting server on port ${port}...`);
2185
+
2186
+ app.addEventListener("listen", ({ hostname, port, secure }) => {
2187
+ console.log(
2188
+ `Server listening on: ${secure ? "https://" : "http://"}${
2189
+ hostname ?? "localhost"
2190
+ }:${port}`
2191
+ );
2192
+ });
2193
+
2194
+ await app.listen({ port });
2195
+ }