|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"; |
|
|
|
|
|
const { createHash } = await import('node:crypto'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getTimestamp(): string { |
|
|
|
|
|
return Math.floor(Date.now()).toString(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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", |
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function captchaSign(deviceId: string, timestamp: string): string { |
|
|
let sign = CLIENT_ID + CLIENT_VERSION + PACKAG_ENAME + deviceId + timestamp; |
|
|
|
|
|
for (const salt of SALTS) { |
|
|
|
|
|
const md5Hash = createHash('md5'); |
|
|
md5Hash.update(sign + salt); |
|
|
sign = md5Hash.digest('hex'); |
|
|
} |
|
|
|
|
|
return `1.${sign}`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function generateDeviceSign(deviceId: string, packageName: string): string { |
|
|
const signatureBase = `${deviceId}${packageName}1appkey`; |
|
|
|
|
|
|
|
|
const encoder = new TextEncoder(); |
|
|
const sha1Result = createHash("sha1").update(encoder.encode(signatureBase)).toString(); |
|
|
|
|
|
|
|
|
const md5Result = createHash("md5").update(encoder.encode(sha1Result)).toString(); |
|
|
|
|
|
return `div101.${deviceId}${md5Result}`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(" "); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function b64encode(str: string): string { |
|
|
return btoa(str); |
|
|
} |
|
|
|
|
|
export function b64decode(str: string): string { |
|
|
return atob(str); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class PikPakApi { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static readonly PIKPAK_API_HOST = "api-pan.xunleix.com"; |
|
|
static readonly PIKPAK_USER_HOST = "xluser-ssl.xunleix.com"; |
|
|
|
|
|
|
|
|
username?: string; |
|
|
password?: string; |
|
|
encoded_token?: string; |
|
|
access_token?: string; |
|
|
refresh_token?: string; |
|
|
user_id?: string; |
|
|
|
|
|
|
|
|
max_retries: number; |
|
|
initial_backoff: number; |
|
|
device_id: string; |
|
|
captcha_token?: string; |
|
|
data_response?: any; |
|
|
user_agent?: string; |
|
|
|
|
|
|
|
|
token_refresh_callback?: TokenRefreshCallbackFn; |
|
|
token_refresh_callback_kwargs: Record<string, any>; |
|
|
|
|
|
|
|
|
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 || {}; |
|
|
|
|
|
|
|
|
this.device_id = options.device_id || this.generateDeviceId(); |
|
|
|
|
|
if (this.encoded_token) { |
|
|
this.decodeToken(); |
|
|
} else if (this.username && this.password) { |
|
|
|
|
|
} else { |
|
|
throw new PikpakException("username and password or encoded_token is required"); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private generateDeviceId(): string { |
|
|
const idBase = `${this.username || ""}${this.password || ""}`; |
|
|
return createHash("md5").update(new TextEncoder().encode(idBase)).toString(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
toDict(): Record<string, any> { |
|
|
const data: Record<string, any> = {}; |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
buildCustomUserAgent(): string { |
|
|
this.user_agent = buildCustomUserAgent( |
|
|
this.device_id, |
|
|
this.user_id || "" |
|
|
); |
|
|
return this.user_agent; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
throw new PikpakException(`Max retries reached. Last error: ${lastError?.message || "Unknown error"}`); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, |
|
|
}; |
|
|
|
|
|
|
|
|
if (data && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) { |
|
|
options.body = JSON.stringify(data); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async _handleResponse(response: Response): Promise<any> { |
|
|
|
|
|
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) { |
|
|
const now = Date.now(); |
|
|
|
|
|
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"); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async _requestGet(url: string, params?: Record<string, any>): Promise<any> { |
|
|
return await this._makeRequest("GET", url, undefined, params); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async _requestPost(url: string, data?: Record<string, any>, headers?: Record<string, string>): Promise<any> { |
|
|
return await this._makeRequest("POST", url, data, undefined, headers); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async _requestPatch(url: string, data?: Record<string, any>): Promise<any> { |
|
|
return await this._makeRequest("PATCH", url, data); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async _requestDelete(url: string, params?: Record<string, any>, data?: Record<string, any>): Promise<any> { |
|
|
return await this._makeRequest("DELETE", url, data, params); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
encodeToken(): void { |
|
|
const tokenData = { |
|
|
access_token: this.access_token, |
|
|
refresh_token: this.refresh_token |
|
|
}; |
|
|
|
|
|
this.encoded_token = b64encode(JSON.stringify(tokenData)); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"); |
|
|
} |
|
|
|
|
|
|
|
|
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"); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
const headers = { |
|
|
"Content-Type": "application/x-www-form-urlencoded", |
|
|
}; |
|
|
|
|
|
|
|
|
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(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
const multiLevelPaths = []; |
|
|
for (let i = 0; i < paths.length; i++) { |
|
|
multiLevelPaths.push('/' + paths.slice(0, i + 1).join('/')); |
|
|
} |
|
|
|
|
|
|
|
|
const pathIds: FileInfo[] = []; |
|
|
for (const p of multiLevelPaths) { |
|
|
if (this._path_id_cache[p]) { |
|
|
pathIds.push(this._path_id_cache[p]); |
|
|
} else { |
|
|
break; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
let count = pathIds.length; |
|
|
let parentId = count > 0 ? pathIds[count - 1].id : null; |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async fileBatchShare( |
|
|
ids: string[], |
|
|
need_password: boolean = false, |
|
|
expiration_days: number = -1 |
|
|
): Promise<any> { |
|
|
const data = { |
|
|
file_ids: ids, |
|
|
share_to: "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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getQuotaInfo(): Promise<any> { |
|
|
const result = await this._requestGet( |
|
|
`https://${PikPakApi.PIKPAK_API_HOST}/drive/v1/about` |
|
|
); |
|
|
|
|
|
return result; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"); |
|
|
|
|
|
|
|
|
let THUNDERX_CLIENT: PikPakApi | null = null; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function logToken(client: PikPakApi, extraData: any): Promise<void> { |
|
|
console.log(`Token: ${client.encoded_token}, Extra Data: ${JSON.stringify(extraData)}`); |
|
|
} |
|
|
|
|
|
|
|
|
const app = new Application(); |
|
|
|
|
|
|
|
|
app.use(oakCors({ origin: "*", optionsSuccessStatus: 200 })); |
|
|
|
|
|
|
|
|
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(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
const apiRouter = new Router(); |
|
|
const frontRouter = new Router(); |
|
|
|
|
|
|
|
|
frontRouter.get("/", async (ctx) => { |
|
|
try { |
|
|
|
|
|
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" }; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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 }; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apiRouter.post("/delete_to_trash", async (ctx) => { |
|
|
if (!ensureClient(ctx)) return; |
|
|
|
|
|
try { |
|
|
|
|
|
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 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 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"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 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; |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
const pythonStyleData = { |
|
|
share_id: shareId, |
|
|
pass_code_token: passCodeToken, |
|
|
file_ids: fileIds |
|
|
}; |
|
|
|
|
|
console.log("Python风格请求体:", JSON.stringify(pythonStyleData)); |
|
|
|
|
|
|
|
|
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 }; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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 }; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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}`; |
|
|
|
|
|
|
|
|
const result = await withCaptchaRetry( |
|
|
"GET:/drive/v1/share", |
|
|
() => THUNDERX_CLIENT.getShareInfo(shareLink, passCode) |
|
|
); |
|
|
|
|
|
|
|
|
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" |
|
|
}; |
|
|
}); |
|
|
|
|
|
|
|
|
app.use(frontRouter.routes()); |
|
|
app.use(frontRouter.allowedMethods()); |
|
|
|
|
|
|
|
|
|
|
|
app.use(apiRouter.routes()); |
|
|
app.use(apiRouter.allowedMethods()); |
|
|
|
|
|
|
|
|
app.use(async (ctx, next) => { |
|
|
try { |
|
|
await next(); |
|
|
} catch (err) { |
|
|
ctx.response.status = Status.InternalServerError; |
|
|
ctx.response.body = { error: err.message }; |
|
|
console.error(err); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
if (import.meta.main) { |
|
|
|
|
|
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 }); |
|
|
} |
|
|
|