tx / thunderapi /thunderapi.ts
stnh70's picture
Update thunderapi/thunderapi.ts
ddaec3c verified
// 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 });
}