release: v0.4.0

This commit is contained in:
2026-05-18 05:11:57 +08:00
parent b758391861
commit da5bd01535
147 changed files with 8876 additions and 6941 deletions
+3 -2
View File
@@ -1,3 +1,4 @@
import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api';
import { getDb } from '../database/database';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
@@ -184,11 +185,11 @@ async function fetchQuarkNickname(cookie: string): Promise<string | null> {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch('https://pan.quark.cn/account/info', {
const response = await fetch(QUARK_PAN_HOST + QE.ACCOUNT_INFO, {
headers: {
'Cookie': cookie,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://pan.quark.cn/',
'Referer': QUARK_PAN_HOST + '/',
},
signal: AbortSignal.timeout(15000),
});
@@ -0,0 +1,401 @@
import { getDb } from '../database/database';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
export interface CloudConfig {
id: number;
cloud_type: string;
cookie?: string;
nickname?: string;
is_active: number;
storage_used?: string;
storage_total?: string;
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
last_checkin_at?: string;
checkin_message?: string;
consecutive_failures: number;
last_used_at?: string;
total_saves: number;
created_at: string;
updated_at: string;
verification_status?: string;
promotion_account?: string;
cloud_type_uid?: string;
cookie_uid?: string;
}
// ── Cookie UID Extraction ────────────────────────────────────────
function decryptCookie(encrypted: string): string {
if (!encrypted) return '';
if (!isEncrypted(encrypted)) return encrypted;
return decrypt(encrypted);
}
function extractCookieUid(cookie: string): string {
if (!cookie) return '';
let m = cookie.match(/__uid=([a-zA-Z0-9+/=_-]+)/);
if (m) return m[1];
m = cookie.match(/b-user-id=([a-zA-Z0-9-]+)/);
if (m) return m[1];
return '';
}
// ── Config CRUD ──────────────────────────────────────────────────
export function getCloudConfigs(): CloudConfig[] {
const db = getDb();
const rows = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs ORDER BY id ASC`
).all() as CloudConfig[];
rows.forEach(r => { if (r.cookie) r.cookie = decryptCookie(r.cookie); });
return rows;
}
export function getAvailableClouds(): CloudConfig[] {
const db = getDb();
const rows = db.prepare(
`SELECT id, cloud_type, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC`
).all() as CloudConfig[];
rows.forEach(r => { if (r.cookie) r.cookie = decryptCookie(r.cookie); });
return rows;
}
/** Returns the first active config matching the given cloud type. */
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
const db = getDb();
const cfg = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs WHERE cloud_type = ? AND is_active = 1
ORDER BY id ASC LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
if (cfg && cfg.cookie) cfg.cookie = decryptCookie(cfg.cookie);
return cfg;
}
export function getCloudConfigById(id: number): CloudConfig | undefined {
const db = getDb();
const cfg = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs WHERE id = ?`
).get(id) as CloudConfig | undefined;
if (cfg && cfg.cookie) cfg.cookie = decryptCookie(cfg.cookie);
return cfg;
}
/** Returns all active cloud configs (used by save flow for cloud type switching). */
export function getActiveCloudConfigs(): CloudConfig[] {
const db = getDb();
const rows = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE is_active = 1
ORDER BY cloud_type ASC, id ASC`
).all() as CloudConfig[];
rows.forEach(r => { if (r.cookie) r.cookie = decryptCookie(r.cookie); });
return rows;
}
export function saveCloudConfig(data: {
id?: number;
cloud_type: string;
cookie?: string;
nickname?: string;
cookie_uid?: string;
promotion_account?: string;
is_active?: number;
storage_used?: string;
storage_total?: string;
}): CloudConfig {
const db = getDb();
const cookieUidForUpdate = data.cookie ? extractCookieUid(data.cookie) : null;
const encryptedCookie = data.cookie ? encrypt(data.cookie) : null;
if (data.id) {
db.prepare(
`UPDATE cloud_configs SET
cloud_type = COALESCE(?, cloud_type),
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
cookie_uid = COALESCE(?, cookie_uid),
promotion_account = COALESCE(?, promotion_account),
is_active = COALESCE(?, is_active),
storage_used = COALESCE(?, storage_used),
storage_total = COALESCE(?, storage_total),
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.id);
} else {
const existing = db.prepare(
'SELECT id, nickname FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
).get(data.cloud_type) as any;
if (existing) {
db.prepare(
`UPDATE cloud_configs SET
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
cookie_uid = COALESCE(?, cookie_uid),
promotion_account = COALESCE(?, promotion_account),
is_active = COALESCE(?, is_active),
storage_used = COALESCE(?, storage_used),
storage_total = COALESCE(?, storage_total),
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.id);
} else {
db.prepare(
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, cookie_uid, promotion_account, is_active, storage_used, storage_total, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)'
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null);
}
}
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE id = ?`
).get(savedId) as CloudConfig;
}
export function deleteCloudConfig(id: number): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id);
return result.changes > 0;
}
// ── Cookie Validation ────────────────────────────────────────────
async function fetchQuarkNickname(cookie: string): Promise<string | null> {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch('https://pan.quark.cn/account/info', {
headers: {
'Cookie': cookie,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://pan.quark.cn/',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok) return null;
const data = await response.json() as any;
if (data?.data?.nickname) return data.data.nickname;
} catch {
if (attempt < MAX_RETRIES) {
await new Promise(r => setTimeout(r, 1500));
continue;
}
}
}
return null;
}
export async function testCloudConnection(id: number): Promise<{
success: boolean;
message: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
}> {
const config = getCloudConfigById(id);
if (!config) {
return { success: false, message: 'Cloud config not found' };
}
if (!config.cookie) {
return { success: false, message: 'Cookie not configured' };
}
const cookie = decryptCookie(config.cookie);
try {
let valid = false;
let nickname = '';
let storageUsed = config.storage_used || '';
let storageTotal = config.storage_total || '';
if (config.cloud_type === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
const info = await driver.getUserInfo();
if (info) {
nickname = config.nickname || info.nickname || '百度网盘';
const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB';
storageUsed = fmt(info.usedBytes);
storageTotal = fmt(info.totalBytes);
}
}
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘';
const storage = await driver.getStorageInfoQuick();
storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
}
}
const db = getDb();
if (!valid) {
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), id);
return { success: false, message: '连接失败:Cookie 无效或已过期,或网络暂时异常' };
}
const cookieUid = extractCookieUid(cookie);
db.prepare(
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, cookie_uid = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
).run(nickname, storageTotal, storageUsed, cookieUid, localTimestamp(), id);
return {
success: true,
message: '连接成功',
nickname,
storage_used: storageUsed,
storage_total: storageTotal,
};
} catch (err: any) {
try {
const db = getDb();
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), id);
} catch {}
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
}
}
export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{
success: boolean;
message: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
}> {
try {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie, nickname: '' });
const valid = await driver.validate();
if (!valid) {
return { success: false, message: '连接失败:Cookie 无效或已过期' };
}
const nickname = (await fetchQuarkNickname(cookie)) || cloudType;
const storage = await driver.getStorageInfo();
return {
success: true,
message: '连接成功',
nickname,
storage_used: storage.used,
storage_total: storage.total,
};
} catch (err: any) {
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
}
}
// ── Unified Credential Validation ─────────────────────────────────
export interface CredentialValidationResult {
valid: boolean;
config?: CloudConfig;
errorCode?: string;
message: string;
}
/**
* Get and validate a credential for the given cloud type.
*
* This is the unified entry point for all save/transfer operations.
* It handles:
* 1. Finding an active config with < 5 consecutive failures (round-robin)
* 2. Validating cookie freshness via driver.validate()
* 3. Returning structured result with error codes
*
* Reference: search-ucmao get_and_validate_credential() pattern.
*/
export async function getAndValidateCredential(cloudType: string): Promise<CredentialValidationResult> {
const db = getDb();
const config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
if (!config) {
return {
valid: false,
errorCode: 'NO_AVAILABLE_DRIVE',
message: `Cloud type "${cloudType}" is not configured or no available drives`,
};
}
if (!config.cookie) {
return {
valid: false,
errorCode: 'COOKIE_MISSING',
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
};
}
const cookie = decryptCookie(config.cookie);
try {
let cookieValid = false;
if (cloudType === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
cookieValid = await driver.validate();
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
cookieValid = await driver.validate();
}
if (!cookieValid) {
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), config.id);
return {
valid: false,
errorCode: 'COOKIE_EXPIRED',
message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`,
};
}
config.cookie = cookie;
return {
valid: true,
config,
message: 'ok',
};
} catch (err: any) {
return {
valid: false,
errorCode: 'VALIDATION_ERROR',
message: `Credential validation failed: ${err.message}`,
};
}
}
@@ -0,0 +1,26 @@
// @ts-nocheck
// ===================================================================
// 阿里云盘 API 统一模块
// 所有阿里云盘 API 域名、端点集中管理
// 换 API 只需改这个文件
// ===================================================================
// ==================== 域名 ====================
export const ALIYUN_API_HOST = 'https://api.aliyundrive.com';
export const ALIYUN_WEB_HOST = 'https://www.aliyundrive.com';
// ==================== API 端点路径 ====================
export const EP = {
/** 匿名获取分享信息 (无需登录) */
SHARE_GET_BY_ANONYMOUS: '/v2/share_link/get_share_by_anonymous',
} as const;
// ==================== 请求头 ====================
export function getShareHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
'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',
'Referer': ALIYUN_WEB_HOST + '/',
'Accept-Language': 'zh-CN,zh;q=0.9',
};
}
+33 -38
View File
@@ -1,4 +1,7 @@
// Native fetch available in Node 20+
// @ts-nocheck
// 阿里云盘驱动 — 分享链接验证
// 依赖 aliyun-api.ts 统一端点模块 — URL 不再硬编码
import { ALIYUN_API_HOST, ALIYUN_WEB_HOST, EP as AE, getShareHeaders } from './aliyun-api';
export interface AliyunConfig {
cookie?: string;
@@ -7,7 +10,6 @@ export interface AliyunConfig {
export class AliyunDriver {
private config: AliyunConfig;
private baseUrl = 'https://api.aliyundrive.com';
constructor(config: AliyunConfig = {}) {
this.config = config;
@@ -38,13 +40,6 @@ export class AliyunDriver {
/**
* Validate a share link using Aliyun's public anonymous API.
* No cookie or token required — this endpoint is open.
*
* API:
* POST https://api.aliyundrive.com/v2/share_link/get_share_by_anonymous
* Body: { "share_id": "XXXYYY", "share_pwd": "" }
*
* Success: returns share_name, file_infos, creator info
* Failure: returns error code (ShareLinkExpired, ShareLinkCancelled, etc.)
*/
async validateShareLink(shareUrl: string): Promise<{
valid: boolean;
@@ -59,15 +54,10 @@ export class AliyunDriver {
try {
const response = await fetch(
`${this.baseUrl}/v2/share_link/get_share_by_anonymous`,
ALIYUN_API_HOST + AE.SHARE_GET_BY_ANONYMOUS,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'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',
'Referer': 'https://www.aliyundrive.com/',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
headers: getShareHeaders(),
body: JSON.stringify({
share_id: shareId,
share_pwd: '',
@@ -77,37 +67,42 @@ export class AliyunDriver {
);
if (!response.ok) {
return { valid: false, message: `HTTP ${response.status}: API 请求失败` };
return { valid: false, message: 'HTTP ' + response.status + ': API 请求失败' };
}
const data = await response.json() as any;
const data = await response.json();
// Check for error codes
if (data.code) {
switch (data.code) {
case 'ShareLinkExpired':
return { valid: false, message: '分享已失效(已过期)' };
case 'ShareLinkCancelled':
return { valid: false, message: '分享已被取消' };
case 'NotFound.ShareLink':
return { valid: false, message: '分享链接不存在' };
case 'ShareLinkPasswordIncorrect':
return { valid: true, message: '需要提取码(链接有效)' };
default:
return { valid: false, message: data.message || `未知错误 (${data.code})` };
}
const errorMessages: Record<string, string> = {
'ShareLinkExpired': '分享链接已过期',
'ShareLinkCancelled': '分享链接已取消',
'ShareLinkNotFound': '分享链接不存在',
'ForbiddenFileInTheDrive': '文件已被禁止访问',
'UserNotAuthenticated': '需要登录才能访问',
'InvalidResource.NotFound': '资源不存在',
};
return {
valid: false,
message: errorMessages[data.code] || data.message || 'API 返回错误: ' + data.code,
};
}
// Success — valid share link
const fileInfos = data.file_infos || [];
// 成功:提取分享信息
const fileCount = data.file_infos?.length || 0;
const shareName = data.share_name || data.display_name || '';
return {
valid: true,
message: `有效链接(${fileInfos.length} 个文件)`,
fileCount: fileInfos.length,
shareName: data.share_name || '',
message: '链接有效',
fileCount,
shareName,
};
} catch (err: any) {
return { valid: false, message: `网络错误: ${err.message || err}` };
} catch (err) {
return { valid: false, message: '网络错误: ' + (err instanceof Error ? err.message : String(err)) };
}
}
async getNickname(): Promise<string | null> {
return this.config.nickname || null;
}
}
@@ -0,0 +1,57 @@
// @ts-nocheck
// ===================================================================
// 百度网盘 API 统一模块
// 所有百度网盘 API 域名、端点、请求头集中管理
// 换 API 只需改这个文件
// ===================================================================
// ==================== 域名 ====================
// 修改百度网盘域名只需改下面几行
export const BAIDU_PAN_HOST = 'https://pan.baidu.com';
export const BAIDU_PASSPORT_HOST = 'https://passport.baidu.com';
export const BAIDU_WAPPASS_HOST = 'https://wappass.baidu.com';
// ==================== API 端点路径 ====================
// 所有百度网盘 API 路径集中在此,换版本只需改这里
export const EP = {
// ── 认证/用户 ──
GET_TEMPLATE_VARIABLE: '/api/gettemplatevariable', // GET 获取 bdstoken/token/uk/servertime
USER_INFO: '/api/userinfo', // GET 用户信息
QUOTA: '/api/quota', // GET 容量信息
// ── 文件操作 ──
FILE_LIST: '/api/list', // GET 列出文件 (dir, order, page, num)
FILE_CREATE: '/api/create', // POST 创建文件夹 (a=commit)
FILE_DELETE: '/api/filemanager', // POST 删除文件 (opera=delete)
// ── 分享 ──
SHARE_VERIFY: '/share/verify', // GET 验证分享链接/密码 (surl, bdstoken, t, channel, web, clienttype)
SHARE_TRANSFER: '/share/transfer', // POST 转存分享文件 (shareid, from, bdstoken)
SHARE_SET: '/share/set', // POST 创建分享链接 (bdstoken, channel, web, clienttype, app_id)
// ── NAS ──
NAS_UINFO: '/rest/2.0/xpan/nas', // GET NAS 用户信息 (method=uinfo)
// ── 浏览器页面 (非 API,供 Playwright 导航) ──
PAGE_LOGIN_QR: '/v2/', // QR 登录页 (?login&qrlogin&tpl=netdisk)
PAGE_DISK_HOME: '/disk/home', // 网盘首页
PAGE_QR_GEN: '/wp/', // 二维码生成 (?qrlogin&t=&error=0...)
} as const;
// ==================== 请求头 ====================
/** 构建标准百度网盘 API 请求头 */
export function buildHeaders(cookie: string): Record<string, string> {
const base: Record<string, string> = {
'Host': 'pan.baidu.com',
'Connection': 'keep-alive',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
'Referer': BAIDU_PAN_HOST,
};
if (cookie) {
base['Cookie'] = cookie;
}
return base;
}
+35 -50
View File
@@ -1,4 +1,6 @@
import { BAIDU_PAN_HOST, BAIDU_PASSPORT_HOST, BAIDU_WAPPASS_HOST, EP as BE, buildHeaders as bdHeaders } from './baidu-api';
// Baidu Netdisk Driver v4 — Cookie-based (Playwright QR login + HTTP API)
const APP_ID_WEB = "38824127"; // Web app ID from BaiduPanFilesTransfers
// Uses full browser Cookie string for all operations (no OAuth access_token needed).
// Share operations use internal web API (/share/verify, /share/transfer, parse HTML).
// Reference: https://github.com/hxz393/BaiduPanFilesTransfers
@@ -36,28 +38,11 @@ interface ShareDetail {
// ═══════════════════════════════════
// Constants
// ═══════════════════════════════════
const API_HOST = "https://pan.baidu.com";
import config from '../../config';
const CHROMIUM_PATH = config.chromiumPath;
const APP_ID_WEB = "38824127"; // Web app ID from BaiduPanFilesTransfers
// HTTP headers matching BaiduPanFilesTransfers
const WEB_HEADERS: Record<string, string> = {
'Host': 'pan.baidu.com',
'Connection': 'keep-alive',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
'Referer': 'https://pan.baidu.com',
};
function buildHeaders(cookie: string): Record<string, string> {
if (cookie) {
return { ...WEB_HEADERS, 'Cookie': cookie };
}
return { ...WEB_HEADERS };
}
// ═══════════════════════════════════
// Playwright singleton for QR login
@@ -156,9 +141,9 @@ export class BaiduDriver {
if (!cookie) return null;
try {
const url = `${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","token","uk","isdocuser","servertime"]`;
const url = `${BAIDU_PAN_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","token","uk","isdocuser","servertime"]`;
const res = await fetch(url, {
headers: buildHeaders(cookie),
headers: bdHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
@@ -213,7 +198,7 @@ export class BaiduDriver {
// Navigate directly to passport QR login page (the actual login page with QR code)
console.log('[BaiduQR] Navigating to passport QR login...');
await page.goto('https://passport.baidu.com/v2/?login&qrlogin&tpl=netdisk', { waitUntil: 'commit', timeout: 30000 });
await page.goto('BAIDU_PASSPORT_HOST + BE.PAGE_LOGIN_QR?login&qrlogin&tpl=netdisk', { waitUntil: 'commit', timeout: 30000 });
await page.waitForTimeout(4000);
// Check if we landed on the right page
@@ -248,7 +233,7 @@ export class BaiduDriver {
const sign = imgUrlObj.searchParams.get('sign') || '';
const logPage = imgUrlObj.searchParams.get('logPage') || '';
const t = Math.floor(Date.now() / 1000);
qrContent = `https://wappass.baidu.com/wp/?qrlogin&t=${t}&error=0&sign=${sign}&cmd=login&lp=pc&tpl=netdisk&adapter=3&logPage=${encodeURIComponent(logPage)}&qrloginfrom=pc`;
qrContent = `BAIDU_WAPPASS_HOST + BE.PAGE_QR_GEN?qrlogin&t=${t}&error=0&sign=${sign}&cmd=login&lp=pc&tpl=netdisk&adapter=3&logPage=${encodeURIComponent(logPage)}&qrloginfrom=pc`;
} catch {
qrContent = qrImgSrc; // fallback: raw image URL
}
@@ -333,7 +318,7 @@ export class BaiduDriver {
// Navigate to disk home to ensure cookies are fully set
try {
await page.goto('https://pan.baidu.com/disk/home', { waitUntil: 'commit', timeout: 15000 });
await page.goto('BAIDU_PAN_HOST + BE.PAGE_DISK_HOME', { waitUntil: 'commit', timeout: 15000 });
await page.waitForTimeout(2000);
} catch {}
@@ -350,8 +335,8 @@ export class BaiduDriver {
let bdstoken = '';
try {
const bdres = await fetch(
`${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","uk"]`,
{ headers: buildHeaders(cookieStr), signal: AbortSignal.timeout(10000) }
`${BAIDU_PAN_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","uk"]`,
{ headers: bdHeaders(cookieStr), signal: AbortSignal.timeout(10000) }
);
if (bdres.ok) {
const bddata = await bdres.json() as any;
@@ -366,8 +351,8 @@ export class BaiduDriver {
let storage_total = '';
if (bdstoken) {
try {
const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${bdstoken}`, {
headers: buildHeaders(cookieStr),
const qRes = await fetch(`${BAIDU_PAN_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${bdstoken}`, {
headers: bdHeaders(cookieStr),
signal: AbortSignal.timeout(10000),
});
if (qRes.ok) {
@@ -389,8 +374,8 @@ export class BaiduDriver {
// Get nickname from Baidu REST API (baidu_name field)
if (bdstoken) {
try {
const uRes = await fetch(`${API_HOST}/rest/2.0/xpan/nas?method=uinfo`, {
headers: buildHeaders(cookieStr),
const uRes = await fetch(`${BAIDU_PAN_HOST}/rest/2.0/xpan/nas?method=uinfo`, {
headers: bdHeaders(cookieStr),
signal: AbortSignal.timeout(10000),
});
if (uRes.ok) {
@@ -459,8 +444,8 @@ export class BaiduDriver {
let totalBytes = 0;
// Try to get user info from /api/userinfo
const uRes = await fetch(`${API_HOST}/api/userinfo?act=getuserinfo&bdstoken=${await this.getBdstoken()}`, {
headers: buildHeaders(cookie),
const uRes = await fetch(`${BAIDU_PAN_HOST}/api/userinfo?act=getuserinfo&bdstoken=${await this.getBdstoken()}`, {
headers: bdHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (uRes.ok) {
@@ -472,8 +457,8 @@ export class BaiduDriver {
// Get quota
try {
const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${await this.getBdstoken()}`, {
headers: buildHeaders(cookie),
const qRes = await fetch(`${BAIDU_PAN_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${await this.getBdstoken()}`, {
headers: bdHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (qRes.ok) {
@@ -514,9 +499,9 @@ export class BaiduDriver {
if (!bdstoken) return [];
try {
const url = `${API_HOST}/api/list?order=time&desc=1&showempty=0&web=1&page=1&num=1000&dir=/&bdstoken=${bdstoken}`;
const url = `${BAIDU_PAN_HOST}/api/list?order=time&desc=1&showempty=0&web=1&page=1&num=1000&dir=/&bdstoken=${bdstoken}`;
const res = await fetch(url, {
headers: buildHeaders(cookie),
headers: bdHeaders(cookie),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return [];
@@ -544,11 +529,11 @@ export class BaiduDriver {
if (!bdstoken) return false;
try {
const url = `${API_HOST}/api/create?a=commit&bdstoken=${bdstoken}`;
const url = `${BAIDU_PAN_HOST}/api/create?a=commit&bdstoken=${bdstoken}`;
const body = new URLSearchParams({ path, isdir: '1', block_list: '[]' });
const res = await fetch(url, {
method: 'POST',
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
@@ -590,9 +575,9 @@ export class BaiduDriver {
try {
const filelist = JSON.stringify(fsIds);
const body = new URLSearchParams({ async: '2', filelist });
const res = await fetch(`${API_HOST}/api/filemanager?opera=delete&bdstoken=${bdstoken}`, {
const res = await fetch(`${BAIDU_PAN_HOST}/api/filemanager?opera=delete&bdstoken=${bdstoken}`, {
method: 'POST',
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(30000),
});
@@ -651,12 +636,12 @@ export class BaiduDriver {
if (pwd) {
console.log(`[Baidu:Share] Verifying password for surl=${surl}...`);
const t = String(Date.now());
const verifyUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const verifyUrl = `${BAIDU_PAN_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const verifyBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' });
const vRes = await fetch(verifyUrl, {
method: 'POST',
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: verifyBody.toString(),
signal: AbortSignal.timeout(10000),
});
@@ -686,7 +671,7 @@ export class BaiduDriver {
const shareUrl = `https://pan.baidu.com/s/1${surl}`;
console.log(`[Baidu:Share] Fetching share page: ${shareUrl}`);
const sRes = await fetch(shareUrl, {
headers: buildHeaders(workingCookie),
headers: bdHeaders(workingCookie),
signal: AbortSignal.timeout(15000),
redirect: 'follow',
});
@@ -783,12 +768,12 @@ export class BaiduDriver {
// Verify password first if needed
if (pwd) {
const t = String(Date.now());
const vUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const vUrl = `${BAIDU_PAN_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const vBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' });
const vRes = await fetch(vUrl, {
method: 'POST',
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: vBody.toString(),
signal: AbortSignal.timeout(10000),
});
@@ -805,7 +790,7 @@ export class BaiduDriver {
// Get share page to extract shareid + uk
const sRes = await fetch(shareUrl, {
headers: buildHeaders(workingCookie),
headers: bdHeaders(workingCookie),
signal: AbortSignal.timeout(15000),
redirect: 'follow',
});
@@ -827,7 +812,7 @@ export class BaiduDriver {
const fsidlist = `[${fsIds.join(',')}]`;
const path = destPath === '/' ? '/' : `/${destPath.replace(/^\//, '')}`;
const tUrl = `${API_HOST}/share/transfer?shareid=${shareid}&from=${uk}&bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0`;
const tUrl = `${BAIDU_PAN_HOST}/share/transfer?shareid=${shareid}&from=${uk}&bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0`;
const tBody = new URLSearchParams({ fsidlist, path });
// Retry up to 3 times for transient fetch failures
@@ -837,7 +822,7 @@ export class BaiduDriver {
try {
tRes = await fetch(tUrl, {
method: 'POST',
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: tBody.toString(),
signal: AbortSignal.timeout(30000),
});
@@ -1020,8 +1005,8 @@ export class BaiduDriver {
try {
const res = await fetch(
`${API_HOST}/api/list?dir=${encodeURIComponent(parentPath)}&bdstoken=${bdstoken}&order=time&desc=1`,
{ headers: buildHeaders(cookie), signal: AbortSignal.timeout(10000) }
`${BAIDU_PAN_HOST}/api/list?dir=${encodeURIComponent(parentPath)}&bdstoken=${bdstoken}&order=time&desc=1`,
{ headers: bdHeaders(cookie), signal: AbortSignal.timeout(10000) }
);
if (!res.ok) return null;
const data = await res.json() as any;
@@ -1051,10 +1036,10 @@ export class BaiduDriver {
pwd,
});
const url = `${API_HOST}/share/set?bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0&app_id=250528`;
const url = `${BAIDU_PAN_HOST}/share/set?bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0&app_id=250528`;
const res = await fetch(url, {
method: 'POST',
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
File diff suppressed because it is too large Load Diff
@@ -1,204 +1,120 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as system_config_service from "../../admin/system-config.service";
/**
* 广告关键词清理模块
* 转存完成后执行
* 1. 遍历转存的目录,删除文件名/文件夹名含广告关键词的内容
* 2. 在转存根目录下创建警示文件夹(置顶提醒)
* 夸克网盘 — 广告关键词清理模块
* 转存完成后:删除广告文件名 + 创建警示文件夹
* 依赖 quark-api.ts 统一端点模块
*/
import * as q from './quark-api';
import * as system_config_service from '../../admin/system-config.service';
// ==================== 配置读取 ====================
/** 从 DB 读取广告关键词列表 */
/** 从数据库读取广告关键词列表 */
export function getAdKeywords() {
const raw = system_config_service.getSystemConfig("quark_ad_keywords") || "";
return raw
.split("\n")
.flatMap((line) => line.split(","))
.map((s) => s.trim())
.filter(Boolean);
const raw = system_config_service.getSystemConfig('quark_ad_keywords') || '';
return raw.split('\n').flatMap(line => line.split(',')).map(s => s.trim()).filter(Boolean);
}
/** 从 DB 读取警示文件夹名称列表 */
/** 从数据库读取警示文件夹名称列表 */
export function getWarningFolderNames() {
const raw = system_config_service.getSystemConfig("quark_warning_folder_names") || "";
return raw
.split("\n")
.flatMap((line) => line.split(","))
.map((s) => s.trim())
.filter(Boolean);
const raw = system_config_service.getSystemConfig('quark_warning_folder_names') || '';
return raw.split('\n').flatMap(line => line.split(',')).map(s => s.trim()).filter(Boolean);
}
/** 从 DB 读取可疑文件后缀列表 */
/** 从数据库读取可疑文件后缀列表 */
export function getSusExtensions() {
const raw = system_config_service.getSystemConfig("quark_sus_extensions") || "";
const raw = system_config_service.getSystemConfig('quark_sus_extensions') || '';
if (raw.trim()) {
return raw
.split("\n")
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
.filter(Boolean);
return raw.split('\n').map(s => s.trim().toLowerCase().replace(/^\./, '')).filter(Boolean);
}
// 默认可疑后缀
return ["bat", "exe", "vbs", "scr", "cmd", "com", "pif", "js", "jar", "msi", "reg", "inf", "ps1"];
return ['bat', 'exe', 'vbs', 'scr', 'cmd', 'com', 'pif', 'js', 'jar', 'msi', 'reg', 'inf', 'ps1'];
}
// ==================== 关键词检测 ====================
/** 检查文件名是否包含任意广告关键词 */
export function containsAdKeyword(fileName, keywords) {
if (!keywords.length)
return false;
if (!keywords.length) return false;
const lower = fileName.toLowerCase();
return keywords.some((kw) => kw && lower.includes(kw.toLowerCase()));
return keywords.some(kw => kw && lower.includes(kw.toLowerCase()));
}
// ==================== 删除操作 ====================
/**
* 遍历指定目录(含子目录),删除匹配广告关键词的文件和文件夹
* 返回删除的文件数
* 遍历指定目录(含子目录),删除匹配广告关键词的文件和文件夹
* 返回删除的文件数
*/
export async function deleteAdFiles(cookie, dirFid, keywords) {
const extensions = getSusExtensions();
if (!keywords.length && !extensions.length)
return 0;
if (!keywords.length && !extensions.length) return 0;
let deletedCount = 0;
const stack = [dirFid];
const visited = new Set();
const visited = new Set<string>();
while (stack.length > 0) {
const fid = stack.pop();
if (visited.has(fid))
continue;
const fid = stack.pop()!;
if (visited.has(fid)) continue;
visited.add(fid);
await quark_api.humanDelay();
const files = await quark_api.listDir(cookie, fid);
if (!files || files.length === 0)
continue;
// 先收集所有需要删除的 fid
const toDelete = [];
const toKeep = [];
const extensions = getSusExtensions();
await q.humanDelay();
const files = await q.listDir(cookie, fid);
if (!files || files.length === 0) continue;
const toDelete: string[] = [];
for (const file of files) {
const ext = file.file_name.split(".").pop()?.toLowerCase() || "";
const ext = file.file_name.split('.').pop()?.toLowerCase() || '';
const isSusExt = extensions.includes(ext);
if (containsAdKeyword(file.file_name, keywords) || isSusExt) {
toDelete.push(file.fid);
console.log(`[Quark-AdCleanup] 标记删除: "${file.file_name}" (fid: ${file.fid})${isSusExt ? " [可疑后缀]" : " [广告关键词]"}`);
}
else {
toKeep.push(file.fid);
// 如果是目录且不删除,继续遍历子目录
if (file.dir) {
stack.push(file.fid);
}
const reason = isSusExt ? '[可疑后缀]' : '[广告关键词]';
console.log('[Quark-AdCleanup] 标记删除: "' + file.file_name + '" (fid: ' + file.fid + ') ' + reason);
} else if (file.dir) {
stack.push(file.fid);
}
}
// 批量删除
if (toDelete.length > 0) {
const deleteOk = await batchDeleteFiles(cookie, toDelete);
const deleteOk = await q.permanentDelete(cookie, toDelete);
if (deleteOk) {
deletedCount += toDelete.length;
console.log(`[Quark-AdCleanup] 已删除 ${toDelete.length} 个广告文件`);
console.log('[Quark-AdCleanup] 已删除 ' + toDelete.length + ' 个广告文件');
}
}
}
return deletedCount;
}
// ==================== 警示文件夹 ====================
/**
* 批量删除文件/文件夹(移入回收站)。
* 在转存根目录下创建警示文件夹
* 文件夹名前加 emoji 和空格,让其按字母排序置顶
*/
async function batchDeleteFiles(cookie, fids) {
if (!fids.length)
return true;
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file/trash?${quark_api.makeQuery()}`, {
method: "POST",
headers: {
...quark_api.getHeaders(cookie),
"Content-Type": "application/json",
},
body: JSON.stringify({
action_type: 1,
filelist: fids,
}),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
console.log(`[Quark-AdCleanup] batchDelete HTTP ${resp.status}`);
return false;
}
const data = (await resp.json());
if (data.status === 200) {
return true;
}
console.log(`[Quark-AdCleanup] batchDelete 返回非200: status=${data.status} msg=${data.message}`);
return false;
}
catch (err) {
console.log(`[Quark-AdCleanup] batchDelete 错误: ${err.message}`);
return false;
}
}
// ==================== 警示文件夹创建 ====================
/**
* 在转存根目录下创建警示文件夹。
* 文件夹名前加 ⚠️ 和空格,让其按字母排序置顶。
* 已存在的则跳过。
*/
export async function createWarningDirectories(cookie, dirNames, parentDirFid = "0") {
if (!dirNames.length)
return;
// 先获取根目录下所有文件夹,避免重复创建
await quark_api.humanDelay();
const rootFiles = await quark_api.listDirAllPages(cookie, parentDirFid);
const existingDirs = new Set(rootFiles.filter((f) => f.dir).map((f) => f.file_name));
export async function createWarningDirectories(cookie, dirNames, parentDirFid = '0') {
if (!dirNames.length) return;
await q.humanDelay();
const rootFiles = await q.listDirAllPages(cookie, parentDirFid);
const existingDirs = new Set(rootFiles.filter(f => f.dir).map(f => f.file_name));
for (const name of dirNames) {
// 格式化名称:确保以 ⚠️ 开头
let formattedName = name;
if (!formattedName.startsWith("⚠️") && !formattedName.startsWith("⚠")) {
formattedName = `⚠️ ${formattedName}`;
if (!formattedName.startsWith('\u26a0\ufe0f') && !formattedName.startsWith('\u26a0')) {
formattedName = '\u26a0\ufe0f ' + formattedName;
}
// 去掉多余空格
formattedName = formattedName.replace(/\s+/g, " ").trim();
formattedName = formattedName.replace(/\s+/g, ' ').trim();
if (existingDirs.has(formattedName)) {
console.log(`[Quark-AdCleanup] 警示文件夹已存在,跳过: "${formattedName}"`);
console.log('[Quark-AdCleanup] 警示文件夹已存在,跳过: "' + formattedName + '"');
continue;
}
await createSingleDir(cookie, formattedName, parentDirFid);
// 加入已存在集合,防止同名重试
const result = await q.quarkCreateDir(cookie, formattedName, parentDirFid);
if (result) {
console.log('[Quark-AdCleanup] 已创建警示文件夹: "' + formattedName + '" (fid: ' + result.fid + ')');
}
existingDirs.add(formattedName);
}
}
/**
* 创建单个文件夹。
*/
async function createSingleDir(cookie, dirName, pdirFid = "0") {
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file?${quark_api.makeQuery()}`, {
method: "POST",
headers: {
...quark_api.getHeaders(cookie),
"Content-Type": "application/json",
},
body: JSON.stringify({
pdir_fid: pdirFid,
file_name: dirName,
dir: true,
dir_path: "",
}),
signal: AbortSignal.timeout(10000),
});
const data = (await resp.json());
if (data.status === 200 && data.data?.fid) {
console.log(`[Quark-AdCleanup] 已创建警示文件夹: "${dirName}" (fid: ${data.data.fid})`);
return true;
}
console.log(`[Quark-AdCleanup] 创建文件夹失败: status=${data.status} msg=${data.message}`);
return false;
}
catch (err) {
console.log(`[Quark-AdCleanup] 创建文件夹错误: "${dirName}" — ${err.message}`);
return false;
}
}
// ==================== 主入口 ====================
/**
* 执行广告清理 + 创建警示文件夹
* 在转存重命名后调用
* 执行广告清理 + 创建警示文件夹
* 在转存重命名后调用
*/
export async function runAdCleanup(cookie, savedDirFid) {
const keywords = getAdKeywords();
@@ -206,24 +122,23 @@ export async function runAdCleanup(cookie, savedDirFid) {
const warningNames = getWarningFolderNames();
let adDeleted = 0;
let warningDirs = 0;
// 1. 广告关键词 + 可疑后缀清理
if (keywords.length > 0 || susExtensions.length > 0) {
console.log(`[Quark-AdCleanup] 开始文件清理: ${keywords.length} 个关键词, ${susExtensions.length} 个可疑后缀`);
console.log('[Quark-AdCleanup] 开始文件清理: ' + keywords.length + ' 个关键词, ' + susExtensions.length + ' 个可疑后缀');
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
console.log(`[Quark-AdCleanup] 清理完成,共删除 ${adDeleted} 个文件/文件夹`);
console.log('[Quark-AdCleanup] 清理完成,共删除 ' + adDeleted + ' 个文件/文件夹');
} else {
console.log('[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理');
}
else {
console.log("[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理");
}
// 2. 创建警示文件夹
if (warningNames.length > 0) {
console.log(`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length}`);
console.log('[Quark-AdCleanup] 开始创建警示文件夹: ' + warningNames.length + ' 个');
await createWarningDirectories(cookie, warningNames, savedDirFid);
warningDirs = warningNames.length;
console.log(`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`);
}
else {
console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建");
console.log('[Quark-AdCleanup] 警示文件夹创建完成(共 ' + warningDirs + ' 个)');
} else {
console.log('[Quark-AdCleanup] 无警示文件夹配置,跳过创建');
}
return { adDeleted, warningDirs };
}
+222 -80
View File
@@ -1,20 +1,61 @@
// @ts-nocheck
// ==================== Headers & Params ====================
const BASE_URL = 'https://drive-pc.quark.cn';
// ===================================================================
// 夸克网盘 API 统一模块
// 所有夸克 API 端点、请求头、工具函数集中管理
// 换 API 只需改这个文件
// ===================================================================
// ==================== 域名 ====================
// 修改夸克 API 域名只需改下面两行
export const QUARK_DRIVE_HOST = 'https://drive-pc.quark.cn';
export const QUARK_PAN_HOST = 'https://pan.quark.cn';
// ==================== API 端点路径 ====================
// 所有夸克 API 路径集中在此,换版本只需改这里
export const EP = {
// ── 账号 ──
ACCOUNT_INFO: '/account/info', // GET 获取账号信息
// ── 文件操作 ──
FILE: '/1/clouddrive/file', // POST 创建文件夹
FILE_SORT: '/1/clouddrive/file/sort', // GET 列出目录文件
FILE_RENAME: '/1/clouddrive/file/rename', // POST 重命名
FILE_DELETE: '/1/clouddrive/file/delete', // POST 永久删除 (action_type=2)
FILE_TRASH_CLEAR: '/1/clouddrive/file/trash/clear', // POST 清空回收站
// ── 分享 ──
SHARE: '/1/clouddrive/share', // POST 创建分享链接
SHARE_PASSWORD: '/1/clouddrive/share/password', // POST 设置分享密码
SHARE_PAGE_TOKEN: '/1/clouddrive/share/sharepage/token', // GET 获取分享页 token
SHARE_PAGE_DETAIL: '/1/clouddrive/share/sharepage/detail', // GET 获取分享详情
SHARE_PAGE_SAVE: '/1/clouddrive/share/sharepage/save', // POST 保存分享文件到网盘
// ── 任务 ──
TASK: '/1/clouddrive/task', // GET 查询异步任务状态
// ── 容量 ──
CAPACITY_DETAIL: '/1/clouddrive/capacity/detail', // GET 容量详情
MEMBER: '/1/clouddrive/member', // GET 会员/容量快速查询
} as const;
// ==================== 请求头 ====================
export function getHeaders(cookie) {
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',
'Referer': QUARK_PAN_HOST + '/',
'Origin': QUARK_PAN_HOST,
};
}
// ==================== 通用参数 ====================
export function getCommonParams() {
return { pr: 'ucpro', fr: 'pc' };
}
/** Generate query string with common params + random timing to mimic browser */
/** 生成浏览器风格的 query string(随机延迟参数) */
export function makeQuery(extra = {}) {
const __dt = Math.floor(Math.random() * 240000 + 60000);
const __t = Date.now() / 1000;
@@ -27,20 +68,21 @@ export function makeQuery(extra = {}) {
...extra,
}).toString();
}
/** Random delay to mimic human behavior (500-2000ms) */
// ==================== 工具函数 ====================
/** 随机延迟 500~2000ms,模拟人类操作 */
export async function humanDelay() {
const ms = Math.floor(Math.random() * 1500) + 500;
await new Promise(r => setTimeout(r, ms));
}
/** Generate a random password for share links */
/** 生成 4 位数字分享密码 */
export function randomSharePwd() {
return Math.floor(1000 + Math.random() * 9000).toString();
}
/**
* Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
*/
/** 从 Cookie 中提取 kps/sign/vcode */
export function getMparam(cookie) {
// 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%+/=]+)/);
@@ -53,16 +95,40 @@ export function getMparam(cookie) {
}
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(path, options) {
const { method = 'GET', query, body, cookie, timeout = 10000 } = options;
let url = `${BASE_URL}${path}`;
if (query)
url += `?${new URLSearchParams(query).toString()}`;
/** 格式化字节为人类可读字符串 */
export function formatBytes(bytes) {
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];
}
/** 当天日期文件夹名 (YYYY-MM-DD) */
export function dailyFolderName() {
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}`;
}
/** 随机 12 位字母数字文件夹名 */
export function randomFolderName() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let name = '';
for (let i = 0; i < 12; i++) {
name += chars[Math.floor(Math.random() * chars.length)];
}
return name;
}
// ==================== 底层 fetch 封装 ====================
/** 通用 JSON fetch,返回解析后的 body 或 null */
export async function apiFetch(endpoint, options) {
const { method = 'GET', query, body, cookie, timeout = 10000, host = QUARK_DRIVE_HOST } = options;
let url = host + endpoint;
if (query) url += '?' + new URLSearchParams(query).toString();
try {
const resp = await fetch(url, {
method,
@@ -73,17 +139,15 @@ export async function apiFetch(path, options) {
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(timeout),
});
if (!resp.ok)
return null;
return (await resp.json());
}
catch {
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
/**
* List files in a directory by FID.
*/
// ==================== 目录操作 ====================
/** 列出指定目录下的文件(单页) */
export async function listDir(cookie, pdirFid, page = 1, pageSize = 50) {
try {
const params = new URLSearchParams({
@@ -98,27 +162,26 @@ export async function listDir(cookie, pdirFid, page = 1, pageSize = 50) {
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 resp = await fetch(QUARK_DRIVE_HOST + EP.FILE_SORT + '?' + params.toString(), {
headers: getHeaders(cookie),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return [];
const data = await resp.json();
if (data.status !== 200)
return [];
return (data.data?.list || []).filter((f) => f.fid).map((f) => ({
if (data.status !== 200) return [];
return (data.data?.list || []).filter(f => f.fid).map(f => ({
fid: f.fid,
file_name: f.file_name,
share_fid_token: '',
dir: f.dir || false,
size: f.size || 0,
}));
}
catch {
} catch {
return [];
}
}
/**
* List root directory (pdir_fid=0) — returns all top-level dirs/files.
*/
/** 列出根目录文件 */
export async function listRootDir(cookie) {
try {
const params = new URLSearchParams({
@@ -130,27 +193,25 @@ export async function listRootDir(cookie) {
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 resp = await fetch(QUARK_DRIVE_HOST + EP.FILE_SORT + '?' + params.toString(), {
headers: getHeaders(cookie),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return [];
const data = await resp.json();
if (data.status !== 200 || !data.data?.list)
return [];
return (data.data.list || []).map((f) => ({
if (data.status !== 200 || !data.data?.list) return [];
return (data.data.list || []).map(f => ({
fid: f.fid,
file_name: f.file_name,
dir: f.dir || false,
size: f.size || 0,
}));
}
catch {
} catch {
return [];
}
}
/**
* List all files in a directory, handling pagination.
* Fetches all pages until no more results.
*/
/** 列出目录全部文件(自动翻页) */
export async function listDirAllPages(cookie, pdirFid) {
const allFiles = [];
let page = 1;
@@ -158,38 +219,119 @@ export async function listDirAllPages(cookie, pdirFid) {
let total = -1;
while (total === -1 || (page - 1) * pageSize < total) {
const files = await listDir(cookie, pdirFid, page, pageSize);
if (!files.length)
break;
if (!files.length) break;
allFiles.push(...files);
if (total === -1) {
total = files.length;
}
if (total === -1) total = files.length;
page++;
}
return allFiles;
}
// ==================== Format utilities ====================
export function formatBytes(bytes) {
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() {
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() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let name = '';
for (let i = 0; i < 12; i++) {
name += chars[Math.floor(Math.random() * chars.length)];
// ==================== 高级操作封装 ====================
/**
* 永久删除文件/文件夹
* @param cookie 认证 cookie
* @param fids 要删除的文件/文件夹 fid 数组
*/
export async function permanentDelete(cookie, fids) {
if (!fids.length) return true;
try {
const resp = await fetch(QUARK_DRIVE_HOST + EP.FILE_DELETE + '?' + makeQuery(), {
method: 'POST',
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ action_type: 2, filelist: fids }),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) return false;
const data = await resp.json();
return data.status === 200;
} catch (err) {
console.error('[Quark] permanentDelete error:', err.message);
return false;
}
}
/**
* 清空回收站
*/
export async function emptyTrashApi(cookie) {
try {
const resp = await fetch(QUARK_DRIVE_HOST + EP.FILE_TRASH_CLEAR + '?' + makeQuery(), {
method: 'POST',
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({}),
signal: AbortSignal.timeout(60000),
});
if (!resp.ok) return false;
const data = await resp.json();
return data.status === 200;
} catch (err) {
console.error('[Quark] emptyTrashApi error:', err.message);
return false;
}
}
/**
* 创建文件夹
* @returns 成功返回 { fid, file_name },失败返回 null
*/
export async function quarkCreateDir(cookie, dirName, pdirFid = '0') {
try {
const resp = await fetch(QUARK_DRIVE_HOST + EP.FILE + '?' + makeQuery(), {
method: 'POST',
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ pdir_fid: pdirFid, file_name: dirName, dir: true, dir_path: '' }),
signal: AbortSignal.timeout(10000),
});
const data = await resp.json();
if (data.status === 200 && data.data?.fid) {
return { fid: data.data.fid, file_name: dirName };
}
console.log('[Quark] quarkCreateDir failed: status=' + data.status + ' msg=' + data.message);
return null;
} catch (err) {
console.error('[Quark] quarkCreateDir error:', err.message);
return null;
}
}
/**
* 获取会员/容量信息 (快速接口)
*/
export async function getMemberInfo(cookie) {
try {
const params = new URLSearchParams({ pr: 'ucpro', fr: 'pc', uc_param_str: '', __t: String(Date.now()), __dt: '1000' });
const resp = await fetch(QUARK_PAN_HOST + EP.MEMBER + '?' + params.toString(), {
headers: getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return null;
const data = await resp.json();
if (data.status === 200 && data.data) {
return {
usedBytes: data.data.use_capacity ?? 0,
totalBytes: data.data.total_capacity ?? 0,
};
}
return null;
} catch {
return null;
}
}
/**
* 获取账号信息 (验证 cookie 有效性)
*/
export async function getAccountInfo(cookie) {
const url = QUARK_PAN_HOST + EP.ACCOUNT_INFO + '?fr=pc&platform=pc';
try {
const resp = await fetch(url, { headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) });
if (resp.ok) {
const data = await resp.json();
if (data.status === 200 && data.data) return data.data;
}
return null;
} catch {
return null;
}
return name;
}
+8 -9
View File
@@ -1,9 +1,10 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as q from './quark-api';
/**
* 认证模块 — Cookie 验证、账号信息获取、QR 登录状态检查。
* 所有方法以 cookie 字符串为参数,不持有驱动状态。
* 依赖 quark-api.ts 统一端点模块 — URL 不再硬编码。
*/
// ==================== Validate ====================
/**
@@ -13,12 +14,10 @@ export async function validate(cookie) {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
// Use account/info API (same as quark-auto-save project)
// Only needs __uid cookie, no mparam (kps/sign/vcode) required
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const url = q.QUARK_PAN_HOST + q.EP.ACCOUNT_INFO + '?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
...q.getHeaders(cookie),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
},
signal: AbortSignal.timeout(15000),
@@ -31,11 +30,11 @@ export async function validate(cookie) {
}
catch (err) {
if (attempt < MAX_RETRIES) {
console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`);
console.log('[Quark] validate attempt ' + (attempt + 1) + ' failed: ' + err.message + ', retrying...');
await new Promise(r => setTimeout(r, 2000));
continue;
}
console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`);
console.log('[Quark] validate all ' + (MAX_RETRIES + 1) + ' attempts failed: ' + err.message);
}
}
return false;
@@ -43,10 +42,10 @@ export async function validate(cookie) {
/** Fetch nickname from Quark account info (same API used by quark-auto-save) */
export async function fetchNickname(cookie) {
try {
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const url = q.QUARK_PAN_HOST + q.EP.ACCOUNT_INFO + '?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
...q.getHeaders(cookie),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
},
signal: AbortSignal.timeout(15000),
@@ -0,0 +1,62 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
/**
* 认证模块 — Cookie 验证、账号信息获取、QR 登录状态检查。
* 所有方法以 cookie 字符串为参数,不持有驱动状态。
*/
// ==================== Validate ====================
/**
* Validate the cookie by fetching user info.
*/
export async function validate(cookie) {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
// Use account/info API (same as quark-auto-save project)
// Only needs __uid cookie, no mparam (kps/sign/vcode) required
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok)
return false;
const data = await response.json();
if (data?.data?.nickname)
return true;
}
catch (err) {
if (attempt < MAX_RETRIES) {
console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`);
await new Promise(r => setTimeout(r, 2000));
continue;
}
console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`);
}
}
return false;
}
/** Fetch nickname from Quark account info (same API used by quark-auto-save) */
export async function fetchNickname(cookie) {
try {
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok)
return null;
const data = await response.json();
return data?.data?.nickname || null;
}
catch {
return null;
}
}
+81 -175
View File
@@ -1,58 +1,38 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
/**
* 夸克网盘 — 容量查询 & 空间清理
* 依赖 quark-api.ts 统一端点模块
*/
import * as q from './quark-api';
// ==================== 容量查询 ====================
/** 容量缓存 (3小时窗口) */
const storageCache = { bytes: 0, hourBlock: -1 };
/**
* 容量信息 & 空间清理模块。
*/
const BASE_URL = 'https://drive-pc.quark.cn';
// ==================== Storage Info ====================
/** Cached used space, keyed by hour block (3h window) */
const cachedUsedSpace = null;
// We use a function-scoped cache instead of instance field
const storageCache = { bytes: 0, hourBlock: -1 };
/**
* Get total capacity from /capacity/detail API.
* Also does a quick used-space estimate by summing root-level file sizes + subdir sizes
* (夸克目录的 size 字段 = 该目录内所有文件总大小,无需递归).
* If the API fails (e.g. missing sign params), falls back to fallbackTotal if provided.
*/
/**
* Get storage info from /member API — single fast call returns both used & total capacity.
* Falls back to capacity/detail + root file sum if member API fails.
* 快速获取容量信息 (优先 /member API,回退到 capacity/detail + 根目录统计)
*/
export async function getStorageInfoQuick(cookie, fallbackTotal?) {
// 优先使用 /member 快速接口
const member = await q.getMemberInfo(cookie);
if (member && member.totalBytes > 0) {
const currentHourBlock = Math.floor(new Date().getHours() / 3);
storageCache.bytes = member.usedBytes;
storageCache.hourBlock = currentHourBlock;
return {
total: q.formatBytes(member.totalBytes),
totalBytes: member.totalBytes,
used: q.formatBytes(member.usedBytes),
usedBytes: member.usedBytes,
};
}
// 回退: capacity/detail + 根目录文件大小合计
try {
const memberParams = new URLSearchParams({ pr: 'ucpro', fr: 'pc', uc_param_str: '', __t: String(Date.now()), __dt: '1000' });
const resp = await fetch(`https://pan.quark.cn/1/clouddrive/member?${memberParams.toString()}`, {
headers: quark_api.getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (resp.ok) {
const data = await resp.json();
if (data.status === 200 && data.data) {
const usedBytes = data.data.use_capacity ?? 0;
const totalBytes = data.data.total_capacity ?? 0;
if (totalBytes > 0) {
// Cache for calculateUsedSpace compatibility
const currentHourBlock = Math.floor(new Date().getHours() / 3);
storageCache.bytes = usedBytes;
storageCache.hourBlock = currentHourBlock;
return {
total: quark_api.formatBytes(totalBytes),
totalBytes,
used: quark_api.formatBytes(usedBytes),
usedBytes,
};
}
}
}
} catch {}
// Fallback: capacity/detail for total + root file sum for used
try {
const params = new URLSearchParams(quark_api.getCommonParams());
const capResp = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
headers: quark_api.getHeaders(cookie),
const params = new URLSearchParams(q.getCommonParams());
const capResp = await fetch(q.QUARK_DRIVE_HOST + q.EP.CAPACITY_DETAIL + '?' + params.toString(), {
headers: q.getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
let totalBytes = 0;
@@ -62,46 +42,32 @@ export async function getStorageInfoQuick(cookie, fallbackTotal?) {
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
if (totalBytes === 0) {
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
totalBytes = memberships.reduce((max, m) => Math.max(max, m.capacity || 0), 0);
}
}
}
let usedBytes = 0;
try {
const rootFiles = await quark_api.listRootDir(cookie);
for (const f of rootFiles) usedBytes += f.size || 0;
} catch {}
try { const rootFiles = await q.listRootDir(cookie); for (const f of rootFiles) usedBytes += f.size || 0; } catch {}
if (totalBytes > 0) {
return { total: quark_api.formatBytes(totalBytes), totalBytes, used: quark_api.formatBytes(usedBytes), usedBytes };
return { total: q.formatBytes(totalBytes), totalBytes, used: q.formatBytes(usedBytes), usedBytes };
}
} catch {}
// Last resort: parse fallbackTotal string
// 兜底:从字符串解析
if (fallbackTotal) {
const match = String(fallbackTotal).match(/^([\d.]+)\s*([KMGT]B?)/i);
if (match) {
const num = parseFloat(match[1]);
const unit = match[2].toUpperCase();
const multipliers: Record<string,number> = { B:1, KB:1024, MB:1048576, GB:1073741824, TB:1099511627776 };
const multipliers = { B:1, KB:1024, MB:1048576, GB:1073741824, TB:1099511627776 };
const m = multipliers[unit] || multipliers[unit.replace('B','')+'B'] || 0;
if (m > 0) return { total: String(fallbackTotal), totalBytes: Math.round(num*m), used: '-', usedBytes: 0 };
}
}
return { total: '-', totalBytes: 0, used: '-', usedBytes: 0 };
}
/**
* Get storage info with used space calculation.
*/
/**
* Fast estimation (root-level files only) + background full traversal.
* First call returns quickly; full traversal runs async and updates DB later.
* `onBackgroundComplete` is called when traversal finishes.
*/
/**
* Get accurate storage info via /member API (fast, no file traversal needed).
* Returns { total, totalBytes, used, usedBytes } in ~1s.
* onBackgroundComplete is kept for backward compat (called synchronously).
*/
/** 获取容量信息 (兼容旧接口) */
export async function getStorageInfo(cookie, onBackgroundComplete?) {
const result = await getStorageInfoQuick(cookie);
if (onBackgroundComplete && result.used !== '-' && result.usedBytes > 0) {
@@ -109,10 +75,8 @@ export async function getStorageInfo(cookie, onBackgroundComplete?) {
}
return result;
}
/**
* Calculate total used space by recursively traversing all files
* and summing their sizes. Uses 3-hour time window cache.
*/
/** 递归遍历所有文件计算已用空间 (带 3 小时缓存) */
export async function calculateUsedSpace(cookie) {
const currentHourBlock = Math.floor(new Date().getHours() / 3);
if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) {
@@ -120,22 +84,16 @@ export async function calculateUsedSpace(cookie) {
}
let totalUsed = 0;
const stack = ['0'];
const visited = new Set();
const visited = new Set<string>();
while (stack.length > 0) {
const fid = stack.pop();
if (visited.has(fid))
continue;
const fid = stack.pop()!;
if (visited.has(fid)) continue;
visited.add(fid);
const files = await quark_api.listDirAllPages(cookie, fid);
if (!files.length)
continue;
const files = await q.listDirAllPages(cookie, fid);
if (!files.length) continue;
for (const f of files) {
if (f.dir) {
stack.push(f.fid);
}
else {
totalUsed += f.size || 0;
}
if (f.dir) stack.push(f.fid);
else totalUsed += f.size || 0;
}
await new Promise(r => setTimeout(r, 50));
}
@@ -143,119 +101,69 @@ export async function calculateUsedSpace(cookie) {
storageCache.hourBlock = currentHourBlock;
return totalUsed;
}
// ==================== Cleanup ====================
// ==================== 清理操作 ====================
/**
* Trash specified files/folders (move to recycle bin).
* 永久删除文件/文件夹 (之前是移入回收站, 现改为直接永久删除)
*/
export async function trashFiles(cookie, fids) {
if (!fids.length)
return true;
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/file/trash?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
action_type: 1, // 1 = move to trash
filelist: fids,
exclude_filelist: [],
}),
signal: AbortSignal.timeout(30000),
});
if (!response.ok)
return false;
const data = await response.json();
if (data.status === 200)
return true;
console.error(`[Quark] trashFiles failed: ${data.message || data.status}`);
return false;
}
catch (err) {
console.error(`[Quark] trashFiles error: ${err.message}`);
return false;
}
return q.permanentDelete(cookie, fids);
}
/**
* Empty the recycle bin — permanently delete all files in trash.
* 清空回收站 (现在删除是永久删除, 此函数基本不需要了, 保留兼容)
*/
export async function emptyTrash(cookie) {
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/file/trash/clear?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({}),
signal: AbortSignal.timeout(60000),
});
if (!response.ok)
return false;
const data = await response.json();
if (data.status === 200)
return true;
console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`);
return false;
}
catch (err) {
console.error(`[Quark] emptyTrash error: ${err.message}`);
return false;
}
return q.emptyTrashApi(cookie);
}
/**
* Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`.
* 清理 N 天前的日期文件夹 (YYYY-MM-DD 格式)
*/
export async function cleanupOldDateFolders(cookie, days, whitelistDirs) {
const errors = [];
export async function cleanupOldDateFolders(cookie, days, whitelistDirs?) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
try {
const rootItems = await quark_api.listRootDir(cookie);
const rootItems = await q.listRootDir(cookie);
const oldFolders = rootItems.filter(item => {
if (!item.dir)
return false;
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
return false;
if (whitelistDirs && whitelistDirs.includes(item.file_name))
return false;
if (!item.dir) return false;
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false;
if (whitelistDirs && whitelistDirs.includes(item.file_name)) return false;
return item.file_name < cutoffStr;
});
if (oldFolders.length === 0) {
return { trashed: 0, errors: [] };
}
if (oldFolders.length === 0) return { trashed: 0, errors: [] };
const fids = oldFolders.map(f => f.fid);
console.log(`[Quark] Trashing ${fids.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`);
console.log('[Quark] 永久删除 ' + fids.length + ' 个旧日期文件夹 (早于 ' + cutoffStr + '): ' + oldFolders.map(f => f.file_name).join(', '));
const ok = await trashFiles(cookie, fids);
if (ok) {
return { trashed: fids.length, errors: [] };
}
return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] };
}
catch (err) {
if (ok) return { trashed: fids.length, errors: [] };
return { trashed: 0, errors: ['删除 API 失败: ' + fids.length + ' 个文件夹'] };
} catch (err) {
return { trashed: 0, errors: [err.message] };
}
}
/**
* Cleanup: if used space exceeds thresholdPercent% of total,
* delete the oldest date folders until totalBytes * deletePercent/100
* of total capacity is freed.
* 根据空间阈值清理:已用空间超过 thresholdPercent% 时删除最老的日期文件夹
* 直到释放 deletePercent% 的总容量
*/
export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePercent, whitelistDirs) {
const errors = [];
export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePercent, whitelistDirs?) {
try {
const storage = await getStorageInfo(cookie);
if (storage.totalBytes <= 0)
return { trashed: 0, errors: [] };
if (storage.totalBytes <= 0) return { trashed: 0, errors: [] };
const usagePercent = (storage.usedBytes / storage.totalBytes) * 100;
if (usagePercent < thresholdPercent) {
console.log(`[Quark] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`);
console.log('[Quark] 使用率 ' + usagePercent.toFixed(1) + '% 低于阈值 ' + thresholdPercent + '%, 跳过');
return { trashed: 0, errors: [] };
}
const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100);
const rootItems = await quark_api.listRootDir(cookie);
const rootItems = await q.listRootDir(cookie);
const dateFolders = rootItems
.filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
.filter(item => !whitelistDirs || !whitelistDirs.includes(item.file_name))
.sort((a, b) => a.file.localeCompare(b.file_name));
if (dateFolders.length === 0)
return { trashed: 0, errors: [] };
.sort((a, b) => a.file_name.localeCompare(b.file_name));
if (dateFolders.length === 0) return { trashed: 0, errors: [] };
const hasSizes = dateFolders.some(f => f.size && f.size > 0);
let cumulativeSize = 0;
const foldersToTrash = [];
@@ -263,11 +171,9 @@ export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePe
for (const folder of dateFolders) {
foldersToTrash.push(folder);
cumulativeSize += folder.size || 0;
if (cumulativeSize >= targetBytesToFree)
break;
if (cumulativeSize >= targetBytesToFree) break;
}
}
else {
} else {
const avgSizePerFolder = storage.usedBytes / dateFolders.length;
const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder));
foldersToTrash.push(...dateFolders.slice(0, estCount));
@@ -276,15 +182,15 @@ export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePe
const freedMB = (cumulativeSize / 1024 / 1024).toFixed(0);
const targetMB = (targetBytesToFree / 1024 / 1024).toFixed(0);
const fidsToTrash = foldersToTrash.map(f => f.fid);
console.log(`[Quark] Space threshold: trashing ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB) to free ${targetMB} MB (${deletePercent}% of ${(storage.totalBytes / 1024 / 1024 / 1024).toFixed(0)} GB total)`);
const gbTotal = (storage.totalBytes / 1024 / 1024 / 1024).toFixed(0);
console.log('[Quark] 空间阈值清理:删除 ' + foldersToTrash.length + '/' + dateFolders.length + ' 个最老文件夹 (~' + freedMB + ' MB),目标释放 ' + targetMB + ' MB (' + deletePercent + '% of ' + gbTotal + ' GB)');
const ok = await trashFiles(cookie, fidsToTrash);
if (ok) {
console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`);
console.log('[Quark] OK 空间阈值清理完成:已删除 ' + foldersToTrash.length + ' 个文件夹 (~' + freedMB + ' MB)');
return { trashed: foldersToTrash.length, errors: [] };
}
return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] };
}
catch (err) {
return { trashed: 0, errors: ['空间阈值清理失败: ' + foldersToTrash.length + ' 个文件夹'] };
} catch (err) {
return { trashed: 0, errors: [err.message] };
}
}
+20 -21
View File
@@ -1,10 +1,9 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as q from './quark-api';
/**
* 分享模块 — 分享链接解析、转存任务、创建分享链接。
*/
const BASE_URL = 'https://drive-pc.quark.cn';
// ==================== Acquire Stoken ====================
/**
* Acquire stoken for a share link (needed for detail/save).
@@ -12,10 +11,10 @@ const BASE_URL = 'https://drive-pc.quark.cn';
export async function acquireStoken(cookie, pwdId) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const params = new URLSearchParams(quark_api.getCommonParams());
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/token?${params.toString()}`, {
const params = new URLSearchParams(q.getCommonParams());
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PAGE_TOKEN?${params.toString()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
signal: AbortSignal.timeout(10000),
});
@@ -44,7 +43,7 @@ export async function acquireStoken(cookie, pwdId) {
*/
export async function getDetailAt(cookie, pwdId, stoken, pdirFid) {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
...q.getCommonParams(),
pwd_id: pwdId,
stoken,
pdir_fid: pdirFid,
@@ -58,7 +57,7 @@ export async function getDetailAt(cookie, pwdId, stoken, pdirFid) {
ver: '2',
fetch_share_full_path: '0',
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/detail?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(15000) });
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PAGE_DETAIL?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const data = await resp.json();
@@ -108,9 +107,9 @@ export async function getShareFiles(cookie, pwdId, stoken) {
*/
export async function saveFiles(cookie, pwdId, stoken, fids, fidTokens, toPdirFid) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/save?${quark_api.makeQuery()}`, {
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PAGE_SAVE?${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: fids,
fid_token_list: fidTokens,
@@ -148,14 +147,14 @@ export async function waitForTask(cookie, taskId, timeoutMs) {
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
...q.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.TASK?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.status === 200) {
if (data.data?.status === 2) {
@@ -180,9 +179,9 @@ export async function waitForTask(cookie, taskId, timeoutMs) {
*/
export async function renameFile(cookie, fid, newName) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/file/rename?${quark_api.makeQuery()}`, {
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.FILE_RENAME?${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ fid, file_name: newName }),
signal: AbortSignal.timeout(10000),
});
@@ -200,16 +199,16 @@ export async function renameFile(cookie, fid, newName) {
*/
export async function createShareLink(cookie, fileId) {
try {
const sharePwd = quark_api.randomSharePwd();
const sharePwd = q.randomSharePwd();
// Try different share_type values (1=7天, 0=无限制)
const shareTypes = ['1', '0'];
let lastError = '';
for (const st of shareTypes) {
await quark_api.humanDelay();
await q.humanDelay();
// Step 1: Create share task - get task_id
const response = await fetch(`${BASE_URL}/1/clouddrive/share?${quark_api.makeQuery()}`, {
const response = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE + "?"${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: [fileId],
share_type: st,
@@ -256,9 +255,9 @@ export async function createShareLink(cookie, fileId) {
*/
async function submitShare(cookie, shareId, sharePwd) {
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/share/password?${quark_api.makeQuery()}`, {
const response = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PASSWORD?${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
signal: AbortSignal.timeout(15000),
});
@@ -285,14 +284,14 @@ async function waitForShareTask(cookie, taskId, timeoutMs) {
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
...q.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.TASK?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.data?.status === 2) {
// Task completed — try multiple extraction approaches
@@ -0,0 +1,356 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
/**
* 分享模块 — 分享链接解析、转存任务、创建分享链接。
*/
const BASE_URL = 'https://drive-pc.quark.cn';
// ==================== Acquire Stoken ====================
/**
* Acquire stoken for a share link (needed for detail/save).
*/
export async function acquireStoken(cookie, pwdId) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const params = new URLSearchParams(quark_api.getCommonParams());
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/token?${params.toString()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
if (attempt < 2)
continue;
return null;
}
const data = await resp.json();
if (data.status === 200 && data.data?.stoken) {
return data.data.stoken;
}
return null;
}
catch {
if (attempt >= 2)
return null;
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
}
}
return null;
}
// ==================== Get Share Files ====================
/**
* Fetch detail at a given pdir_fid within a share.
*/
export async function getDetailAt(cookie, pwdId, stoken, pdirFid) {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
pwd_id: pwdId,
stoken,
pdir_fid: pdirFid,
force: '0',
_page: '1',
_size: '50',
_fetch_banner: '0',
_fetch_share: '1',
_fetch_total: '1',
_sort: 'file_type:asc,updated_at:desc',
ver: '2',
fetch_share_full_path: '0',
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/detail?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const data = await resp.json();
if (data.status !== 200)
return [];
return (data.data?.list || []).filter((f) => f.fid).map((f) => ({
fid: f.fid,
file_name: f.file_name,
share_fid_token: f.share_fid_token || '',
dir: f.dir || false,
size: f.size || 0,
}));
}
/**
* Recursively collect files from a share.
* If the share contains a single directory, drill into it to list contents
* but still save the directory itself.
*/
export async function getShareFiles(cookie, pwdId, stoken) {
try {
const topLevel = await getDetailAt(cookie, pwdId, stoken, '0');
if (!topLevel || topLevel.length === 0)
return null;
// If the share is a single directory, we save the directory itself
// and fetch its contents for renaming later
if (topLevel.length === 1 && topLevel[0].dir) {
const innerFiles = await getDetailAt(cookie, pwdId, stoken, topLevel[0].fid);
return {
files: topLevel,
topDir: true,
childFiles: innerFiles || [],
};
}
// Multiple top-level items: save them directly
return {
files: topLevel,
topDir: false,
};
}
catch {
return null;
}
}
// ==================== Save Files (share → cloud) ====================
/**
* Save shared files to the user's cloud directory.
*/
export async function saveFiles(cookie, pwdId, stoken, fids, fidTokens, toPdirFid) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/save?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: fids,
fid_token_list: fidTokens,
to_pdir_fid: toPdirFid,
pwd_id: pwdId,
stoken,
pdir_fid: '0',
scene: 'link',
}),
signal: AbortSignal.timeout(30000),
});
const data = await resp.json();
if (data.status === 200 && data.data?.task_id) {
return { success: true, message: 'Save task created', taskId: data.data.task_id };
}
return {
success: false,
message: data.message === 'require login [guest]'
? '夸克网盘 Cookie 已过期,请在后台重新配置 Cookie'
: (data.message || `API 返回错误 (status=${data.status}, code=${data.code})`),
};
}
catch (err) {
return { success: false, message: err.message || 'Network error' };
}
}
// ==================== Wait for Save Task ====================
/**
* Poll task status until complete or timeout.
* Returns the saved file FIDs (save_as_top_fids).
*/
export async function waitForTask(cookie, taskId, timeoutMs) {
const start = Date.now();
let retryIndex = 0;
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.status === 200) {
if (data.data?.status === 2) {
// Task completed
const savedFids = data.data?.save_as?.save_as_top_fids || [];
return savedFids;
}
// Still in progress
retryIndex++;
}
}
catch {
// Network error, retry
}
await new Promise(r => setTimeout(r, 1000));
}
return null; // Timeout
}
// ==================== Rename File ====================
/**
* Rename a file by its FID.
*/
export async function renameFile(cookie, fid, newName) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/file/rename?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ fid, file_name: newName }),
signal: AbortSignal.timeout(10000),
});
const data = await resp.json();
return data.status === 200 || data.code === 0;
}
catch {
return false;
}
}
// ==================== Create Share Link ====================
/**
* Create a share link for a file/folder.
* Flow: create task → poll for share_id → submit to get short URL.
*/
export async function createShareLink(cookie, fileId) {
try {
const sharePwd = quark_api.randomSharePwd();
// Try different share_type values (1=7天, 0=无限制)
const shareTypes = ['1', '0'];
let lastError = '';
for (const st of shareTypes) {
await quark_api.humanDelay();
// Step 1: Create share task - get task_id
const response = await fetch(`${BASE_URL}/1/clouddrive/share?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: [fileId],
share_type: st,
url_type: '1',
share_pwd: sharePwd,
}),
signal: AbortSignal.timeout(15000),
});
const data = await response.json();
const taskId = data.data?.task_id;
if (!taskId) {
lastError = data.message || `share_type=${st} 失败`;
console.error('[Quark] Create share task failed (type=%s):', st, data.message || JSON.stringify(data).slice(0, 200));
continue;
}
// Step 2: Poll task until complete
const result = await waitForShareTask(cookie, taskId, 20000);
if (!result?.shareId) {
lastError = result?.message || '任务超时';
console.error('[Quark] Wait for share task failed (type=%s):', st, result?.message || 'unknown');
continue;
}
// Step 3: Submit share via /password endpoint
const shareUrl = await submitShare(cookie, result.shareId, sharePwd);
if (shareUrl) {
return {
success: true,
shareUrl,
sharePwd,
message: `分享链接已生成(密码:${sharePwd}`,
};
}
lastError = '提交密码后未获取到短链接';
}
return { success: false, message: lastError || '🤷 各种姿势都试过了,就是分享不出来…' };
}
catch (err) {
console.error('[Quark] createShareLink error:', err.message);
return { success: false, message: err.message || '🌩️ 网络开小差了,再试试?' };
}
}
/**
* Submit share via /password endpoint to get the actual short URL.
*/
async function submitShare(cookie, shareId, sharePwd) {
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/share/password?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
signal: AbortSignal.timeout(15000),
});
const data = await response.json();
if (data.status === 200 && data.data?.share_url) {
console.log('[Quark] Share short URL:', data.data.share_url);
return data.data.share_url;
}
console.log('[Quark] /password response:', JSON.stringify(data).slice(0, 300));
console.error('[Quark] /password FAIL status=%s msg=%s', data.status, data.message || '');
return null;
}
catch (err) {
console.log('[Quark] /password error:', err);
return null;
}
}
/**
* Poll share task until complete and extract share URL/shortcode.
*/
async function waitForShareTask(cookie, taskId, timeoutMs) {
const start = Date.now();
let retryIndex = 0;
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.data?.status === 2) {
// Task completed — try multiple extraction approaches
// 1. Direct share_url field
if (data.data?.share_url) {
const match = data.data.share.match(/\/s\/([a-zA-Z0-9]+)/);
if (match)
return { shareId: match[1] };
}
// 2. Nested share object
if (data.data?.share?.url) {
const match = data.data.share.url.match(/\/s\/([a-zA-Z0-9]+)/);
if (match)
return { shareId: match[1] };
}
if (data.data?.share?.short_url) {
const match = data.data.share.short.match(/\/s\/([a-zA-Z0-9]+)/);
if (match)
return { shareId: match[1] };
}
// 3. share_id — validate it's a reasonable short code (8-20 chars, not UUID-like)
const shareId = data.data?.share_id;
if (shareId && shareId.length <= 20 && shareId.length >= 8) {
return { shareId };
}
// 4. Regex search through the full response for a URL pattern
const str = JSON.stringify(data);
const urlMatch = str.match(/https?:\/\/pan\.quark\.cn\/s\/([a-zA-Z0-9]{6,16})/);
if (urlMatch) {
return { shareId: urlMatch[1] };
}
// 5. Extract from any URL field in the response
const urlFields = ['url', 'link', 'share_url', 'short_url', 'share_link'];
for (const field of urlFields) {
const val = data.data?.[field] || data.data?.share?.[field];
if (typeof val === 'string' && val.includes('pan.quark.cn/s/')) {
const m = val.match(/\/s\/([a-zA-Z0-9]+)/);
if (m)
return { shareId: m[1] };
}
}
// 6. Log full share task response for debugging
console.log('[Quark] Full share task response:', JSON.stringify(data, null, 2).slice(0, 2000));
// 7. Even if shareId is UUID-like (32 hex chars), use it anyway as last resort
if (shareId) {
return { shareId };
}
return { message: 'Share task completed but no share URL found' };
}
if (data.data?.status === 3) {
return { message: data.message || 'Share task failed' };
}
retryIndex++;
}
catch {
// Retry
}
await new Promise(r => setTimeout(r, 1000));
}
return { message: 'Share task timed out' };
}
+15 -15
View File
@@ -1,6 +1,6 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as quark_share from "./quark-share";
import * as q from './quark-api';
import * as qs from './quark-share';
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
@@ -54,12 +54,12 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
return { success: false, message: 'Invalid share URL: could not extract share token' };
}
// Step 1: Acquire stoken
const stoken = await quark_share.acquireStoken(cookie, pwdId);
const stoken = await qs.acquireStoken(cookie, pwdId);
if (!stoken) {
return { success: false, message: '😅 Oops!资源好像偷偷溜走了,换个链接试试吧~' };
}
// Step 2: Get share detail
const shareInfo = await quark_share.getShareFiles(cookie, pwdId, stoken);
const shareInfo = await qs.getShareFiles(cookie, pwdId, stoken);
if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) {
return { success: false, message: '🌚 空的!这个分享里啥都没有…' };
}
@@ -68,8 +68,8 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
const fids = topFiles.map(f => f.fid);
const fidTokens = topFiles.map(f => f.share_fid_token);
// 按日期创建/查找文件夹,每天的转存存入当天文件夹
await quark_api.humanDelay();
const saveDirName = quark_api.dailyFolderName();
await q.humanDelay();
const saveDirName = q.dailyFolderName();
console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`);
const saveDirFid = await findOrCreateDir(cookie, saveDirName);
let targetPdirFid = saveDirFid || '0';
@@ -90,18 +90,18 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`);
}
// Step 3: Save top-level item(s) to the target directory
const saveResult = await quark_share.saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean), targetPdirFid);
const saveResult = await qs.saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean), targetPdirFid);
if (!saveResult.success) {
return saveResult;
}
const taskId = saveResult.taskId;
// Step 4: Wait for save task to complete (poll up to 30s)
const savedFids = await quark_share.waitForTask(cookie, taskId, 30000);
const savedFids = await qs.waitForTask(cookie, taskId, 30000);
if (!savedFids || savedFids.length === 0) {
return { success: true, message: '文件已保存,但获取保存结果超时' };
}
// Step 5: Magic rename files — with random delay to avoid detection
await quark_api.humanDelay();
await q.humanDelay();
const renamed = [];
let shareFid = '';
let savedFolderName = '';
@@ -122,7 +122,7 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
console.log(`[Quark] Retry: sharing subfolder (fid: ${retrySubFolderFid}) instead of saved content`);
}
// Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared
await quark_api.humanDelay();
await q.humanDelay();
let shareUrlResult = '';
let sharePwdResult = '';
let shareMsg = '';
@@ -146,7 +146,7 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
// ── Single folder share ──
const savedDirFid = savedFids[0];
// List files inside the saved directory
const dirFiles = await quark_api.listDir(cookie, savedDirFid);
const dirFiles = await q.listDir(cookie, savedDirFid);
if (dirFiles && dirFiles.length > 0) {
for (const file of dirFiles) {
if (file.dir)
@@ -255,9 +255,9 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
*/
export async function createDir(cookie, dirName, parentFid = '0') {
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file?${quark_api.makeQuery()}`, {
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.FILE + '?'${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
pdir_fid: parentFid,
file_name: dirName,
@@ -284,7 +284,7 @@ export async function createDir(cookie, dirName, parentFid = '0') {
*/
export async function findOrCreateDir(cookie, dirName, parentFid = '0') {
try {
const rootFiles = await quark_api.listDirAllPages(cookie, parentFid);
const rootFiles = await q.listDirAllPages(cookie, parentFid);
const existing = rootFiles.find(f => f.dir && f.file_name === dirName);
if (existing?.fid) {
console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`);
@@ -313,7 +313,7 @@ export async function countRecursive(cookie, pdirFid) {
if (visited.has(fid))
continue;
visited.add(fid);
const files = await quark_api.listDir(cookie, fid);
const files = await q.listDir(cookie, fid);
if (!files)
continue;
for (const f of files) {
@@ -0,0 +1,330 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as quark_share from "./quark-share";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
/**
* 转存 & 存储管理模块。
* 处理分享链接解析 → 转存 → 查/创建目标文件夹 → 文件重命名 → 递归统计。
*/
// ==================== saveFromShare — 核心转存流水线 ====================
/**
* Save files from a share link → magic rename → create shared link.
*
* Flow: token → detail → save → wait_task → rename → share
*/
export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, retrySave = false) {
try {
// Parse share token from URL
const urlObj = new URL(shareUrl);
const pwdId = urlObj.pathname.split('/').filter(Boolean).pop();
if (!pwdId) {
return { success: false, message: 'Invalid share URL: could not extract share token' };
}
// Step 1: Acquire stoken
const stoken = await quark_share.acquireStoken(cookie, pwdId);
if (!stoken) {
return { success: false, message: '😅 Oops!资源好像偷偷溜走了,换个链接试试吧~' };
}
// Step 2: Get share detail
const shareInfo = await quark_share.getShareFiles(cookie, pwdId, stoken);
if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) {
return { success: false, message: '🌚 空的!这个分享里啥都没有…' };
}
const { files: topFiles, topDir, childFiles } = shareInfo;
const originalFolderName = topFiles[0]?.file_name || '';
const fids = topFiles.map(f => f.fid);
const fidTokens = topFiles.map(f => f.share_fid_token);
// 按日期创建/查找文件夹,每天的转存存入当天文件夹
await quark_api.humanDelay();
const saveDirName = quark_api.dailyFolderName();
console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`);
const saveDirFid = await findOrCreateDir(cookie, saveDirName);
let targetPdirFid = saveDirFid || '0';
let retrySubFolderFid = '';
if (saveDirFid) {
console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`);
if (retrySave) {
const subName = 'retry_' + Math.random().toString(36).slice(2, 6);
console.log(`[Quark] Retry: creating subfolder "${subName}" inside "${saveDirName}"`);
retrySubFolderFid = await findOrCreateDir(cookie, subName, saveDirFid);
if (retrySubFolderFid) {
targetPdirFid = retrySubFolderFid;
console.log(`[Quark] Retry: saving to subfolder ${subName} (fid: ${retrySubFolderFid})`);
}
}
}
else {
console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`);
}
// Step 3: Save top-level item(s) to the target directory
const saveResult = await quark_share.saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean), targetPdirFid);
if (!saveResult.success) {
return saveResult;
}
const taskId = saveResult.taskId;
// Step 4: Wait for save task to complete (poll up to 30s)
const savedFids = await quark_share.waitForTask(cookie, taskId, 30000);
if (!savedFids || savedFids.length === 0) {
return { success: true, message: '文件已保存,但获取保存结果超时' };
}
// Step 5: Magic rename files — with random delay to avoid detection
await quark_api.humanDelay();
const renamed = [];
let shareFid = '';
let savedFolderName = '';
let newInnerDirName = '';
if (topDir && childFiles && childFiles.length > 0) {
// ── Single folder share ──
const savedDirFid = savedFids[0];
shareFid = savedDirFid;
savedFolderName = topFiles[0]?.file_name || '';
}
else {
// ── Multiple files at top level ──
shareFid = savedFids[0];
savedFolderName = topFiles[0]?.file_name || '';
}
if (retrySave && retrySubFolderFid) {
shareFid = retrySubFolderFid;
console.log(`[Quark] Retry: sharing subfolder (fid: ${retrySubFolderFid}) instead of saved content`);
}
// Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared
await quark_api.humanDelay();
let shareUrlResult = '';
let sharePwdResult = '';
let shareMsg = '';
let successCount = 0; // total items (files + folders) actually saved
const { createShareLink } = await import('./quark-share');
if (shareFid) {
const shareResult = await createShareLink(cookie, shareFid);
if (shareResult.success && shareResult.shareUrl) {
shareUrlResult = shareResult.shareUrl;
if (shareResult.sharePwd)
sharePwdResult = shareResult.sharePwd;
}
else {
shareMsg = `(分享失败:${shareResult.message}`;
}
}
const { magicRenameDir, magicRename } = await import('./quark-rename');
const { renameFile } = await import('./quark-share');
// Step 7: Rename files AFTER creating the share link (anti-harmony, won't affect the share)
if (topDir && childFiles && childFiles.length > 0) {
// ── Single folder share ──
const savedDirFid = savedFids[0];
// List files inside the saved directory
const dirFiles = await quark_api.listDir(cookie, savedDirFid);
if (dirFiles && dirFiles.length > 0) {
for (const file of dirFiles) {
if (file.dir)
continue;
const newName = magicRename(file.file_name);
const renameOk = await renameFile(cookie, file.fid, newName);
if (renameOk) {
renamed.push({ original: file.file_name, renamed: newName });
}
}
}
// Also rename the inner folder itself (the actual shared folder)
const innerDirOriginalName = sourceTitle || topFiles[0]?.file_name || '';
if (innerDirOriginalName) {
newInnerDirName = magicRenameDir(innerDirOriginalName);
const innerDirRenameOk = await renameFile(cookie, savedDirFid, newInnerDirName);
if (innerDirRenameOk) {
console.log(`[Quark] Renamed inner folder: ${innerDirOriginalName} → ${newInnerDirName}`);
}
}
}
else {
// ── Multiple files at top level ──
for (let i = 0; i < savedFids.length && i < topFiles.length; i++) {
const originalName = topFiles[i].file_name;
if (topFiles[i].dir)
continue;
const newName = magicRename(originalName);
const renameOk = await renameFile(cookie, savedFids[i], newName);
if (renameOk) {
renamed.push({ original: originalName, renamed: newName });
}
}
}
// Step 7.5: 广告关键词清理 + 创建警示文件夹
if (shareFid) {
try {
const { runAdCleanup } = await import('./quark-ad-cleanup');
const adResult = await runAdCleanup(cookie, shareFid);
if (adResult.adDeleted > 0) {
console.log(`[Quark] 广告清理完成: 删除了 ${adResult.adDeleted} 个广告文件/文件夹`);
}
if (adResult.warningDirs > 0) {
console.log(`[Quark] 已创建 ${adResult.warningDirs} 个警示文件夹`);
}
}
catch (err) {
console.log(`[Quark] 广告清理/警示文件夹创建失败(非致命): ${err.message}`);
}
}
// Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03")
// DO NOT rename the date folder — it serves as the organizational container.
savedFolderName = newInnerDirName ? `${saveDirName}/${newInnerDirName}` : saveDirName;
// Recursively count files and folders from saved cloud directory
let fileCount = 0;
let folderCount = 0;
if (shareFid) {
try {
const counts = await countRecursive(cookie, shareFid);
fileCount = counts.fileCount;
folderCount = counts.folderCount;
}
catch {
console.log('[Quark] Recursive count failed, using fallback');
}
}
// If recursive count returned nothing, try fallback
if (fileCount === 0 && folderCount === 0) {
if (topDir && childFiles) {
folderCount = 1 + childFiles.filter(f => f.dir).length;
fileCount = childFiles.filter(f => !f.dir).length;
}
else {
folderCount = topFiles.filter(f => f.dir).length;
fileCount = topFiles.filter(f => !f.dir).length;
}
}
// Calculate total file size
const allFiles = topDir && childFiles ? childFiles : topFiles;
const fileSize = allFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
const renameMsg = renamed.length > 0
? `,已重命名 ${renamed.length} 个文件`
: '';
const folderMsg = savedFolderName ? `到文件夹「${savedFolderName}」` : '';
return {
success: true,
message: `已保存${folderMsg}${renameMsg}${shareMsg}`,
shareUrl: shareUrlResult || undefined,
sharePwd: sharePwdResult || undefined,
folderName: savedFolderName,
taskId,
renamed: renamed.map(r => `${r.original} → ${r.renamed}`),
fileCount,
folderCount,
fileSize,
originalFolderName,
};
}
catch (err) {
return { success: false, message: err.message || 'Network error' };
}
}
// ==================== Dir Management ====================
/**
* Create a new directory at root.
*/
export async function createDir(cookie, dirName, parentFid = '0') {
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
pdir_fid: parentFid,
file_name: dirName,
dir: true,
dir_path: '',
}),
signal: AbortSignal.timeout(10000),
});
const data = await resp.json();
if (data.status === 200 && data.data?.fid) {
console.log(`[Quark] Created dir "${dirName}" (fid: ${data.data.fid})`);
return data.data.fid;
}
console.log(`[Quark] createDir API returned non-200: status=${data.status} msg=${data.message}`);
return null;
}
catch (err) {
console.log(`[Quark] createDir error: ${err.message}`);
return null;
}
}
/**
* Find an existing directory by name, or create it if not found.
*/
export async function findOrCreateDir(cookie, dirName, parentFid = '0') {
try {
const rootFiles = await quark_api.listDirAllPages(cookie, parentFid);
const existing = rootFiles.find(f => f.dir && f.file_name === dirName);
if (existing?.fid) {
console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`);
return existing.fid;
}
console.log(`[Quark] Daily folder "${dirName}" not found, creating...`);
}
catch (err) {
console.log(`[Quark] findOrCreateDir list error: ${err.message}`);
}
const fid = await createDir(cookie, dirName, parentFid);
console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`);
return fid;
}
// ==================== Recursive Count ====================
/**
* Recursively count files and folders for a saved cloud directory.
*/
export async function countRecursive(cookie, pdirFid) {
let fileCount = 0;
let folderCount = 0;
const stack = [pdirFid];
const visited = new Set();
while (stack.length > 0) {
const fid = stack.pop();
if (visited.has(fid))
continue;
visited.add(fid);
const files = await quark_api.listDir(cookie, fid);
if (!files)
continue;
for (const f of files) {
if (f.dir) {
folderCount++;
stack.push(f.fid);
}
else {
fileCount++;
}
}
}
return { fileCount, folderCount };
}
+6 -5
View File
@@ -1,3 +1,4 @@
import { QUARK_PAN_HOST, QUARK_DRIVE_HOST, EP as QE } from './drivers/quark-api';
import { chromium, BrowserContext, Page } from 'playwright';
import jsQR from 'jsqr';
import { getDb } from '../database/database';
@@ -140,7 +141,7 @@ export async function startQrLogin(): Promise<{
try {
// Navigate to Quark login page (now the homepage itself has QR login)
await page.goto('https://pan.quark.cn/', {
await page.goto(QUARK_PAN_HOST + '/', {
waitUntil: 'commit',
timeout: 30000,
});
@@ -243,7 +244,7 @@ async function pollLoginStatus(session: QrSession) {
// Check URL change as alternative indicator
const url = session.page.url();
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) {
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== QUARK_PAN_HOST + '/' && url.length > 10) {
await checkAndCaptureCookies(session);
}
} catch (err: any) {
@@ -275,7 +276,7 @@ async function checkAndCaptureCookies(session: QrSession) {
session.cookieSnapshot = cookieStr;
try {
const resp = await session.page.evaluate(async () => {
const r = await fetch('https://pan.quark.cn/account/info', {
const r = await fetch(QUARK_PAN_HOST + QE.ACCOUNT_INFO, {
credentials: 'include',
});
return await r.text();
@@ -319,7 +320,7 @@ export async function getQrLoginStatus(sessionId: string): Promise<{
let nickname = '';
try {
const resp = await session.page.evaluate(async () => {
const r = await fetch('https://pan.quark.cn/account/info', {
const r = await fetch(QUARK_PAN_HOST + QE.ACCOUNT_INFO, {
credentials: 'include',
});
return await r.text();
@@ -334,7 +335,7 @@ export async function getQrLoginStatus(sessionId: string): Promise<{
try {
const capResp = await session.page.evaluate(async () => {
const r = await fetch(
'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc',
QUARK_PAN_HOST + QE.CAPACITY_DETAIL + '?pr=ucpro&fr=pc',
{ credentials: 'include' }
);
return await r.text();
+402
View File
@@ -0,0 +1,402 @@
import { chromium, BrowserContext, Page } from 'playwright';
import jsQR from 'jsqr';
import { getDb } from '../database/database';
import { escapeLike } from '../utils/time';
interface QrSession {
id: string;
browserContext: BrowserContext;
page: Page;
createdAt: number;
cookieSnapshot: string;
lastPollAt: number;
qrUrl: string;
status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error';
error?: string;
}
const SESSIONS = new Map<string, QrSession>();
const SESSION_TTL = 5 * 60 * 1000; // 5 minutes
const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks
import config from '../config';
import { formatBytes } from './drivers/quark-api';
const CHROMIUM_PATH = config.chromiumPath;
// Clean up old sessions periodically
setInterval(() => {
const now = Date.now();
for (const [id, session] of SESSIONS.entries()) {
if (now - session.createdAt > SESSION_TTL) {
cleanupSession(id);
}
}
}, 60000);
function cleanupSession(id: string) {
const session = SESSIONS.get(id);
if (session) {
try {
session.browserContext.close().catch(() => {});
} catch {}
SESSIONS.delete(id);
}
}
/**
* Extract QR code URL from the login page canvas using jsQR.
* The actual login QR code is Canvas #0 (anonymous, 177x177), NOT #react-qrcode-logo.
*/
async function extractQrUrl(page: Page): Promise<string> {
// Run inside Playwright's browser context (as a string to avoid Node TS type errors)
const raw = await page.evaluate(`(() => {
const canvases = document.querySelectorAll('canvas');
var results = [];
for (var i = 0; i < canvases.length; i++) {
try {
var c = canvases[i];
var ctx = c.getContext('2d');
if (!ctx) continue;
var imageData = ctx.getImageData(0, 0, c.width, c.height);
results.push({
index: i,
w: c.width,
h: c.height,
data: Array.from(imageData.data)
});
} catch(e) {}
}
return results;
})()`) as unknown as { index: number; w: number; h: number; data: number[] }[];
if (!raw || raw.length === 0) {
throw new Error('页面没有可用的 canvas');
}
// Try to decode each canvas, preferring the one with su.quark.cn URL
let bestUrl = '';
let bestResult: { index: number; w: number; h: number; data: number[] } | null = null;
for (const canvas of raw) {
const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h);
if (code && code.data) {
// If this is the login QR code (has su.quark.cn), use it immediately
if (code.data.includes('su.quark.cn')) {
return code.data;
}
// Otherwise keep it as fallback
if (!bestUrl) {
bestUrl = code.data;
bestResult = canvas;
}
}
}
if (bestUrl) {
return bestUrl;
}
throw new Error('无法解析二维码内容');
}
/**
* Start a QR code login session.
* Launches headless Chromium, navigates to Quark login page, extracts QR code URL.
*/
export async function startQrLogin(): Promise<{
sessionId: string;
qrUrl: string;
expiresIn: number;
}> {
// Clean up any existing expired sessions
for (const [id, session] of SESSIONS.entries()) {
if (Date.now() - session.createdAt > SESSION_TTL) {
cleanupSession(id);
}
}
const browser = await chromium.launch({
executablePath: CHROMIUM_PATH,
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--no-first-run',
'--no-zygote',
],
});
const browserContext = await browser.newContext({
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1280, height: 800 },
locale: 'zh-CN',
});
const page = await browserContext.newPage();
const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
try {
// Navigate to Quark login page (now the homepage itself has QR login)
await page.goto('https://pan.quark.cn/', {
waitUntil: 'commit',
timeout: 30000,
});
// Wait for the QR code canvas to appear
await page.waitForSelector('canvas', { timeout: 15000 });
// Extra wait for the QR code to fully render
await page.waitForTimeout(2000);
// Extract the QR code URL from the canvas
const qrUrl = await extractQrUrl(page);
// Take initial cookie snapshot
const cookies = await browserContext.cookies();
const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; ');
const session: QrSession = {
id: sessionId,
browserContext,
page,
createdAt: Date.now(),
cookieSnapshot,
lastPollAt: Date.now(),
qrUrl,
status: 'pending',
};
SESSIONS.set(sessionId, session);
// Start background polling for login detection
pollLoginStatus(session);
// Handle page navigation (like redirect after login)
page.on('framenavigated', async (frame) => {
if (frame === page.mainFrame()) {
const url = frame.url();
if (url === 'about:blank') {
await checkAndCaptureCookies(session);
}
}
});
// Handle popups/dialogs
page.on('popup', async (popup) => {
try {
await popup.waitForLoadState('networkidle', { timeout: 10000 });
await checkAndCaptureCookies(session);
} catch {}
});
return {
sessionId,
qrUrl,
expiresIn: SESSION_TTL / 1000,
};
} catch (err: any) {
// Clean up on failure
try { await browserContext.close(); } catch {}
try { browser.close().catch(() => {}); } catch {}
SESSIONS.delete(sessionId);
throw new Error(`启动扫码登录失败: ${err.message}`);
}
}
/**
* Poll login status in background.
* Checks cookies every COOKIE_CHECK_INTERVAL ms for new session tokens.
*/
async function pollLoginStatus(session: QrSession) {
const checkInterval = setInterval(async () => {
try {
const now = Date.now();
// Check if expired
if (now - session.createdAt > SESSION_TTL) {
clearInterval(checkInterval);
session.status = 'expired';
cleanupSession(session.id);
return;
}
session.lastPollAt = now;
// Check cookies
const cookies = await session.browserContext.cookies();
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
// Check for session cookies indicating login
const hasSessionCookie = cookies.some(
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
);
if (hasSessionCookie) {
session.cookieSnapshot = cookieStr;
session.status = 'logged_in';
clearInterval(checkInterval);
return;
}
// Check URL change as alternative indicator
const url = session.page.url();
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) {
await checkAndCaptureCookies(session);
}
} catch (err: any) {
// Page might have been closed
clearInterval(checkInterval);
}
}, COOKIE_CHECK_INTERVAL);
}
/**
* Check cookies after navigation/redirect and capture them if login succeeded.
*/
async function checkAndCaptureCookies(session: QrSession) {
try {
const cookies = await session.browserContext.cookies();
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
const hasSessionCookie = cookies.some(
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
);
if (hasSessionCookie) {
session.cookieSnapshot = cookieStr;
session.status = 'logged_in';
} else if (cookies.length > 3) {
const newCookies = cookies.filter(
c => !['ctoken', 'b-user-id', '__wpkreporterwid_'].includes(c.name)
);
if (newCookies.length > 0) {
session.cookieSnapshot = cookieStr;
try {
const resp = await session.page.evaluate(async () => {
const r = await fetch('https://pan.quark.cn/account/info', {
credentials: 'include',
});
return await r.text();
});
const data = JSON.parse(resp);
if (data?.data?.nickname) {
session.status = 'logged_in';
}
} catch {}
}
}
} catch {}
}
/**
* Get the login status for a session.
*/
export async function getQrLoginStatus(sessionId: string): Promise<{
status: string;
cookie?: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
autoUpdated?: boolean;
updatedConfigId?: number;
}> {
const session = SESSIONS.get(sessionId);
if (!session) {
return { status: 'expired' };
}
// Check if expired
if (Date.now() - session.createdAt > SESSION_TTL) {
session.status = 'expired';
cleanupSession(sessionId);
return { status: 'expired' };
}
if (session.status === 'logged_in') {
// Try to get nickname too
let nickname = '';
try {
const resp = await session.page.evaluate(async () => {
const r = await fetch('https://pan.quark.cn/account/info', {
credentials: 'include',
});
return await r.text();
});
const data = JSON.parse(resp);
nickname = data?.data?.nickname || '';
} catch {}
// Fetch capacity info from within the browser context (has full JS signing)
let storageTotal = '';
let storageUsed = '';
try {
const capResp = await session.page.evaluate(async () => {
const r = await fetch(
'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc',
{ credentials: 'include' }
);
return await r.text();
});
const capData = JSON.parse(capResp);
if (capData.status === 200 && capData.data?.capacity_summary) {
const summary = capData.data.capacity_summary;
const total = summary.sum_capacity || 0;
storageTotal = formatBytes(total);
storageUsed = '0 B'; // capacity/detail doesn't return used_size
}
} catch {}
// Build full cookie string including httpOnly cookies
const cookies = await session.browserContext.cookies();
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
// Extract __uid from cookie for duplicate detection
const uidMatch = cookieStr.match(/(?<!\\w)__uid=([a-f0-9-]+)/);
let autoUpdated = false;
let updatedConfigId: number | undefined;
if (uidMatch) {
const uid = uidMatch[1];
try {
const db = getDb();
const existing = db.prepare(
`SELECT id, nickname FROM cloud_configs WHERE cloud_type = 'quark' AND cookie LIKE ?`
).get(`%${escapeLike(uid)}%`) as { id: number; nickname: string } | undefined;
if (existing) {
// Same account → auto-update cookie with capacity info too
const localTimestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
db.prepare(
`UPDATE cloud_configs SET cookie = ?, storage_used = ?, storage_total = ?, updated_at = ? WHERE id = ?`
).run(cookieStr, storageUsed || null, storageTotal || null, localTimestamp, existing.id);
autoUpdated = true;
updatedConfigId = existing.id;
}
} catch {}
}
// Clean up session after successful login
cleanupSession(sessionId);
return {
status: 'logged_in',
cookie: cookieStr,
nickname,
storage_used: storageUsed,
storage_total: storageTotal,
autoUpdated,
updatedConfigId,
};
}
return { status: session.status };
}
/**
* Cancel a QR login session.
*/
export async function cancelQrLogin(sessionId: string): Promise<void> {
cleanupSession(sessionId);
}