238 lines
6.9 KiB
TypeScript
238 lines
6.9 KiB
TypeScript
// Native fetch available in Node 20+
|
|
import * as crypto from 'crypto';
|
|
|
|
/**
|
|
* HTTP 封装层 — 统一处理夸克 API 的请求签名、headers、query params。
|
|
* 所有模块共用此单例/函数集,不持有状态。
|
|
*/
|
|
|
|
export interface QuarkConfig {
|
|
cookie: string;
|
|
nickname?: string;
|
|
}
|
|
|
|
// ==================== Headers & Params ====================
|
|
|
|
const BASE_URL = 'https://drive-pc.quark.cn';
|
|
|
|
export function getHeaders(cookie: string): Record<string, string> {
|
|
return {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
'Cookie': cookie,
|
|
'Accept': 'application/json, text/plain, */*',
|
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
'Referer': 'https://pan.quark.cn/',
|
|
'Origin': 'https://pan.quark.cn',
|
|
};
|
|
}
|
|
|
|
export function getCommonParams(): Record<string, string> {
|
|
return { pr: 'ucpro', fr: 'pc' };
|
|
}
|
|
|
|
/** Generate query string with common params + random timing to mimic browser */
|
|
export function makeQuery(extra: Record<string, string> = {}): string {
|
|
const __dt = Math.floor(Math.random() * 240000 + 60000);
|
|
const __t = Date.now() / 1000;
|
|
return new URLSearchParams({
|
|
...getCommonParams(),
|
|
uc_param_str: '',
|
|
app: 'clouddrive',
|
|
__dt: String(__dt),
|
|
__t: String(__t),
|
|
...extra,
|
|
}).toString();
|
|
}
|
|
|
|
/** Random delay to mimic human behavior (500-2000ms) */
|
|
export async function humanDelay(): Promise<void> {
|
|
const ms = Math.floor(Math.random() * 1500) + 500;
|
|
await new Promise(r => setTimeout(r, ms));
|
|
}
|
|
|
|
/** Generate a random password for share links */
|
|
export function randomSharePwd(): string {
|
|
return Math.floor(1000 + Math.random() * 9000).toString();
|
|
}
|
|
|
|
/**
|
|
* Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
|
|
*/
|
|
export function getMparam(cookie: string): { kps?: string; sign?: string; vcode?: string } {
|
|
// Match both __kps and kps (with or without __ prefix)
|
|
const kpsMatch = cookie.match(/__?kps=([a-zA-Z0-9%+/=]+)/);
|
|
const signMatch = cookie.match(/__?sign=([a-zA-Z0-9%+/=]+)/);
|
|
const vcodeMatch = cookie.match(/__?vcode=([a-zA-Z0-9%+/=]+)/);
|
|
if (kpsMatch && signMatch && vcodeMatch) {
|
|
return {
|
|
kps: kpsMatch[1],
|
|
sign: signMatch[1].replace(/%25/g, '%'),
|
|
vcode: vcodeMatch[1],
|
|
};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// ==================== Shared fetch helpers ====================
|
|
|
|
/**
|
|
* Raw fetch wrapper with JSON parse + status check.
|
|
* Returns parsed JSON body on 2xx, null on network error.
|
|
*/
|
|
export async function apiFetch<T = any>(
|
|
path: string,
|
|
options: {
|
|
method?: string;
|
|
query?: Record<string, string>;
|
|
body?: any;
|
|
cookie: string;
|
|
timeout?: number;
|
|
},
|
|
): Promise<T | null> {
|
|
const { method = 'GET', query, body, cookie, timeout = 10000 } = options;
|
|
let url = `${BASE_URL}${path}`;
|
|
if (query) url += `?${new URLSearchParams(query).toString()}`;
|
|
try {
|
|
const resp = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
...getHeaders(cookie),
|
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
signal: AbortSignal.timeout(timeout),
|
|
});
|
|
if (!resp.ok) return null;
|
|
return (await resp.json()) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ==================== File listing (shared across modules) ====================
|
|
|
|
export interface QuarkFile {
|
|
fid: string;
|
|
file_name: string;
|
|
share_fid_token?: string;
|
|
dir: boolean;
|
|
size?: number;
|
|
}
|
|
|
|
/**
|
|
* List files in a directory by FID.
|
|
*/
|
|
export async function listDir(cookie: string, pdirFid: string, page = 1, pageSize = 50): Promise<QuarkFile[]> {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
...getCommonParams(),
|
|
uc_param_str: '',
|
|
pdir_fid: pdirFid,
|
|
_page: String(page),
|
|
_size: String(pageSize),
|
|
_fetch_total: '1',
|
|
_fetch_sub_dirs: '0',
|
|
_sort: 'file_type:asc,updated_at:desc',
|
|
fetch_all_file: '1',
|
|
fetch_risk_file_name: '1',
|
|
});
|
|
const resp = await fetch(
|
|
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
|
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
|
);
|
|
if (!resp.ok) return [];
|
|
const data = await resp.json() as any;
|
|
if (data.status !== 200) return [];
|
|
return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
|
|
fid: f.fid,
|
|
file_name: f.file_name,
|
|
share_fid_token: '',
|
|
dir: f.dir || false,
|
|
size: f.size || 0,
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List root directory (pdir_fid=0) — returns all top-level dirs/files.
|
|
*/
|
|
export async function listRootDir(cookie: string): Promise<QuarkFile[]> {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
pr: 'ucpro', fr: 'pc',
|
|
pdir_fid: '0',
|
|
_page: '1', _size: '200',
|
|
_fetch_total: '1', _fetch_sub_dirs: '0',
|
|
_sort: 'file_type:asc,updated_at:desc',
|
|
fetch_all_file: '1',
|
|
fetch_risk_file_name: '1',
|
|
});
|
|
const resp = await fetch(
|
|
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
|
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
|
);
|
|
if (!resp.ok) return [];
|
|
const data = await resp.json() as any;
|
|
if (data.status !== 200 || !data.data?.list) return [];
|
|
return (data.data.list || []).map((f: any) => ({
|
|
fid: f.fid,
|
|
file_name: f.file_name,
|
|
dir: f.dir || false,
|
|
size: f.size || 0,
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all files in a directory, handling pagination.
|
|
* Fetches all pages until no more results.
|
|
*/
|
|
export async function listDirAllPages(cookie: string, pdirFid: string): Promise<QuarkFile[]> {
|
|
const allFiles: QuarkFile[] = [];
|
|
let page = 1;
|
|
const pageSize = 100;
|
|
let total = -1;
|
|
while (total === -1 || (page - 1) * pageSize < total) {
|
|
const files = await listDir(cookie, pdirFid, page, pageSize);
|
|
if (!files.length) break;
|
|
allFiles.push(...files);
|
|
if (total === -1) {
|
|
total = files.length;
|
|
}
|
|
page++;
|
|
}
|
|
return allFiles;
|
|
}
|
|
|
|
// ==================== Format utilities ====================
|
|
|
|
export function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */
|
|
export function dailyFolderName(): string {
|
|
const d = new Date();
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
/** Generate a random folder name for saving (fallback) */
|
|
export function randomFolderName(): string {
|
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
let name = '';
|
|
for (let i = 0; i < 12; i++) {
|
|
name += chars[Math.floor(Math.random() * chars.length)];
|
|
}
|
|
return name;
|
|
}
|