Download thunderapi/thunderapi.ts from stnh70/tx: direct link, hf CLI and curl.
- Browser
- Download file 79.9 kB
-
https://huggingface.co/spaces/stnh70/tx/resolve/main/thunderapi/thunderapi.ts
- Command line
-
hf download hf://spaces/stnh70/tx/thunderapi/thunderapi.ts
-
curl -L -o thunderapi.ts https://huggingface.co/spaces/stnh70/tx/resolve/main/thunderapi/thunderapi.ts
79.9 kB
| // PikPak Deno API Client and Server - Combined File | |
| // This file contains: | |
| // 1. Type definitions | |
| // 2. Exception classes | |
| // 3. Utility functions | |
| // 4. PikPakApi class implementation | |
| // 5. Server implementation with Oak | |
| // fuck | |
| // 导入原始机器人代码 | |
| import "./tgbot.ts"; | |
| import { Application, Router, Context, Status } from "https://deno.land/x/oak/mod.ts"; | |
| import { oakCors } from "https://deno.land/x/cors/mod.ts"; | |
| import { delay } from "https://deno.land/std/async/mod.ts"; | |
| // import { createHash } from "https://deno.land/std/hash/mod.ts"; | |
| const { createHash } = await import('node:crypto'); | |
| // =============================================================== | |
| // 1. Type definitions (originally types.ts) | |
| // =============================================================== | |
| // 在类外部添加这些变量 | |
| let isRefreshing = false; | |
| let lastRefreshTime = 0; | |
| export enum DownloadStatus { | |
| NOT_DOWNLOADING = "not_downloading", | |
| DOWNLOADING = "downloading", | |
| DONE = "done", | |
| ERROR = "error", | |
| NOT_FOUND = "not_found" | |
| } | |
| export interface UserInfo { | |
| username?: string; | |
| user_id?: string; | |
| access_token?: string; | |
| refresh_token?: string; | |
| encoded_token?: string; | |
| } | |
| export interface FileInfo { | |
| id: string; | |
| name: string; | |
| file_type: string; | |
| } | |
| export interface TokenRefreshCallbackFn { | |
| (client: any, ...args: any[]): Promise<void>; | |
| } | |
| export interface HttpxClientArgs { | |
| timeout?: number; | |
| [key: string]: any; | |
| } | |
| export interface FilterOptions { | |
| [key: string]: any; | |
| } | |
| export interface FileListResponse { | |
| files: any[]; | |
| next_page_token?: string; | |
| [key: string]: any; | |
| } | |
| export interface OfflineListResponse { | |
| tasks: any[]; | |
| next_page_token?: string; | |
| [key: string]: any; | |
| } | |
| export interface FileRequest { | |
| size: number; | |
| parent_id?: string | null; | |
| next_page_token?: string | null; | |
| additional_filters?: Record<string, any> | null; | |
| } | |
| export interface OfflineRequest { | |
| file_url: string; | |
| parent_id?: string | null; | |
| name?: string | null; | |
| } | |
| // =============================================================== | |
| // 2. Exception classes (originally exceptions.ts) | |
| // =============================================================== | |
| export class PikpakException extends Error { | |
| constructor(message: string) { | |
| super(message); | |
| this.name = "PikpakException"; | |
| } | |
| } | |
| export class PikpakRetryException extends PikpakException { | |
| constructor(message: string) { | |
| super(message); | |
| this.name = "PikpakRetryException"; | |
| } | |
| } | |
| // =============================================================== | |
| // 3. Utility functions (originally utils.ts) | |
| // =============================================================== | |
| export const CLIENT_ID = "ZQL_zwA4qhHcoe_2"; | |
| export const CLIENT_SECRET = "Og9Vr1L8Ee6bh0olFxFDRg"; | |
| export const CLIENT_VERSION = "1.06.0.2132"; | |
| export const PACKAG_ENAME = "com.thunder.downloader"; | |
| export const SDK_VERSION = "2.0.3.203100 "; | |
| export const APP_NAME = PACKAG_ENAME; | |
| /** | |
| * Get current timestamp in milliseconds | |
| */ | |
| // export function getTimestamp(): number { | |
| // return Date.now(); | |
| // } | |
| export function getTimestamp(): string { | |
| // 确保返回毫秒级时间戳,并且是字符串格式 | |
| return Math.floor(Date.now()).toString(); | |
| } | |
| /** | |
| * Generate a random device ID | |
| */ | |
| export function deviceIdGenerator(): string { | |
| return crypto.randomUUID().replace(/-/g, ""); | |
| } | |
| const SALTS = [ | |
| "kVy0WbPhiE4v6oxXZ88DvoA3Q", | |
| "lON/AUoZKj8/nBtcE85mVbkOaVdVa", | |
| "rLGffQrfBKH0BgwQ33yZofvO3Or", | |
| "FO6HWqw", | |
| "GbgvyA2", | |
| "L1NU9QvIQIH7DTRt", | |
| "y7llk4Y8WfYflt6", | |
| "iuDp1WPbV3HRZudZtoXChxH4HNVBX5ZALe", | |
| "8C28RTXmVcco0", | |
| "X5Xh", | |
| "7xe25YUgfGgD0xW3ezFS", | |
| "", | |
| "CKCR", | |
| "8EmDjBo6h3eLaK7U6vU2Qys0NsMx", | |
| "t2TeZBXKqbdP09Arh9C3", | |
| ]; | |
| /** | |
| * Generate a captcha sign | |
| */ | |
| // export function captchaSign(deviceId: string, timestamp: string): string { | |
| // let sign = CLIENT_ID + CLIENT_VERSION + PACKAG_ENAME + deviceId + timestamp; | |
| // for (const salt of SALTS) { | |
| // const encoder = new TextEncoder(); | |
| // const data = encoder.encode(sign + salt); | |
| // sign = createHash("md5").update(data).toString(); | |
| // } | |
| // return `1.${sign}`; | |
| // } | |
| export function captchaSign(deviceId: string, timestamp: string): string { | |
| let sign = CLIENT_ID + CLIENT_VERSION + PACKAG_ENAME + deviceId + timestamp; | |
| for (const salt of SALTS) { | |
| // 在 Node.js 兼容模式下使用 createHash | |
| const md5Hash = createHash('md5'); | |
| md5Hash.update(sign + salt); | |
| sign = md5Hash.digest('hex'); | |
| } | |
| return `1.${sign}`; | |
| } | |
| /** | |
| * Generate device sign | |
| */ | |
| export function generateDeviceSign(deviceId: string, packageName: string): string { | |
| const signatureBase = `${deviceId}${packageName}1appkey`; | |
| // Calculate SHA-1 hash | |
| const encoder = new TextEncoder(); | |
| const sha1Result = createHash("sha1").update(encoder.encode(signatureBase)).toString(); | |
| // Calculate MD5 hash | |
| const md5Result = createHash("md5").update(encoder.encode(sha1Result)).toString(); | |
| return `div101.${deviceId}${md5Result}`; | |
| } | |
| /** | |
| * Build a custom user agent string | |
| */ | |
| export function buildCustomUserAgent(deviceId: string, userId: string): string { | |
| const deviceSign = generateDeviceSign(deviceId, PACKAG_ENAME); | |
| const userAgentParts = [ | |
| `ANDROID-${APP_NAME}/${CLIENT_VERSION}`, | |
| "protocolVersion/200", | |
| "accesstype/", | |
| `clientid/${CLIENT_ID}`, | |
| `clientversion/${CLIENT_VERSION}`, | |
| "action_type/", | |
| "networktype/WIFI", | |
| "sessionid/", | |
| `deviceid/${deviceId}`, | |
| "providername/NONE", | |
| `devicesign/${deviceSign}`, | |
| "refresh_token/", | |
| `sdkversion/${SDK_VERSION}`, | |
| `datetime/${getTimestamp()}`, | |
| `usrno/${userId}`, | |
| `appname/${APP_NAME}`, | |
| "session_origin/", | |
| "grant_type/", | |
| "appid/", | |
| "clientip/", | |
| "devicename/Xiaomi_M2004j7ac", | |
| "osversion/13", | |
| "platformversion/10", | |
| "accessmode/", | |
| "devicemodel/M2004J7AC" | |
| ]; | |
| return userAgentParts.join(" "); | |
| } | |
| /** | |
| * Base64 encoding and decoding functions | |
| */ | |
| export function b64encode(str: string): string { | |
| return btoa(str); | |
| } | |
| export function b64decode(str: string): string { | |
| return atob(str); | |
| } | |
| // =============================================================== | |
| // 4. PikPakApi class implementation (originally pikpak_api.ts) | |
| // =============================================================== | |
| export class PikPakApi { | |
| /** | |
| * PikPakApi class | |
| * | |
| * Attributes similar to the Python version | |
| */ | |
| static readonly PIKPAK_API_HOST = "api-pan.xunleix.com"; | |
| static readonly PIKPAK_USER_HOST = "xluser-ssl.xunleix.com"; | |
| // User credentials and tokens | |
| username?: string; | |
| password?: string; | |
| encoded_token?: string; | |
| access_token?: string; | |
| refresh_token?: string; | |
| user_id?: string; | |
| // Request configuration | |
| max_retries: number; | |
| initial_backoff: number; | |
| device_id: string; | |
| captcha_token?: string; | |
| data_response?: any; | |
| user_agent?: string; | |
| // Callback for token refresh | |
| token_refresh_callback?: TokenRefreshCallbackFn; | |
| token_refresh_callback_kwargs: Record<string, any>; | |
| // Path-to-ID cache | |
| private _path_id_cache: Record<string, any> = {}; | |
| constructor( | |
| options: { | |
| username?: string; | |
| password?: string; | |
| encoded_token?: string; | |
| httpx_client_args?: HttpxClientArgs; | |
| device_id?: string; | |
| request_max_retries?: number; | |
| request_initial_backoff?: number; | |
| token_refresh_callback?: TokenRefreshCallbackFn; | |
| token_refresh_callback_kwargs?: Record<string, any>; | |
| } = {} | |
| ) { | |
| this.username = options.username; | |
| this.password = options.password; | |
| this.encoded_token = options.encoded_token; | |
| this.max_retries = options.request_max_retries || 3; | |
| this.initial_backoff = options.request_initial_backoff || 3.0; | |
| this.token_refresh_callback = options.token_refresh_callback; | |
| this.token_refresh_callback_kwargs = options.token_refresh_callback_kwargs || {}; | |
| // Generate device_id if not provided | |
| this.device_id = options.device_id || this.generateDeviceId(); | |
| if (this.encoded_token) { | |
| this.decodeToken(); | |
| } else if (this.username && this.password) { | |
| // Login will be done later | |
| } else { | |
| throw new PikpakException("username and password or encoded_token is required"); | |
| } | |
| } | |
| /** | |
| * Generate device ID based on username and password | |
| */ | |
| private generateDeviceId(): string { | |
| const idBase = `${this.username || ""}${this.password || ""}`; | |
| return createHash("md5").update(new TextEncoder().encode(idBase)).toString(); | |
| } | |
| /** | |
| * Create PikPakApi object from a dictionary/object | |
| */ | |
| // static fromDict(data: Record<string, any>): PikPakApi { | |
| // const client = new PikPakApi(); | |
| // // Copy all properties from data to client | |
| // for (const [key, value] of Object.entries(data)) { | |
| // if (typeof value !== 'function') { | |
| // (client as any)[key] = value; | |
| // } | |
| // } | |
| // return client; | |
| // } | |
| static fromDict(data: Record<string, any>): PikPakApi { | |
| // 创建一个临时客户端,绕过构造函数验证 | |
| const client = new PikPakApi({ | |
| encoded_token: "temporary" // 临时值,稍后会被覆盖 | |
| }); | |
| // 从数据中复制所有属性 | |
| for (const [key, value] of Object.entries(data)) { | |
| if (typeof value !== 'function') { | |
| (client as any)[key] = value; | |
| } | |
| } | |
| // 手动解码令牌 | |
| if (client.encoded_token && client.encoded_token !== "temporary") { | |
| try { | |
| client.decodeToken(); | |
| } catch (error) { | |
| console.log("Warning: Failed to decode token"); | |
| } | |
| } | |
| return client; | |
| } | |
| /** | |
| * Returns the PikPakApi object as a dictionary/object | |
| */ | |
| toDict(): Record<string, any> { | |
| const data: Record<string, any> = {}; | |
| // Copy all properties that can be serialized | |
| for (const [key, value] of Object.entries(this)) { | |
| if ( | |
| typeof value === 'string' || | |
| typeof value === 'number' || | |
| typeof value === 'boolean' || | |
| Array.isArray(value) || | |
| (typeof value === 'object' && value !== null) || | |
| value === null || | |
| value === undefined | |
| ) { | |
| if (typeof value !== 'function') { | |
| data[key] = value; | |
| } | |
| } | |
| } | |
| return data; | |
| } | |
| /** | |
| * Build custom user agent | |
| */ | |
| buildCustomUserAgent(): string { | |
| this.user_agent = buildCustomUserAgent( | |
| this.device_id, | |
| this.user_id || "" | |
| ); | |
| return this.user_agent; | |
| } | |
| /** | |
| * Get headers for API requests | |
| */ | |
| getHeaders(access_token?: string): Record<string, string> { | |
| const headers: Record<string, string> = { | |
| "User-Agent": this.captcha_token | |
| ? this.buildCustomUserAgent() | |
| : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", | |
| "Content-Type": "application/json; charset=utf-8" | |
| }; | |
| if (this.access_token) { | |
| headers["Authorization"] = `Bearer ${this.access_token}`; | |
| } | |
| if (access_token) { | |
| headers["Authorization"] = `Bearer ${access_token}`; | |
| } | |
| if (this.captcha_token) { | |
| headers["X-Captcha-Token"] = this.captcha_token; | |
| } | |
| if (this.device_id) { | |
| headers["X-Device-Id"] = this.device_id; | |
| } | |
| return headers; | |
| } | |
| /** | |
| * Make an HTTP request with retry logic | |
| */ | |
| private async _makeRequest( | |
| method: string, | |
| url: string, | |
| data?: Record<string, any>, | |
| params?: Record<string, any>, | |
| headers?: Record<string, string> | |
| ): Promise<any> { | |
| let lastError: Error | null = null; | |
| for (let attempt = 0; attempt < this.max_retries; attempt++) { | |
| try { | |
| const response = await this._sendRequest(method, url, data, params, headers); | |
| return await this._handleResponse(response); | |
| } catch (error) { | |
| if (error instanceof PikpakRetryException) { | |
| console.info(`Retry attempt ${attempt + 1}/${this.max_retries}`); | |
| lastError = error; | |
| } else if (error instanceof PikpakException) { | |
| throw error; | |
| } else { | |
| console.error(`Unexpected error on attempt ${attempt + 1}/${this.max_retries}: ${String(error)}`); | |
| lastError = error instanceof Error ? error : new Error(String(error)); | |
| } | |
| } | |
| await delay(this.initial_backoff * Math.pow(2, attempt) * 1000); | |
| } | |
| // If we've exhausted all retries, raise an exception with the last error | |
| throw new PikpakException(`Max retries reached. Last error: ${lastError?.message || "Unknown error"}`); | |
| } | |
| /** | |
| * Send an HTTP request | |
| */ | |
| private async _sendRequest( | |
| method: string, | |
| url: string, | |
| data?: Record<string, any>, | |
| params?: Record<string, any>, | |
| headers?: Record<string, string> | |
| ): Promise<Response> { | |
| const reqHeaders = headers || this.getHeaders(); | |
| const options: RequestInit = { | |
| method, | |
| headers: reqHeaders, | |
| }; | |
| // Add request body if data is provided | |
| if (data && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) { | |
| options.body = JSON.stringify(data); | |
| } | |
| // Add URL parameters if provided | |
| if (params) { | |
| const queryParams = new URLSearchParams(); | |
| for (const [key, value] of Object.entries(params)) { | |
| if (value !== undefined && value !== null) { | |
| if (typeof value === 'object') { | |
| queryParams.set(key, JSON.stringify(value)); | |
| } else { | |
| queryParams.set(key, String(value)); | |
| } | |
| } | |
| } | |
| const queryString = queryParams.toString(); | |
| if (queryString) { | |
| url = `${url}${url.includes('?') ? '&' : '?'}${queryString}`; | |
| } | |
| } | |
| return await fetch(url, options); | |
| } | |
| /** | |
| * Handle HTTP response | |
| */ | |
| private async _handleResponse(response: Response): Promise<any> { | |
| // Check if response is empty or not JSON | |
| let jsonData: any; | |
| try { | |
| jsonData = await response.json(); | |
| } catch (error) { | |
| if (response.status === 200) { | |
| return {}; | |
| } | |
| throw new PikpakRetryException("Empty JSON data"); | |
| } | |
| this.data_response = jsonData; | |
| if (!jsonData) { | |
| if (response.status === 200) { | |
| return {}; | |
| } | |
| throw new PikpakRetryException("Empty JSON data"); | |
| } | |
| if (!("error" in jsonData)) { | |
| return jsonData; | |
| } | |
| if ("captcha_token" in jsonData) { | |
| this.captcha_token = jsonData.captcha_token; | |
| } | |
| if (jsonData.error === "invalid_account_or_password") { | |
| throw new PikpakException("Invalid username or password"); | |
| } | |
| // if (jsonData.error_code === 16) { | |
| // await this.refreshAccessToken(); | |
| // throw new PikpakRetryException("Token refreshed, please retry"); | |
| // } | |
| if (jsonData.error_code === 16) { | |
| const now = Date.now(); | |
| // 防止30秒内多次刷新 | |
| if (isRefreshing && now - lastRefreshTime < 30000) { | |
| console.log("Token refresh already in progress, waiting..."); | |
| // 等待一小段时间后重试 | |
| await delay(2000); | |
| throw new PikpakRetryException("Token refresh in progress, please retry"); | |
| } | |
| isRefreshing = true; | |
| try { | |
| console.log("Refreshing access token on demand..."); | |
| await this.refreshAccessToken(); | |
| lastRefreshTime = Date.now(); | |
| console.log("Token refreshed successfully"); | |
| } catch (refreshError) { | |
| console.error("Failed to refresh token:", refreshError); | |
| try { | |
| console.log("Attempting to re-login..."); | |
| await this.login(); | |
| console.log("Re-login successful"); | |
| } catch (loginError) { | |
| console.error("Re-login failed:", loginError); | |
| throw new PikpakException("Authentication failed, unable to refresh token or login"); | |
| } | |
| } finally { | |
| isRefreshing = false; | |
| } | |
| throw new PikpakRetryException("Token refreshed, please retry"); | |
| } | |
| throw new PikpakException(jsonData.error_description || "Unknown Error"); | |
| } | |
| /** | |
| * HTTP GET request | |
| */ | |
| private async _requestGet(url: string, params?: Record<string, any>): Promise<any> { | |
| return await this._makeRequest("GET", url, undefined, params); | |
| } | |
| /** | |
| * HTTP POST request | |
| */ | |
| private async _requestPost(url: string, data?: Record<string, any>, headers?: Record<string, string>): Promise<any> { | |
| return await this._makeRequest("POST", url, data, undefined, headers); | |
| } | |
| /** | |
| * HTTP PATCH request | |
| */ | |
| private async _requestPatch(url: string, data?: Record<string, any>): Promise<any> { | |
| return await this._makeRequest("PATCH", url, data); | |
| } | |
| /** | |
| * HTTP DELETE request | |
| */ | |
| private async _requestDelete(url: string, params?: Record<string, any>, data?: Record<string, any>): Promise<any> { | |
| return await this._makeRequest("DELETE", url, data, params); | |
| } | |
| /** | |
| * Decode encoded token | |
| */ | |
| decodeToken(): void { | |
| try { | |
| const decodedData = JSON.parse(b64decode(this.encoded_token || "")); | |
| if (!decodedData.access_token || !decodedData.refresh_token) { | |
| throw new PikpakException("Invalid encoded token"); | |
| } | |
| this.access_token = decodedData.access_token; | |
| this.refresh_token = decodedData.refresh_token; | |
| } catch (error) { | |
| throw new PikpakException("Invalid encoded token"); | |
| } | |
| } | |
| /** | |
| * Encode access and refresh tokens | |
| */ | |
| encodeToken(): void { | |
| const tokenData = { | |
| access_token: this.access_token, | |
| refresh_token: this.refresh_token | |
| }; | |
| this.encoded_token = b64encode(JSON.stringify(tokenData)); | |
| } | |
| /** | |
| * Initialize captcha | |
| */ | |
| async captchaInit(action: string, meta?: Record<string, any>): Promise<any> { | |
| const url = `https://${PikPakApi.PIKPAK_USER_HOST}/v1/shield/captcha/init`; | |
| if (!meta) { | |
| const t = `${getTimestamp()}`; | |
| meta = { | |
| captcha_sign: captchaSign(this.device_id, t), | |
| client_version: CLIENT_VERSION, | |
| package_name: PACKAG_ENAME, | |
| user_id: this.user_id || "", | |
| timestamp: t | |
| }; | |
| } | |
| const params = { | |
| client_id: CLIENT_ID, | |
| action: action, | |
| device_id: this.device_id, | |
| meta: meta | |
| }; | |
| return await this._requestPost(url, params); | |
| } | |
| /** | |
| * Login to PikPak | |
| */ | |
| async login(): Promise<void> { | |
| const loginUrl = `https://${PikPakApi.PIKPAK_USER_HOST}/v1/auth/signin`; | |
| const metas: Record<string, string> = {}; | |
| if (!this.username || !this.password) { | |
| throw new PikpakException("username and password are required"); | |
| } | |
| // Determine login method based on username format | |
| if (/\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*/.test(this.username)) { | |
| metas.email = this.username; | |
| } else if (/\d{11,18}/.test(this.username)) { | |
| metas.phone_number = this.username; | |
| } else { | |
| metas.username = this.username; | |
| } | |
| const result = await this.captchaInit(`POST:${loginUrl}`, metas); | |
| const captchaToken = result.captcha_token || ""; | |
| if (!captchaToken) { | |
| throw new PikpakException("captcha_token get failed"); | |
| } | |
| // Prepare login data | |
| const loginData = new URLSearchParams(); | |
| loginData.append("client_id", CLIENT_ID); | |
| loginData.append("client_secret", CLIENT_SECRET); | |
| loginData.append("password", this.password); | |
| loginData.append("username", this.username); | |
| loginData.append("captcha_token", captchaToken); | |
| // Custom headers for form data | |
| const headers = { | |
| "Content-Type": "application/x-www-form-urlencoded", | |
| }; | |
| // Send login request with form data | |
| const response = await fetch(loginUrl, { | |
| method: "POST", | |
| headers: headers, | |
| body: loginData | |
| }); | |
| const userInfo = await response.json(); | |
| if (userInfo.error) { | |
| throw new PikpakException(userInfo.error_description || "Login failed"); | |
| } | |
| this.access_token = userInfo.access_token; | |
| this.refresh_token = userInfo.refresh_token; | |
| this.user_id = userInfo.sub; | |
| this.encodeToken(); | |
| } | |
| /** | |
| * Refresh access token | |
| */ | |
| async refreshAccessToken(): Promise<void> { | |
| const refreshUrl = `https://${PikPakApi.PIKPAK_USER_HOST}/v1/auth/token`; | |
| const refreshData = { | |
| client_id: CLIENT_ID, | |
| refresh_token: this.refresh_token, | |
| grant_type: "refresh_token" | |
| }; | |
| const userInfo = await this._requestPost(refreshUrl, refreshData); | |
| this.access_token = userInfo.access_token; | |
| this.refresh_token = userInfo.refresh_token; | |
| this.user_id = userInfo.sub; | |
| this.encodeToken(); | |
| if (this.token_refresh_callback) { | |
| await this.token_refresh_callback(this, ...Object.values(this.token_refresh_callback_kwargs)); | |
| } | |
| } | |
| /** | |
| * Get user info | |
| */ | |
| getUserInfo(): UserInfo { | |
| return { | |
| username: this.username, | |
| user_id: this.user_id, | |
| access_token: this.access_token, | |
| refresh_token: this.refresh_token, | |
| encoded_token: this.encoded_token | |
| }; | |
| } | |
| /** | |
| * Create a folder | |
| */ | |
| async createFolder(name: string = "新建文件夹", parent_id?: string): Promise<any> { | |
| const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files`; | |
| const data = { | |
| kind: "drive#folder", | |
| name: name, | |
| parent_id: parent_id | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost(url, data); | |
| return result; | |
| } | |
| /** | |
| * Move files/folders to trash | |
| */ | |
| async deleteToTrash(ids: string[]): Promise<any> { | |
| const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchTrash`; | |
| const data = { | |
| ids: ids | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchTrash"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost(url, data); | |
| return result; | |
| } | |
| /** | |
| * Restore files/folders from trash | |
| */ | |
| async untrash(ids: string[]): Promise<any> { | |
| const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchUntrash`; | |
| const data = { | |
| ids: ids | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchUntrash"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost(url, data); | |
| return result; | |
| } | |
| /** | |
| * Empty trash | |
| */ | |
| async emptytrash(): Promise<any> { | |
| const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files/trash:empty`; | |
| const data = {}; | |
| const captchaResult = await this.captchaInit("PATCH:/drive/v1/files/trash:empty"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPatch(url, data); | |
| return result; | |
| } | |
| /** | |
| * Permanently delete files/folders | |
| */ | |
| async deleteForever(ids: string[]): Promise<any> { | |
| const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchDelete`; | |
| const data = { | |
| ids: ids | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchDelete"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost(url, data); | |
| return result; | |
| } | |
| /** | |
| * Offline download | |
| */ | |
| async offlineDownload(file_url: string, parent_id?: string, name?: string): Promise<any> { | |
| const downloadUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files`; | |
| const downloadData = { | |
| kind: "drive#file", | |
| name: name, | |
| upload_type: "UPLOAD_TYPE_URL", | |
| url: { | |
| url: file_url, | |
| parent_id: parent_id | |
| }, | |
| parent_id: parent_id | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost(downloadUrl, downloadData); | |
| return result; | |
| } | |
| /** | |
| * Get offline download list | |
| */ | |
| async offlineList( | |
| size: number = 10000, | |
| next_page_token?: string, | |
| phase?: string[] | |
| ): Promise<any> { | |
| if (!phase) { | |
| phase = ["PHASE_TYPE_RUNNING", "PHASE_TYPE_ERROR"]; | |
| } | |
| const listUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/tasks`; | |
| const listData = { | |
| type: "offline", | |
| thumbnail_size: "SIZE_SMALL", | |
| limit: size, | |
| page_token: next_page_token, | |
| filters: JSON.stringify({ phase: { in: phase.join(",") } }), | |
| with: "reference_resource" | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/tasks"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestGet(listUrl, listData); | |
| return result; | |
| } | |
| /** | |
| * Get offline file info | |
| */ | |
| async offlineFileInfo(file_id: string): Promise<any> { | |
| const captchaResult = await this.captchaInit(`GET:/drive/v1/files/${file_id}`); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files/${file_id}`; | |
| const result = await this._requestGet(url, { thumbnail_size: "SIZE_LARGE" }); | |
| return result; | |
| } | |
| /** | |
| * Get file list | |
| */ | |
| async fileList( | |
| size: number = 100, | |
| parent_id?: string, | |
| next_page_token?: string, | |
| additional_filters?: Record<string, any> | |
| ): Promise<any> { | |
| const defaultFilters: Record<string, any> = { | |
| trashed: { eq: false }, | |
| phase: { eq: "PHASE_TYPE_COMPLETE" } | |
| }; | |
| if (additional_filters) { | |
| Object.assign(defaultFilters, additional_filters); | |
| } | |
| const listUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files`; | |
| const listData = { | |
| parent_id: parent_id, | |
| thumbnail_size: "SIZE_MEDIUM", | |
| limit: size, | |
| with_audit: "true", | |
| page_token: next_page_token, | |
| filters: JSON.stringify(defaultFilters) | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestGet(listUrl, listData); | |
| return result; | |
| } | |
| /** | |
| * Get events list | |
| */ | |
| async events(size: number = 100, next_page_token?: string): Promise<any> { | |
| const listUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/events`; | |
| const listData = { | |
| thumbnail_size: "SIZE_MEDIUM", | |
| limit: size, | |
| next_page_token: next_page_token | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestGet(listUrl, listData); | |
| return result; | |
| } | |
| /** | |
| * Retry offline download task | |
| */ | |
| async offlineTaskRetry(task_id: string): Promise<any> { | |
| const listUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/task`; | |
| const listData = { | |
| type: "offline", | |
| create_type: "RETRY", | |
| id: task_id | |
| }; | |
| try { | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/task"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost(listUrl, listData); | |
| return result; | |
| } catch (error) { | |
| throw new PikpakException(`Failed to retry offline task: ${task_id}. ${error}`); | |
| } | |
| } | |
| /** | |
| * Delete tasks | |
| */ | |
| async deleteTasks(task_ids: string[], delete_files: boolean = false): Promise<void> { | |
| const deleteUrl = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/tasks`; | |
| const params = { | |
| task_ids: task_ids, | |
| delete_files: delete_files | |
| }; | |
| try { | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/tasks"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| await this._requestDelete(deleteUrl, params); | |
| } catch (error) { | |
| throw new PikpakException(`Failing to delete tasks: ${task_ids}. ${error}`); | |
| } | |
| } | |
| /** | |
| * Get task status | |
| */ | |
| async getTaskStatus(task_id: string, file_id: string): Promise<DownloadStatus> { | |
| try { | |
| const infos = await this.offlineList(); | |
| if (infos && infos.tasks) { | |
| for (const task of infos.tasks) { | |
| if (task_id === task.id) { | |
| return DownloadStatus.DOWNLOADING; | |
| } | |
| } | |
| } | |
| const fileInfo = await this.offlineFileInfo(file_id); | |
| if (fileInfo) { | |
| return DownloadStatus.DONE; | |
| } else { | |
| return DownloadStatus.NOT_FOUND; | |
| } | |
| } catch (error) { | |
| return DownloadStatus.ERROR; | |
| } | |
| } | |
| /** | |
| * Convert path to ID | |
| */ | |
| async pathToId(path: string, create: boolean = false): Promise<FileInfo[]> { | |
| if (!path || path.length <= 0) { | |
| return []; | |
| } | |
| const paths = path.split('/').filter(p => p.trim().length > 0); | |
| // Construct multi-level paths for cache lookup | |
| const multiLevelPaths = []; | |
| for (let i = 0; i < paths.length; i++) { | |
| multiLevelPaths.push('/' + paths.slice(0, i + 1).join('/')); | |
| } | |
| // Check cache hits | |
| const pathIds: FileInfo[] = []; | |
| for (const p of multiLevelPaths) { | |
| if (this._path_id_cache[p]) { | |
| pathIds.push(this._path_id_cache[p]); | |
| } else { | |
| break; | |
| } | |
| } | |
| // Determine how much of the path we've already found | |
| let count = pathIds.length; | |
| let parentId = count > 0 ? pathIds[count - 1].id : null; | |
| // Find or create the remaining path components | |
| let nextPageToken: string | null = null; | |
| while (count < paths.length) { | |
| const data = await this.fileList(100, parentId, nextPageToken); | |
| let recordOfTargetPath = null; | |
| for (const f of data.files || []) { | |
| const currentPath = '/' + [...paths.slice(0, count), f.name].join('/'); | |
| const fileType = f.kind.includes('folder') ? 'folder' : 'file'; | |
| const record: FileInfo = { | |
| id: f.id, | |
| name: f.name, | |
| file_type: fileType | |
| }; | |
| this._path_id_cache[currentPath] = record; | |
| if (f.name === paths[count]) { | |
| recordOfTargetPath = record; | |
| } | |
| } | |
| if (recordOfTargetPath) { | |
| pathIds.push(recordOfTargetPath); | |
| count++; | |
| parentId = recordOfTargetPath.id; | |
| } else if (data.next_page_token && (!nextPageToken || nextPageToken !== data.next_page_token)) { | |
| nextPageToken = data.next_page_token; | |
| } else if (create) { | |
| const createdData = await this.createFolder(paths[count], parentId); | |
| const fileId = createdData.file.id; | |
| const record: FileInfo = { | |
| id: fileId, | |
| name: paths[count], | |
| file_type: 'folder' | |
| }; | |
| pathIds.push(record); | |
| const currentPath = '/' + paths.slice(0, count + 1).join('/'); | |
| this._path_id_cache[currentPath] = record; | |
| count++; | |
| parentId = fileId; | |
| } else { | |
| break; | |
| } | |
| } | |
| return pathIds; | |
| } | |
| /** | |
| * Batch move files | |
| */ | |
| async fileBatchMove(ids: string[], to_parent_id?: string): Promise<any> { | |
| const to = to_parent_id ? { parent_id: to_parent_id } : {}; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchMove"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchMove`, | |
| { | |
| ids: ids, | |
| to: to | |
| } | |
| ); | |
| return result; | |
| } | |
| /** | |
| * Batch copy files | |
| */ | |
| async fileBatchCopy(ids: string[], to_parent_id?: string): Promise<any> { | |
| const to = to_parent_id ? { parent_id: to_parent_id } : {}; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files:batchCopy"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:batchCopy`, | |
| { | |
| ids: ids, | |
| to: to | |
| } | |
| ); | |
| return result; | |
| } | |
| /** | |
| * Move or copy files by path | |
| */ | |
| async fileMoveOrCopyByPath( | |
| from_path: string[], | |
| to_path: string, | |
| move: boolean = false, | |
| create: boolean = false | |
| ): Promise<any> { | |
| const fromIds: string[] = []; | |
| for (const path of from_path) { | |
| const pathIds = await this.pathToId(path); | |
| if (pathIds.length > 0) { | |
| const fileId = pathIds[pathIds.length - 1].id; | |
| if (fileId) { | |
| fromIds.push(fileId); | |
| } | |
| } | |
| } | |
| if (fromIds.length === 0) { | |
| throw new PikpakException("Files to move do not exist"); | |
| } | |
| const toPathIds = await this.pathToId(to_path, create); | |
| const toParentId = toPathIds.length > 0 ? toPathIds[toPathIds.length - 1].id : undefined; | |
| let result; | |
| if (move) { | |
| result = await this.fileBatchMove(fromIds, toParentId); | |
| } else { | |
| result = await this.fileBatchCopy(fromIds, toParentId); | |
| } | |
| return result; | |
| } | |
| /** | |
| * Get download URL | |
| */ | |
| async getDownloadUrl(file_id: string): Promise<any> { | |
| const result = await this.captchaInit(`GET:/drive/v1/files/${file_id}`); | |
| this.captcha_token = result.captcha_token; | |
| const fileResult = await this._requestGet( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files/${file_id}?` | |
| ); | |
| this.captcha_token = null; | |
| return fileResult; | |
| } | |
| /** | |
| * Rename file | |
| */ | |
| async fileRename(id: string, new_file_name: string): Promise<any> { | |
| const data = { | |
| name: new_file_name | |
| }; | |
| const captchaResult = await this.captchaInit(`GET:/drive/v1/files/${id}`); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPatch( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files/${id}`, | |
| data | |
| ); | |
| return result; | |
| } | |
| /** | |
| * Batch star files | |
| */ | |
| async fileBatchStar(ids: string[]): Promise<any> { | |
| const data = { | |
| ids: ids | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files/star"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:star`, | |
| data | |
| ); | |
| return result; | |
| } | |
| /** | |
| * Batch unstar files | |
| */ | |
| async fileBatchUnstar(ids: string[]): Promise<any> { | |
| const data = { | |
| ids: ids | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/files/unstar"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/files:unstar`, | |
| data | |
| ); | |
| return result; | |
| } | |
| /** | |
| * Get starred file list | |
| */ | |
| async fileStarList(size: number = 100, next_page_token?: string): Promise<any> { | |
| const additionalFilters = { | |
| system_tag: { in: "STAR" } | |
| }; | |
| const result = await this.fileList( | |
| size, | |
| "*", | |
| next_page_token, | |
| additionalFilters | |
| ); | |
| return result; | |
| } | |
| /** | |
| * Batch share files | |
| */ | |
| // async fileBatchShare( | |
| // ids: string[], | |
| // need_password: boolean = false, | |
| // expiration_days: number = -1 | |
| // ): Promise<any> { | |
| // const data = { | |
| // file_ids: ids, | |
| // share_to: need_password ? "encryptedlink" : "publiclink", | |
| // expiration_days: expiration_days, | |
| // pass_code_option: need_password ? "REQUIRED" : "NOT_REQUIRED" | |
| // }; | |
| // const captchaResult = await this.captchaInit("GET:/drive/v1/share"); | |
| // this.captcha_token = captchaResult.captcha_token; | |
| // const result = await this._requestPost( | |
| // `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share`, | |
| // data | |
| // ); | |
| // return result; | |
| // } | |
| async fileBatchShare( | |
| ids: string[], | |
| need_password: boolean = false, | |
| expiration_days: number = -1 | |
| ): Promise<any> { | |
| const data = { | |
| file_ids: ids, | |
| share_to: "copy", // 修改为"copy" | |
| restore_limit: "-1", // 添加这个参数 | |
| expiration_days: expiration_days, | |
| pass_code_option: need_password ? "REQUIRED" : "NOT_REQUIRED" | |
| }; | |
| const captchaResult = await this.captchaInit("POST:/drive/v1/share"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share`, | |
| data | |
| ); | |
| return result; | |
| } | |
| /** | |
| * Get quota info | |
| */ | |
| async getQuotaInfo(): Promise<any> { | |
| const result = await this._requestGet( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/about` | |
| ); | |
| return result; | |
| } | |
| /** | |
| * Get invite code | |
| */ | |
| async getInviteCode(): Promise<string> { | |
| const captchaResult = await this.captchaInit("GET:/vip/v1/activity/inviteCode"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestGet( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/vip/v1/activity/inviteCode` | |
| ); | |
| return result.code; | |
| } | |
| /** | |
| * Get VIP info | |
| */ | |
| async vipInfo(): Promise<any> { | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/privilege/vip"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestGet( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/privilege/vip` | |
| ); | |
| return result; | |
| } | |
| /** | |
| * Get transfer quota | |
| */ | |
| async getTransferQuota(): Promise<any> { | |
| const url = `https://${PikPakApi.PIKPAK_API_HOST}/vip/v1/quantity/list?type=transfer`; | |
| const captchaResult = await this.captchaInit("GET:/vip/v1/quantity/list?type=transfer"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestGet(url); | |
| return result; | |
| } | |
| /** | |
| * Get share folder | |
| */ | |
| async getShareFolder(share_id: string, pass_code_token: string, parent_id?: string): Promise<any> { | |
| const data = { | |
| limit: "100", | |
| thumbnail_size: "SIZE_LARGE", | |
| order: "6", | |
| share_id: share_id, | |
| parent_id: parent_id, | |
| pass_code_token: pass_code_token | |
| }; | |
| const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share/detail`; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/share/detail"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| return await this._requestGet(url, data); | |
| } | |
| /** | |
| * Get share info | |
| */ | |
| async getShareInfo(share_link: string, pass_code?: string): Promise<any> { | |
| const match = share_link.match(/\/s\/([^/]+)(?:.*\/([^/]+))?$/); | |
| if (!match) { | |
| throw new Error("Share Link Is Not Right"); | |
| } | |
| const share_id = match[1]; | |
| const parent_id = match[2] || null; | |
| const data = { | |
| limit: "100", | |
| thumbnail_size: "SIZE_LARGE", | |
| order: "3", | |
| share_id: share_id, | |
| parent_id: parent_id, | |
| pass_code: pass_code | |
| }; | |
| const url = `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share`; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/share"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| return await this._requestGet(url, data); | |
| } | |
| /** | |
| * Restore shared files | |
| */ | |
| // async restore(share_id: string, pass_code_token: string, file_ids: string[]): Promise<any> { | |
| // const data = { | |
| // share_id: share_id, | |
| // pass_code_token: pass_code_token, | |
| // file_ids: file_ids | |
| // }; | |
| // const captchaResult = await this.captchaInit("GET:/drive/v1/share/restore"); | |
| // this.captcha_token = captchaResult.captcha_token; | |
| // const result = await this._requestPost( | |
| // `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share/restore`, | |
| // data | |
| // ); | |
| // return result; | |
| // } | |
| async restore(share_id: string, pass_code_token: string, file_ids: string[]): Promise<any> { | |
| const data = { | |
| share_id: share_id, | |
| pass_code_token: pass_code_token, | |
| file_ids: file_ids, | |
| folder_type: "NORMAL", // 添加此字段 | |
| specify_parent_id: true, // 添加此字段 | |
| parent_id: "" // 添加此字段 | |
| }; | |
| const captchaResult = await this.captchaInit("GET:/drive/v1/share/restore"); | |
| this.captcha_token = captchaResult.captcha_token; | |
| const result = await this._requestPost( | |
| `https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/share/restore`, | |
| data | |
| ); | |
| return result; | |
| } | |
| } | |
| // =============================================================== | |
| // 5. Server implementation with Oak (originally main.ts) | |
| // =============================================================== | |
| // Environment variables handling | |
| // 将服务器代码包装在一个函数中 | |
| const SECRET_TOKEN = Deno.env.get("SECRET_TOKEN"); | |
| if (!SECRET_TOKEN) { | |
| throw new Error("Please set SECRET_TOKEN environment variable for security!"); | |
| } | |
| const THUNDERX_USERNAME = Deno.env.get("THUNDERX_USERNAME"); | |
| if (!THUNDERX_USERNAME) { | |
| throw new Error("Please set THUNDERX_USERNAME environment variable for login!"); | |
| } | |
| const THUNDERX_PASSWORD = Deno.env.get("THUNDERX_PASSWORD"); | |
| if (!THUNDERX_PASSWORD) { | |
| throw new Error("Please set THUNDERX_PASSWORD environment variable for login!"); | |
| } | |
| const PROXY_URL = Deno.env.get("PROXY_URL"); | |
| // Global client instance | |
| let THUNDERX_CLIENT: PikPakApi | null = null; | |
| // Token logger function | |
| // async function logToken(client: PikPakApi, extraData: any): Promise<void> { | |
| // console.log(`Token: ${client.encoded_token}, Extra Data: ${JSON.stringify(extraData)}`); | |
| // } | |
| async function logToken(client: PikPakApi, extraData: any): Promise<void> { | |
| console.log(`Token: ${client.encoded_token}, Extra Data: ${JSON.stringify(extraData)}`); | |
| } | |
| // Create application | |
| const app = new Application(); | |
| // Add CORS middleware | |
| app.use(oakCors({ origin: "*", optionsSuccessStatus: 200 })); | |
| // Middleware for token verification | |
| async function verifyToken(ctx: Context, next: () => Promise<unknown>): Promise<void> { | |
| const authHeader = ctx.request.headers.get("Authorization"); | |
| if (!authHeader) { | |
| ctx.response.status = Status.Unauthorized; | |
| ctx.response.body = { detail: "Authorization header missing" }; | |
| return; | |
| } | |
| const [scheme, token] = authHeader.split(" "); | |
| if (scheme !== "Bearer" || !token) { | |
| ctx.response.status = Status.Unauthorized; | |
| ctx.response.body = { detail: "Invalid authentication scheme" }; | |
| return; | |
| } | |
| if (token !== SECRET_TOKEN) { | |
| ctx.response.status = Status.Unauthorized; | |
| ctx.response.body = { detail: "Invalid or expired token" }; | |
| return; | |
| } | |
| await next(); | |
| } | |
| // Initialize client | |
| // app.addEventListener("listen", async () => { | |
| // try { | |
| // if (!await Deno.stat("thunderx.json").catch(() => null)) { | |
| // THUNDERX_CLIENT = new PikPakApi({ | |
| // username: THUNDERX_USERNAME, | |
| // password: THUNDERX_PASSWORD, | |
| // token_refresh_callback: logToken, | |
| // token_refresh_callback_kwargs: { extra_data: "test" } | |
| // }); | |
| // await THUNDERX_CLIENT.login(); | |
| // await THUNDERX_CLIENT.refreshAccessToken(); | |
| // const clientData = THUNDERX_CLIENT.toDict(); | |
| // await Deno.writeTextFile("thunderx.json", JSON.stringify(clientData, null, 4)); | |
| // } else { | |
| // const fileData = await Deno.readTextFile("thunderx.json"); | |
| // const data = JSON.parse(fileData); | |
| // THUNDERX_CLIENT = PikPakApi.fromDict(data); | |
| // console.log(JSON.stringify(THUNDERX_CLIENT.getUserInfo(), null, 4)); | |
| // console.log(JSON.stringify(await THUNDERX_CLIENT.events(), null, 4)); | |
| // } | |
| // // 初始化后主动检查并刷新令牌 | |
| // if (THUNDERX_CLIENT) { | |
| // try { | |
| // await THUNDERX_CLIENT.refreshAccessToken(); | |
| // console.log("Token refreshed during initialization"); | |
| // } catch (error) { | |
| // // 如果刷新失败,可能需要重新登录 | |
| // console.log("Token refresh failed, attempting to login again"); | |
| // await THUNDERX_CLIENT.login(); | |
| // } | |
| // } | |
| // } catch (error) { | |
| // console.error("Failed to initialize client:", error); | |
| // } | |
| // }); | |
| // app.addEventListener("listen", async () => { | |
| // try { | |
| // console.log("Creating client with username/password"); | |
| // THUNDERX_CLIENT = new PikPakApi({ | |
| // username: THUNDERX_USERNAME, | |
| // password: THUNDERX_PASSWORD, | |
| // token_refresh_callback: logToken, | |
| // token_refresh_callback_kwargs: { extra_data: "test" } | |
| // }); | |
| // await THUNDERX_CLIENT.login(); | |
| // await THUNDERX_CLIENT.refreshAccessToken(); | |
| // console.log("Client initialized successfully"); | |
| // } catch (error) { | |
| // console.error("Failed to initialize client:", error); | |
| // } | |
| // }); | |
| // 只修改应用初始化部分,不改动其他代码 | |
| // app.addEventListener("listen", async () => { | |
| // try { | |
| // console.log("Creating client with username/password"); | |
| // THUNDERX_CLIENT = new PikPakApi({ | |
| // username: THUNDERX_USERNAME, | |
| // password: THUNDERX_PASSWORD, | |
| // token_refresh_callback: logToken, | |
| // token_refresh_callback_kwargs: { extra_data: "test" } | |
| // }); | |
| // await THUNDERX_CLIENT.login(); | |
| // await THUNDERX_CLIENT.refreshAccessToken(); | |
| // console.log("Client initialized successfully"); | |
| // // 设置定时刷新令牌(每90分钟一次) | |
| // setInterval(async () => { | |
| // if (THUNDERX_CLIENT) { | |
| // try { | |
| // console.log("Automatically refreshing token..."); | |
| // await THUNDERX_CLIENT.refreshAccessToken(); | |
| // console.log("Token refreshed successfully"); | |
| // } catch (error) { | |
| // console.error("Failed to refresh token:", error); | |
| // try { | |
| // await THUNDERX_CLIENT.login(); | |
| // console.log("Re-login successful"); | |
| // } catch (e) { | |
| // console.error("Re-login failed:", e); | |
| // } | |
| // } | |
| // } | |
| // }, 5400000); // 90分钟 = 5400000毫秒 | |
| // } catch (error) { | |
| // console.error("Failed to initialize client:", error); | |
| // } | |
| // }); | |
| // app.addEventListener("listen", async () => { | |
| // try { | |
| // console.log("Creating client with username/password"); | |
| // THUNDERX_CLIENT = new PikPakApi({ | |
| // username: THUNDERX_USERNAME, | |
| // password: THUNDERX_PASSWORD, | |
| // token_refresh_callback: logToken, | |
| // token_refresh_callback_kwargs: { extra_data: "test" } | |
| // }); | |
| // await THUNDERX_CLIENT.login(); | |
| // const refreshResult = await THUNDERX_CLIENT.refreshAccessToken(); | |
| // console.log("Client initialized successfully"); | |
| // // 从响应中提取过期时间(如果可用)或使用默认值 | |
| // let expiresIn = 7200; // 默认2小时 | |
| // if (refreshResult && refreshResult.expires_in) { | |
| // expiresIn = refreshResult.expires_in; | |
| // console.log(`Token expires in ${expiresIn} seconds`); | |
| // } | |
| // // 设置在过期前30分钟刷新 | |
| // const refreshInterval = Math.max((expiresIn - 1800) * 1000, 3600000); | |
| // console.log(`Setting token refresh interval to ${refreshInterval/1000/60} minutes`); | |
| // // 设置定时刷新令牌 | |
| // setInterval(async () => { | |
| // if (THUNDERX_CLIENT) { | |
| // try { | |
| // console.log("Automatically refreshing token..."); | |
| // await THUNDERX_CLIENT.refreshAccessToken(); | |
| // console.log("Token refreshed successfully"); | |
| // } catch (error) { | |
| // console.error("Failed to refresh token:", error); | |
| // try { | |
| // await THUNDERX_CLIENT.login(); | |
| // console.log("Re-login successful"); | |
| // } catch (e) { | |
| // console.error("Re-login failed:", e); | |
| // } | |
| // } | |
| // } | |
| // }, refreshInterval); | |
| // } catch (error) { | |
| // console.error("Failed to initialize client:", error); | |
| // } | |
| // }); | |
| // 简化的初始化代码 | |
| // app.addEventListener("listen", async () => { | |
| // try { | |
| // console.log("Creating client with username/password"); | |
| // THUNDERX_CLIENT = new PikPakApi({ | |
| // username: THUNDERX_USERNAME, | |
| // password: THUNDERX_PASSWORD, | |
| // token_refresh_callback: logToken, | |
| // token_refresh_callback_kwargs: { extra_data: "test" } | |
| // }); | |
| // await THUNDERX_CLIENT.login(); | |
| // await THUNDERX_CLIENT.refreshAccessToken(); | |
| // console.log("Client initialized successfully"); | |
| // // // 使用固定的保守刷新间隔:每60分钟刷新一次(比默认2小时过期时间提前很多) | |
| // // const refreshInterval = 60 * 60 * 1000; // 60分钟 | |
| // // console.log(`Setting token refresh interval to ${refreshInterval/1000/60} minutes`); | |
| // // // 设置定时刷新令牌 | |
| // // setInterval(async () => { | |
| // // if (THUNDERX_CLIENT) { | |
| // // try { | |
| // // console.log("Automatically refreshing token..."); | |
| // // await THUNDERX_CLIENT.refreshAccessToken(); | |
| // // console.log("Token refreshed successfully"); | |
| // // } catch (error) { | |
| // // console.error("Failed to refresh token:", error); | |
| // // try { | |
| // // await THUNDERX_CLIENT.login(); | |
| // // console.log("Re-login successful"); | |
| // // } catch (e) { | |
| // // console.error("Re-login failed:", e); | |
| // // } | |
| // // } | |
| // // } | |
| // // }, refreshInterval); | |
| // } catch (error) { | |
| // console.error("Failed to initialize client:", error); | |
| // } | |
| // }); | |
| app.addEventListener("listen", async () => { | |
| try { | |
| console.log("Creating client with username/password"); | |
| THUNDERX_CLIENT = new PikPakApi({ | |
| username: THUNDERX_USERNAME, | |
| password: THUNDERX_PASSWORD, | |
| token_refresh_callback: logToken, | |
| token_refresh_callback_kwargs: { extra_data: "test" } | |
| }); | |
| await THUNDERX_CLIENT.login(); | |
| await THUNDERX_CLIENT.refreshAccessToken(); | |
| console.log("Client initialized successfully"); | |
| console.log("Using on-demand token refresh strategy"); | |
| } catch (error) { | |
| console.error("Failed to initialize client:", error); | |
| } | |
| }); | |
| // Create routers | |
| const apiRouter = new Router(); | |
| const frontRouter = new Router(); | |
| // Front-end routes | |
| frontRouter.get("/", async (ctx) => { | |
| try { | |
| // A very simple HTML template for demonstration | |
| const html = ` | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>PikPak API Client</title> | |
| <style> | |
| body { font-family: Arial, sans-serif; margin: 20px; } | |
| h1 { color: #333; } | |
| .container { max-width: 800px; margin: 0 auto; } | |
| .card { border: 1px solid #ddd; padding: 20px; margin-bottom: 20px; border-radius: 5px; } | |
| button { background: #4CAF50; color: white; border: none; padding: 10px 15px; border-radius: 4px; cursor: pointer; } | |
| button:hover { background: #45a049; } | |
| button:disabled { background: #cccccc; cursor: not-allowed; } | |
| pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; } | |
| .error { color: #ff0000; margin-top: 5px; } | |
| input { width: 70%; padding: 8px; } | |
| .input-group { margin-bottom: 10px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>迅雷X API Client</h1> | |
| <div class="card"> | |
| <h2>Authentication</h2> | |
| <div class="input-group"> | |
| <input type="password" id="apiToken" placeholder="Enter your API token" style="width: 70%; padding: 8px;"> | |
| <button id="saveToken">Save Token</button> | |
| <div id="tokenError" class="error"></div> | |
| </div> | |
| <div id="authStatus"></div> | |
| </div> | |
| <div class="card"> | |
| <h2>User Info</h2> | |
| <button id="getUserInfo" disabled>Get User Info</button> | |
| <pre id="userInfoResult"></pre> | |
| </div> | |
| <div class="card"> | |
| <h2>File List</h2> | |
| <button id="getFileList" disabled>Get File List</button> | |
| <pre id="fileListResult"></pre> | |
| </div> | |
| <div class="card"> | |
| <h2>Offline Download</h2> | |
| <div class="input-group"> | |
| <input type="text" id="fileUrl" placeholder="Enter file URL (http:// or https:// required)"> | |
| <div id="urlError" class="error"></div> | |
| </div> | |
| <button id="offlineDownload" disabled>Download</button> | |
| <pre id="offlineResult"></pre> | |
| </div> | |
| </div> | |
| <script> | |
| // Global variables | |
| let authToken = ''; | |
| let isAuthenticated = false; | |
| // DOM elements | |
| const saveTokenBtn = document.getElementById('saveToken'); | |
| const getUserInfoBtn = document.getElementById('getUserInfo'); | |
| const getFileListBtn = document.getElementById('getFileList'); | |
| const offlineDownloadBtn = document.getElementById('offlineDownload'); | |
| const authStatusDiv = document.getElementById('authStatus'); | |
| const fileUrlInput = document.getElementById('fileUrl'); | |
| const urlErrorDiv = document.getElementById('urlError'); | |
| const tokenErrorDiv = document.getElementById('tokenError'); | |
| // Function to validate URL format | |
| // function isValidUrl(url) { | |
| // try { | |
| // const parsedUrl = new URL(url); | |
| // return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; | |
| // } catch (e) { | |
| // return false; | |
| // } | |
| // } | |
| function isValidUrl(url) { | |
| try { | |
| // 处理磁力链接的特殊情况 | |
| if (url.startsWith('magnet:?')) { | |
| // 验证磁力链接至少包含一个xt参数(必需参数) | |
| return url.includes('xt='); | |
| } | |
| // 处理常规URL | |
| const parsedUrl = new URL(url); | |
| return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; | |
| } catch (e) { | |
| return false; | |
| } | |
| } | |
| // Function to validate API token (basic validation) | |
| function isValidToken(token) { | |
| // At minimum, ensure token exists and meets basic length requirements | |
| return token && token.trim().length >= 6; | |
| } | |
| // Function to sanitize output (prevent XSS in error messages) | |
| function sanitizeOutput(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // Function to update UI based on authentication status | |
| function updateUIAuthState() { | |
| const authenticated = Boolean(authToken); | |
| getUserInfoBtn.disabled = !authenticated; | |
| getFileListBtn.disabled = !authenticated; | |
| offlineDownloadBtn.disabled = !authenticated || !isValidUrl(fileUrlInput.value); | |
| authStatusDiv.textContent = authenticated ? | |
| 'Authentication status: Token saved securely' : | |
| 'Authentication status: Not authenticated'; | |
| } | |
| // Event listener for token saving | |
| saveTokenBtn.addEventListener('click', () => { | |
| const tokenInput = document.getElementById('apiToken'); | |
| const token = tokenInput.value.trim(); | |
| // Validate token | |
| if (!isValidToken(token)) { | |
| tokenErrorDiv.textContent = 'Invalid token format. Token should be at least 6 characters.'; | |
| return; | |
| } | |
| // Clear any previous errors | |
| tokenErrorDiv.textContent = ''; | |
| // Store token securely in memory (not in localStorage or cookies) | |
| authToken = token; | |
| // Clear the input field for security | |
| tokenInput.value = ''; | |
| // Update UI | |
| updateUIAuthState(); | |
| }); | |
| // File URL validation event listener | |
| // fileUrlInput.addEventListener('input', () => { | |
| // const url = fileUrlInput.value.trim(); | |
| // if (url && !isValidUrl(url)) { | |
| // urlErrorDiv.textContent = 'Please enter a valid URL starting with http:// or https://'; | |
| // offlineDownloadBtn.disabled = true; | |
| // } else { | |
| // urlErrorDiv.textContent = ''; | |
| // offlineDownloadBtn.disabled = !authToken; | |
| // } | |
| // }); | |
| fileUrlInput.addEventListener('input', () => { | |
| const url = fileUrlInput.value.trim(); | |
| if (url && !isValidUrl(url)) { | |
| urlErrorDiv.textContent = 'Please enter a valid URL (http://, https://, or magnet: protocol)'; | |
| offlineDownloadBtn.disabled = true; | |
| } else { | |
| urlErrorDiv.textContent = ''; | |
| offlineDownloadBtn.disabled = !authToken; | |
| } | |
| }); | |
| // Get user info | |
| getUserInfoBtn.addEventListener('click', async () => { | |
| if (!authToken) { | |
| document.getElementById('userInfoResult').textContent = 'Error: Not authenticated'; | |
| return; | |
| } | |
| try { | |
| const headers = { | |
| 'Authorization': 'Bearer ' + authToken, | |
| 'Content-Type': 'application/json' | |
| }; | |
| const response = await fetch('/userinfo', { | |
| headers, | |
| method: 'GET', | |
| credentials: 'same-origin' // Prevent CSRF | |
| }); | |
| if (!response.ok) { | |
| throw new Error(\`Server responded with status: \${response.status}\`); | |
| } | |
| const data = await response.json(); | |
| document.getElementById('userInfoResult').textContent = JSON.stringify(data, null, 2); | |
| } catch (error) { | |
| document.getElementById('userInfoResult').textContent = 'Error: ' + sanitizeOutput(error.message); | |
| } | |
| }); | |
| // Get file list | |
| getFileListBtn.addEventListener('click', async () => { | |
| if (!authToken) { | |
| document.getElementById('fileListResult').textContent = 'Error: Not authenticated'; | |
| return; | |
| } | |
| try { | |
| const headers = { | |
| 'Authorization': 'Bearer ' + authToken, | |
| 'Content-Type': 'application/json' | |
| }; | |
| const response = await fetch('/files', { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify({ size: 10 }), | |
| credentials: 'same-origin' // Prevent CSRF | |
| }); | |
| if (!response.ok) { | |
| throw new Error(\`Server responded with status: \${response.status}\`); | |
| } | |
| const data = await response.json(); | |
| document.getElementById('fileListResult').textContent = JSON.stringify(data, null, 2); | |
| } catch (error) { | |
| document.getElementById('fileListResult').textContent = 'Error: ' + sanitizeOutput(error.message); | |
| } | |
| }); | |
| // Offline download | |
| offlineDownloadBtn.addEventListener('click', async () => { | |
| const fileUrl = fileUrlInput.value.trim(); | |
| // URL validation | |
| if (!isValidUrl(fileUrl)) { | |
| urlErrorDiv.textContent = 'Please enter a valid URL starting with http:// or https://'; | |
| return; | |
| } | |
| if (!authToken) { | |
| document.getElementById('offlineResult').textContent = 'Error: Not authenticated'; | |
| return; | |
| } | |
| try { | |
| const headers = { | |
| 'Authorization': 'Bearer ' + authToken, | |
| 'Content-Type': 'application/json' | |
| }; | |
| const response = await fetch('/offline', { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify({ file_url: fileUrl }), | |
| credentials: 'same-origin' // Prevent CSRF | |
| }); | |
| if (!response.ok) { | |
| throw new Error(\`Server responded with status: \${response.status}\`); | |
| } | |
| const data = await response.json(); | |
| document.getElementById('offlineResult').textContent = JSON.stringify(data, null, 2); | |
| // Clear input field after successful submission | |
| fileUrlInput.value = ''; | |
| } catch (error) { | |
| document.getElementById('offlineResult').textContent = 'Error: ' + sanitizeOutput(error.message); | |
| } | |
| }); | |
| // Initialize UI | |
| updateUIAuthState(); | |
| </script> | |
| </body> | |
| </html> | |
| `; | |
| ctx.response.headers.set("Content-Type", "text/html"); | |
| ctx.response.body = html; | |
| } catch (error) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: "Template error" }; | |
| } | |
| }); | |
| // Helper function to ensure client is initialized | |
| function ensureClient(ctx: Context): boolean { | |
| if (!THUNDERX_CLIENT) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: "API client not initialized" }; | |
| return false; | |
| } | |
| return true; | |
| } | |
| // API routes | |
| // File list endpoint | |
| // apiRouter.post("/files", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // const body = await ctx.request.body.json() as FileRequest; | |
| // const size = body.size || 100; | |
| // const parent_id = body.parent_id || undefined; | |
| // const next_page_token = body.next_page_token || undefined; | |
| // const additional_filters = body.additional_filters || {}; | |
| // try { | |
| // const result = await THUNDERX_CLIENT!.fileList( | |
| // size, | |
| // parent_id, | |
| // next_page_token, | |
| // additional_filters | |
| // ); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| // // Get file info endpoint | |
| // apiRouter.get("/files/:file_id", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // const { file_id } = ctx.params; | |
| // try { | |
| // const result = await THUNDERX_CLIENT!.getDownloadUrl(file_id); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| // // Empty trash endpoint | |
| // apiRouter.post("/emptytrash", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // try { | |
| // const result = await THUNDERX_CLIENT!.emptytrash(); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| // // Offline task list endpoint | |
| // apiRouter.get("/offline", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // const url = new URL(ctx.request.url); | |
| // const size = parseInt(url.searchParams.get("size") || "10000"); | |
| // const next_page_token = url.searchParams.get("next_page_token") || undefined; | |
| // try { | |
| // const result = await THUNDERX_CLIENT!.offlineList( | |
| // size, | |
| // next_page_token | |
| // ); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| // // Add offline task endpoint | |
| // apiRouter.post("/offline", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // // const body = await ctx.request.body({ type: "json" }).value as OfflineRequest; | |
| // const body = await ctx.request.body.json() as FileRequest; | |
| // try { | |
| // const result = await THUNDERX_CLIENT!.offlineDownload( | |
| // body.file_url, | |
| // body.parent_id, | |
| // body.name | |
| // ); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| // // User info endpoint | |
| // apiRouter.get("/userinfo", (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // ctx.response.body = THUNDERX_CLIENT!.getUserInfo(); | |
| // }); | |
| // // Quota info endpoint | |
| // apiRouter.get("/quota", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // try { | |
| // const result = await THUNDERX_CLIENT!.getQuotaInfo(); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| // 添加一个包装函数来处理验证码错误并自动重试 | |
| async function withCaptchaRetry(captchaPath, apiCall) { | |
| // 首先尝试刷新验证码 | |
| try { | |
| await THUNDERX_CLIENT.captchaInit(captchaPath); | |
| console.log(`Captcha refreshed for ${captchaPath}`); | |
| } catch (captchaError) { | |
| console.error(`Failed to refresh captcha for ${captchaPath}:`, captchaError); | |
| } | |
| try { | |
| // 尝试执行API调用 | |
| return await apiCall(); | |
| } catch (error) { | |
| // 检查是否是验证码错误 | |
| if (error.message && ( | |
| error.message.includes("Verification code is invalid") || | |
| error.message.includes("invalid captcha_sign"))) { | |
| console.log(`Detected invalid verification code, retrying ${captchaPath}...`); | |
| // 再次刷新验证码 | |
| await THUNDERX_CLIENT.captchaInit(captchaPath); | |
| // 重试API调用 | |
| return await apiCall(); | |
| } | |
| // 其他类型的错误则继续抛出 | |
| throw error; | |
| } | |
| } | |
| apiRouter.use(verifyToken); | |
| // 文件列表路由 | |
| apiRouter.post("/files", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| const body = await ctx.request.body.json(); | |
| const size = body.size || 100; | |
| const parent_id = body.parent_id || undefined; | |
| const next_page_token = body.next_page_token || undefined; | |
| const additional_filters = body.additional_filters || {}; | |
| const result = await withCaptchaRetry( | |
| "GET:/drive/v1/files", | |
| () => THUNDERX_CLIENT.fileList( | |
| size, | |
| parent_id, | |
| next_page_token, | |
| additional_filters | |
| ) | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 文件详情路由 | |
| apiRouter.get("/files/:file_id", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| const { file_id } = ctx.params; | |
| try { | |
| const result = await withCaptchaRetry( | |
| `GET:/drive/v1/files/${file_id}`, | |
| () => THUNDERX_CLIENT.getDownloadUrl(file_id) | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 清空回收站路由 | |
| apiRouter.post("/emptytrash", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| const result = await withCaptchaRetry( | |
| "PATCH:/drive/v1/files/trash:empty", | |
| () => THUNDERX_CLIENT.emptytrash() | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 离线任务列表路由 | |
| apiRouter.get("/offline", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| const url = new URL(ctx.request.url); | |
| const size = parseInt(url.searchParams.get("size") || "10000"); | |
| const next_page_token = url.searchParams.get("next_page_token") || undefined; | |
| const result = await withCaptchaRetry( | |
| "GET:/drive/v1/tasks", | |
| () => THUNDERX_CLIENT.offlineList( | |
| size, | |
| next_page_token | |
| ) | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 添加离线任务路由 | |
| apiRouter.post("/offline", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| const body = await ctx.request.body.json(); | |
| const result = await withCaptchaRetry( | |
| "GET:/drive/v1/files", | |
| () => THUNDERX_CLIENT.offlineDownload( | |
| body.file_url, | |
| body.parent_id, | |
| body.name | |
| ) | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 用户信息路由 (不需要验证码刷新) | |
| apiRouter.get("/userinfo", (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| // 用户信息不需要验证码刷新,因为它只是返回客户端中已有的信息 | |
| ctx.response.body = THUNDERX_CLIENT.getUserInfo(); | |
| }); | |
| // 配额信息路由 | |
| apiRouter.get("/quota", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| const result = await withCaptchaRetry( | |
| "GET:/drive/v1/about", | |
| () => THUNDERX_CLIENT.getQuotaInfo() | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 添加移动到回收站接口 | |
| // delete_to_trash | |
| // apiRouter.post("/delete_to_trash", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // try { | |
| // const body = await ctx.request.body.json(); | |
| // const ids = body.ids || []; | |
| // const result = await withCaptchaRetry( | |
| // "GET:/drive/v1/files:batchTrash", | |
| // () => THUNDERX_CLIENT.deleteToTrash(ids) | |
| // ); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| apiRouter.post("/delete_to_trash", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| // 直接将请求体解析为JSON数组 | |
| const ids = await ctx.request.body.json(); | |
| const result = await withCaptchaRetry( | |
| "GET:/drive/v1/files:batchTrash", | |
| () => THUNDERX_CLIENT.deleteToTrash(ids) | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 添加彻底删除接口 | |
| // apiRouter.post("/delete_forever", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // try { | |
| // const body = await ctx.request.body.json(); | |
| // const ids = body.ids || []; | |
| // const result = await withCaptchaRetry( | |
| // "GET:/drive/v1/files:batchDelete", | |
| // () => THUNDERX_CLIENT.deleteToTrash(ids) | |
| // ); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| apiRouter.post("/delete_forever", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| // 直接将请求体解析为JSON数组 | |
| const ids = await ctx.request.body.json(); | |
| const result = await withCaptchaRetry( | |
| "GET:/drive/v1/files:batchTrash", | |
| () => THUNDERX_CLIENT.deleteToTrash(ids) | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // // 批量分享文件 | |
| // apiRouter.post("/file_batch_share", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // try { | |
| // // 解析请求体 | |
| // const requestBody = await ctx.request.body.json(); | |
| // const fileId = requestBody.file_id; // 单个文件ID | |
| // const duration = requestBody.duration || -1; // 过期时间(秒),默认永久 | |
| // const no_password = requestBody.no_password !== undefined ? requestBody.no_password : true; // 是否不需要密码 | |
| // // 构造文件ID数组(即使只有一个ID也用数组) | |
| // const ids = Array.isArray(fileId) ? fileId : [fileId]; | |
| // // 计算天数(如果提供的是秒数) | |
| // let expiration_days = -1; | |
| // if (duration > 0) { | |
| // expiration_days = Math.ceil(duration / (24 * 60 * 60)); | |
| // } | |
| // // 调用迅雷分享API | |
| // const result = await withCaptchaRetry( | |
| // "POST:/drive/v1/files:batchShare", | |
| // () => THUNDERX_CLIENT.fileBatchShare(ids, !no_password, expiration_days) | |
| // ); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| apiRouter.post("/file_batch_share", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| // 从请求体获取文件ID数组 | |
| const ids = await ctx.request.body.json(); | |
| // 从查询参数获取其他选项 | |
| const url = new URL(ctx.request.url); | |
| const needPassword = url.searchParams.get("need_password") === "true"; | |
| const expirationDays = parseInt(url.searchParams.get("expiration_days") || "-1"); | |
| // console.log("分享请求参数:", { | |
| // ids, | |
| // needPassword, | |
| // expirationDays | |
| // }); | |
| // 调用迅雷分享API | |
| const result = await withCaptchaRetry( | |
| "POST:/drive/v1/share", | |
| () => THUNDERX_CLIENT.fileBatchShare(ids, needPassword, expirationDays) | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| console.error("分享失败:", error); | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 转存分享文件 | |
| // 转存分享文件 - 修复后的实现 | |
| // apiRouter.post("/restore", async (ctx) => { | |
| // if (!ensureClient(ctx)) return; | |
| // try { | |
| // // 解析请求体 | |
| // const requestBody = await ctx.request.body.json(); | |
| // // 提取必要参数 | |
| // const shareId = requestBody.share_id; | |
| // const passCodeToken = requestBody.pass_code_token || null; | |
| // const fileIds = requestBody.file_ids || null; | |
| // console.log("转存请求参数:", { | |
| // shareId, | |
| // passCodeToken, | |
| // fileIds: fileIds ? `${Array.isArray(fileIds) ? fileIds.length : 1}个文件` : "无" | |
| // }); | |
| // // 验证必要参数 | |
| // if (!shareId) { | |
| // ctx.response.status = Status.BadRequest; | |
| // ctx.response.body = { error: "share_id is required" }; | |
| // return; | |
| // } | |
| // if (!fileIds || (Array.isArray(fileIds) && fileIds.length === 0)) { | |
| // ctx.response.status = Status.BadRequest; | |
| // ctx.response.body = { error: "file_ids is required and cannot be empty" }; | |
| // return; | |
| // } | |
| // // 确保fileIds是数组格式 | |
| // const normalizedFileIds = Array.isArray(fileIds) ? fileIds : [fileIds]; | |
| // // 调用PikPak API - 注意参数顺序和类型 | |
| // const result = await withCaptchaRetry( | |
| // "GET:/drive/v1/share/restore", | |
| // () => THUNDERX_CLIENT.restore(shareId, passCodeToken, normalizedFileIds) | |
| // ); | |
| // console.log("转存结果:", result); | |
| // ctx.response.body = result; | |
| // } catch (error) { | |
| // console.error("转存分享文件失败:", error); | |
| // ctx.response.status = Status.InternalServerError; | |
| // ctx.response.body = { error: error.message }; | |
| // } | |
| // }); | |
| // 转存分享文件 - 深度调试版 | |
| apiRouter.post("/restore", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| // 记录原始请求 | |
| const originalBody = await ctx.request.body.text(); | |
| console.log("原始请求体:", originalBody); | |
| // 解析请求体 | |
| const requestBody = JSON.parse(originalBody); | |
| console.log("解析后请求体:", requestBody); | |
| // 提取必要参数 | |
| const shareId = requestBody.share_id; | |
| const passCodeToken = requestBody.pass_code_token || null; | |
| let fileIds = requestBody.file_ids || null; | |
| // 确保fileIds总是一个数组 | |
| if (fileIds && !Array.isArray(fileIds)) { | |
| fileIds = [fileIds]; | |
| } | |
| console.log("转存参数提取:", { | |
| shareId, | |
| passCodeToken, | |
| fileIds: fileIds ? JSON.stringify(fileIds) : "无" | |
| }); | |
| // 验证必要参数 | |
| if (!shareId) { | |
| ctx.response.status = Status.BadRequest; | |
| ctx.response.body = { error: "share_id is required" }; | |
| return; | |
| } | |
| if (!fileIds || fileIds.length === 0) { | |
| ctx.response.status = Status.BadRequest; | |
| ctx.response.body = { error: "file_ids is required and cannot be empty" }; | |
| return; | |
| } | |
| // 构造与Python完全相同的请求体 | |
| const pythonStyleData = { | |
| share_id: shareId, | |
| pass_code_token: passCodeToken, | |
| file_ids: fileIds | |
| }; | |
| console.log("Python风格请求体:", JSON.stringify(pythonStyleData)); | |
| // 直接使用重构的请求数据调用API方法 | |
| const result = await withCaptchaRetry( | |
| "GET:/drive/v1/share/restore", | |
| () => THUNDERX_CLIENT.restore(shareId, passCodeToken, fileIds) | |
| ); | |
| console.log("转存API返回:", JSON.stringify(result)); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| console.error(`转存分享文件失败: ${error.message}`); | |
| // 如果错误消息中包含更多细节,也记录下来 | |
| if (error.response) { | |
| console.error("错误响应:", error.response); | |
| } | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 获取分享信息 - 对应Python版本的get_share_folder | |
| apiRouter.post("/get_share_folder", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| // 关键修改:从请求体获取参数,而不是查询参数 | |
| const requestBody = await ctx.request.body.json(); | |
| const shareId = requestBody.share_id; | |
| const passCodeToken = requestBody.pass_code_token || null; | |
| const parentId = requestBody.parent_id || null; | |
| // 验证必要参数 | |
| if (!shareId) { | |
| ctx.response.status = Status.BadRequest; | |
| ctx.response.body = { error: "share_id is required" }; | |
| return; | |
| } | |
| // 调用PikPak API | |
| const result = await withCaptchaRetry( | |
| "GET:/drive/v1/share/detail", | |
| () => THUNDERX_CLIENT.getShareFolder(shareId, passCodeToken, parentId) | |
| ); | |
| ctx.response.body = result; | |
| } catch (error) { | |
| console.error("获取分享信息失败:", error); | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 验证分享密码 - 获取pass_code_token | |
| // 验证分享密码 - 获取pass_code_token | |
| apiRouter.post("/verify_share_password", async (ctx) => { | |
| if (!ensureClient(ctx)) return; | |
| try { | |
| const requestBody = await ctx.request.body.json(); | |
| const shareId = requestBody.share_id; | |
| const passCode = requestBody.pass_code; | |
| if (!shareId || !passCode) { | |
| ctx.response.status = Status.BadRequest; | |
| ctx.response.body = { error: "share_id and pass_code are required" }; | |
| return; | |
| } | |
| // 构造分享链接 - 使用迅雷网盘的格式 | |
| const shareLink = `https://pan.xunleix.com/s/${shareId}`; | |
| // 调用验证密码的API | |
| const result = await withCaptchaRetry( | |
| "GET:/drive/v1/share", | |
| () => THUNDERX_CLIENT.getShareInfo(shareLink, passCode) | |
| ); | |
| // 如果成功,返回pass_code_token | |
| if (result && result.pass_code_token) { | |
| ctx.response.body = { | |
| pass_code_token: result.pass_code_token, | |
| success: true | |
| }; | |
| } else { | |
| throw new Error("Failed to get pass_code_token"); | |
| } | |
| } catch (error) { | |
| console.error("验证分享密码失败:", error); | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: error.message }; | |
| } | |
| }); | |
| // 添加健康检查路由 | |
| apiRouter.get("/health", (ctx) => { | |
| ctx.response.body = { | |
| status: "ok", | |
| client_initialized: !!THUNDERX_CLIENT, | |
| last_token_refresh: lastRefreshTime ? new Date(lastRefreshTime).toISOString() : "never" | |
| }; | |
| }); | |
| // Add routes to application | |
| app.use(frontRouter.routes()); | |
| app.use(frontRouter.allowedMethods()); | |
| // Apply verification middleware to API routes | |
| app.use(apiRouter.routes()); | |
| app.use(apiRouter.allowedMethods()); | |
| // Error handler | |
| app.use(async (ctx, next) => { | |
| try { | |
| await next(); | |
| } catch (err) { | |
| ctx.response.status = Status.InternalServerError; | |
| ctx.response.body = { error: err.message }; | |
| console.error(err); | |
| } | |
| }); | |
| // Start the server if this is the main module | |
| if (import.meta.main) { | |
| // const port = parseInt(Deno.env.get("PORT") || "8000"); | |
| const port = parseInt(Deno.env.get("PORT") || "7860"); | |
| console.log(`Starting server on port ${port}...`); | |
| app.addEventListener("listen", ({ hostname, port, secure }) => { | |
| console.log( | |
| `Server listening on: ${secure ? "https://" : "http://"}${ | |
| hostname ?? "localhost" | |
| }:${port}` | |
| ); | |
| }); | |
| await app.listen({ port }); | |
| } | |