stnh70 commited on
Commit
fc9019c
·
verified ·
1 Parent(s): 034e3ac

Create thunderapi/thunderapi.ts

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