diff --git a/VERSION b/VERSION index c2c0004..449d7e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.5 +0.3.6 diff --git a/source_clean/VERSION b/source_clean/VERSION index 0d91a54..449d7e7 100644 --- a/source_clean/VERSION +++ b/source_clean/VERSION @@ -1 +1 @@ -0.3.0 +0.3.6 diff --git a/source_clean/package-lock.json b/source_clean/package-lock.json index 995ffbc..49198cb 100644 --- a/source_clean/package-lock.json +++ b/source_clean/package-lock.json @@ -1,12 +1,12 @@ { "name": "cloudsearch-backend", - "version": "2.0.26", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cloudsearch-backend", - "version": "2.0.26", + "version": "0.0.0", "dependencies": { "bcryptjs": "^2.4.3", "better-sqlite3": "^11.0.0", diff --git a/source_clean/src/cloud/credential.service.ts b/source_clean/src/cloud/credential.service.ts index d0012ca..6d0cfa5 100644 --- a/source_clean/src/cloud/credential.service.ts +++ b/source_clean/src/cloud/credential.service.ts @@ -1,4 +1,5 @@ import { getDb } from '../database/database'; +import { encrypt, decrypt, isEncrypted } from '../utils/crypto'; import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time'; export interface CloudConfig { @@ -23,6 +24,12 @@ export interface CloudConfig { // ── Cookie UID Extraction ──────────────────────────────────────── function extractCookieUid(cookie: string): string { + +function decryptCookie(encrypted: string): string { + if (!encrypted) return ''; + if (!isEncrypted(encrypted)) return encrypted; + return decrypt(encrypted); +} if (!cookie) return ''; let m = cookie.match(/__uid=([a-zA-Z0-9+/=_-]+)/); if (m) return m[1]; @@ -56,23 +63,25 @@ export function getAvailableClouds(): CloudConfig[] { /** Returns the first active config matching the given cloud type. */ export function getCloudConfigByType(cloudType: string): CloudConfig | undefined { const db = getDb(); - return db.prepare( + const cfg = db.prepare( `SELECT id, cloud_type, cookie, nickname, is_active, 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; + return cfg; } export function getCloudConfigById(id: number): CloudConfig | undefined { const db = getDb(); - return db.prepare( + const cfg = db.prepare( `SELECT id, cloud_type, cookie, nickname, is_active, 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; + return cfg; } /** Returns all active cloud configs (used by save flow for cloud type switching). */ @@ -101,6 +110,7 @@ export function saveCloudConfig(data: { const db = getDb(); const cookieUidForUpdate = data.cookie ? extractCookieUid(data.cookie) : null; + const encryptedCookie = data.cookie ? encrypt(data.cookie) : null; if (data.id) { db.prepare( @@ -116,12 +126,12 @@ export function saveCloudConfig(data: { consecutive_failures = 0, updated_at = ? WHERE id = ?` - ).run(data.cloud_type, data.cookie || null, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.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) { + if (existing) { db.prepare( `UPDATE cloud_configs SET cookie = COALESCE(?, cookie), @@ -134,11 +144,11 @@ export function saveCloudConfig(data: { consecutive_failures = 0, updated_at = ? WHERE id = ?` - ).run(data.cookie || null, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.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, data.cookie || null, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null); + ).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); } } diff --git a/source_clean/src/cloud/drivers/quark-ad-cleanup.ts b/source_clean/src/cloud/drivers/quark-ad-cleanup.ts new file mode 100644 index 0000000..bd94a37 --- /dev/null +++ b/source_clean/src/cloud/drivers/quark-ad-cleanup.ts @@ -0,0 +1,230 @@ +// @ts-nocheck +import * as quark_api from "./quark-api"; +import * as system_config_service from "../../admin/system-config.service"; + +/** + * 广告关键词清理模块。 + * 在转存完成后执行: + * 1. 遍历转存的目录,删除文件名/文件夹名含广告关键词的内容 + * 2. 在转存根目录下创建警示文件夹(置顶提醒) + */ +// ==================== 配置读取 ==================== +/** 从 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); +} +/** 从 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); +} +/** 从 DB 读取可疑文件后缀列表 */ +export function getSusExtensions() { + 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 ["bat", "exe", "vbs", "scr", "cmd", "com", "pif", "js", "jar", "msi", "reg", "inf", "ps1"]; +} +// ==================== 关键词检测 ==================== +/** 检查文件名是否包含任意广告关键词 */ +export function containsAdKeyword(fileName, keywords) { + if (!keywords.length) + return false; + const lower = fileName.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; + let deletedCount = 0; + const stack = [dirFid]; + const visited = new Set(); + while (stack.length > 0) { + 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(); + for (const file of files) { + const ext = file.file.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); + } + } + } + // 批量删除 + if (toDelete.length > 0) { + const deleteOk = await batchDeleteFiles(cookie, toDelete); + if (deleteOk) { + deletedCount += toDelete.length; + console.log(`[Quark-AdCleanup] 已删除 ${toDelete.length} 个广告文件`); + } + } + } + return deletedCount; +} +/** + * 批量删除文件/文件夹(移入回收站)。 + */ +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, + exclude_filelist: [], + }), + 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)); + for (const name of dirNames) { + // 格式化名称:确保以 ⚠️ 开头 + let formattedName = name; + if (!formattedName.startsWith("⚠️") && !formattedName.startsWith("⚠")) { + formattedName = `⚠️ ${formattedName}`; + } + // 去掉多余空格 + formattedName = formattedName.replace(/\s+/g, " ").trim(); + if (existingDirs.has(formattedName)) { + console.log(`[Quark-AdCleanup] 警示文件夹已存在,跳过: "${formattedName}"`); + continue; + } + await createSingleDir(cookie, formattedName, parentDirFid); + // 加入已存在集合,防止同名重试 + 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(); + const susExtensions = getSusExtensions(); + 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} 个可疑后缀`); + adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords); + console.log(`[Quark-AdCleanup] 清理完成,共删除 ${adDeleted} 个文件/文件夹`); + } + else { + console.log("[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理"); + } + // 2. 创建警示文件夹 + if (warningNames.length > 0) { + console.log(`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length} 个`); + await createWarningDirectories(cookie, warningNames, savedDirFid); + warningDirs = warningNames.length; + console.log(`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`); + } + else { + console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建"); + } + return { adDeleted, warningDirs }; +} diff --git a/source_clean/src/cloud/drivers/quark-api.ts b/source_clean/src/cloud/drivers/quark-api.ts new file mode 100644 index 0000000..1d0231e --- /dev/null +++ b/source_clean/src/cloud/drivers/quark-api.ts @@ -0,0 +1,195 @@ +// @ts-nocheck +// ==================== Headers & Params ==================== +const BASE_URL = 'https://drive-pc.quark.cn'; +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', + }; +} +export function getCommonParams() { + return { pr: 'ucpro', fr: 'pc' }; +} +/** Generate query string with common params + random timing to mimic browser */ +export function makeQuery(extra = {}) { + const __dt = Math.floor(Math.random() * 240000 + 60000); + const __t = Date.now() / 1000; + return new URLSearchParams({ + ...getCommonParams(), + uc_param_str: '', + app: 'clouddrive', + __dt: String(__dt), + __t: String(__t), + ...extra, + }).toString(); +} +/** Random delay to mimic human behavior (500-2000ms) */ +export async function humanDelay() { + const ms = Math.floor(Math.random() * 1500) + 500; + await new Promise(r => setTimeout(r, ms)); +} +/** Generate a random password for share links */ +export function randomSharePwd() { + return Math.floor(1000 + Math.random() * 9000).toString(); +} +/** + * Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix). + */ +export function getMparam(cookie) { + // Match both __kps and kps (with or without __ prefix) + const kpsMatch = cookie.match(/__?kps=([a-zA-Z0-9%+/=]+)/); + const signMatch = cookie.match(/__?sign=([a-zA-Z0-9%+/=]+)/); + const vcodeMatch = cookie.match(/__?vcode=([a-zA-Z0-9%+/=]+)/); + if (kpsMatch && signMatch && vcodeMatch) { + return { + kps: kpsMatch[1], + sign: signMatch[1].replace(/%25/g, '%'), + vcode: vcodeMatch[1], + }; + } + return {}; +} +// ==================== Shared fetch helpers ==================== +/** + * Raw fetch wrapper with JSON parse + status check. + * Returns parsed JSON body on 2xx, null on network error. + */ +export async function apiFetch(path, options) { + const { method = 'GET', query, body, cookie, timeout = 10000 } = options; + let url = `${BASE_URL}${path}`; + if (query) + url += `?${new URLSearchParams(query).toString()}`; + try { + const resp = await fetch(url, { + method, + headers: { + ...getHeaders(cookie), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(timeout), + }); + if (!resp.ok) + return null; + return (await resp.json()); + } + 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({ + ...getCommonParams(), + uc_param_str: '', + pdir_fid: pdirFid, + _page: String(page), + _size: String(pageSize), + _fetch_total: '1', + _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + const resp = await fetch(`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) }); + if (!resp.ok) + return []; + const data = await resp.json(); + 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 { + return []; + } +} +/** + * List root directory (pdir_fid=0) — returns all top-level dirs/files. + */ +export async function listRootDir(cookie) { + try { + const params = new URLSearchParams({ + pr: 'ucpro', fr: 'pc', + pdir_fid: '0', + _page: '1', _size: '200', + _fetch_total: '1', _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + const resp = await fetch(`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) }); + if (!resp.ok) + return []; + const data = await resp.json(); + 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 { + 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; + const pageSize = 100; + let total = -1; + while (total === -1 || (page - 1) * pageSize < total) { + const files = await listDir(cookie, pdirFid, page, pageSize); + if (!files.length) + break; + allFiles.push(...files); + if (total === -1) { + total = files.length; + } + page++; + } + return allFiles; +} +// ==================== Format utilities ==================== +export function formatBytes(bytes) { + 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)]; + } + return name; +} diff --git a/source_clean/src/cloud/drivers/quark-auth.ts b/source_clean/src/cloud/drivers/quark-auth.ts new file mode 100644 index 0000000..ab86aba --- /dev/null +++ b/source_clean/src/cloud/drivers/quark-auth.ts @@ -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; + } +} diff --git a/source_clean/src/cloud/drivers/quark-cleanup.ts b/source_clean/src/cloud/drivers/quark-cleanup.ts new file mode 100644 index 0000000..14c6c01 --- /dev/null +++ b/source_clean/src/cloud/drivers/quark-cleanup.ts @@ -0,0 +1,326 @@ +// @ts-nocheck +import * as quark_api from "./quark-api"; + +/** + * 容量信息 & 空间清理模块。 + */ +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. + */ +export async function getStorageInfoQuick(cookie, fallbackTotal) { + try { + const params = new URLSearchParams(quark_api.getCommonParams()); + const capResponse = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, { + headers: quark_api.getHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + let totalBytes = 0; + if (capResponse.ok) { + const data = await capResponse.json(); + if (data.status === 200 && data.data) { + totalBytes = data.data.capacity_summary?.sum_capacity || 0; + if (totalBytes === 0) { + const memberships = [...(data.data.effect || []), ...(data.data.expired || [])]; + totalBytes = memberships.reduce((max, m) => Math.max(max, m.capacity || 0), 0); + } + } + } + // Accurate used space via /member API (1 call, no full traversal needed) + // Ref: pan.quark.cn/1/clouddrive/member returns use_capacity + total_capacity + let usedBytes = 0; + try { + const memberParams = new URLSearchParams({ pr: 'ucpro', fr: 'pc', uc_param_str: '', __t: String(Date.now()), __dt: '1000' }); + const memberResp = await fetch(`https://pan.quark.cn/1/clouddrive/member?${memberParams.toString()}`, { + headers: quark_api.getHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + if (memberResp.ok) { + const memberData = await memberResp.json(); + if (memberData.status === 200 && memberData.data?.use_capacity != null) { + usedBytes = memberData.data.use_capacity; + } + } + } + catch { } + // Fallback: sum root-level file sizes (夸克 folders return size=0) + if (usedBytes === 0) { + try { + const rootFiles = await quark_api.listRootDir(cookie); + for (const f of rootFiles) { + usedBytes += f.size || 0; + } + } + catch { } + } + // Cache the result (3h window) + const currentHourBlock = Math.floor(new Date().getHours() / 3); + storageCache.bytes = usedBytes; + storageCache.hourBlock = currentHourBlock; + if (totalBytes > 0) { + return { + total: quark_api.formatBytes(totalBytes), + totalBytes, + used: quark_api.formatBytes(usedBytes), + usedBytes, + }; + } + } + catch { } + // Fallback: try to parse from a human-readable string like "6 TB" + if (fallbackTotal) { + const match = fallbackTotal.match(/^([\d.]+)\s*([KMGT]B?)/i); + if (match) { + const num = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + const multipliers = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, PB: 1024 ** 5 }; + const multiplier = multipliers[unit] || multipliers[unit.replace('B', '') + 'B'] || 0; + if (multiplier > 0) { + return { total: fallbackTotal, totalBytes: Math.round(num * multiplier), 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. + */ +export async function getStorageInfo(cookie, onBackgroundComplete) { + try { + const params = new URLSearchParams(quark_api.getCommonParams()); + const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, { + headers: quark_api.getHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + let totalBytes = 0; + if (response.ok) { + const data = await response.json(); + if (data.status === 200 && data.data) { + totalBytes = data.data.capacity_summary?.sum_capacity || 0; + if (totalBytes === 0) { + const memberships = [...(data.data.effect || []), ...(data.data.expired || [])]; + totalBytes = memberships.reduce((max, m) => Math.max(max, m.capacity || 0), 0); + } + } + } + const totalFormatted = totalBytes > 0 ? quark_api.formatBytes(totalBytes) : '-'; + // Quick estimation: sum root-level files only + let quickUsed = 0; + try { + const rootFiles = await quark_api.listRootDir(cookie); + for (const f of rootFiles) { + quickUsed += f.size || 0; + } + } + catch { } + // Budget full traversal in background (no await) + calculateUsedSpace(cookie).then(fullUsed => { + if (onBackgroundComplete) { + onBackgroundComplete(quark_api.formatBytes(fullUsed), totalFormatted); + } + }).catch(err => { + console.error('[Storage] Background full traversal failed:', err.message); + }); + return { + total: totalFormatted, + totalBytes, + used: quark_api.formatBytes(quickUsed), + usedBytes: quickUsed, + }; + } + catch { + return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 }; + } +} +/** + * Calculate total used space by recursively traversing all files + * and summing their sizes. Uses 3-hour time window cache. + */ +export async function calculateUsedSpace(cookie) { + const currentHourBlock = Math.floor(new Date().getHours() / 3); + if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) { + return storageCache.bytes; + } + let totalUsed = 0; + const stack = ['0']; + 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.listDirAllPages(cookie, fid); + if (!files.length) + continue; + for (const f of files) { + if (f.dir) { + stack.push(f.fid); + } + else { + totalUsed += f.size || 0; + } + } + await new Promise(r => setTimeout(r, 50)); + } + storageCache.bytes = totalUsed; + 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; + } +} +/** + * 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; + } +} +/** + * Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`. + */ +export async function cleanupOldDateFolders(cookie, days, whitelistDirs) { + const errors = []; + 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 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; + return item.file_name < cutoffStr; + }); + 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(', ')}`); + 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) { + 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. + */ +export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePercent, whitelistDirs) { + const errors = []; + try { + const storage = await getStorageInfo(cookie); + 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`); + return { trashed: 0, errors: [] }; + } + const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100); + const rootItems = await quark_api.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: [] }; + const hasSizes = dateFolders.some(f => f.size && f.size > 0); + let cumulativeSize = 0; + const foldersToTrash = []; + if (hasSizes) { + for (const folder of dateFolders) { + foldersToTrash.push(folder); + cumulativeSize += folder.size || 0; + if (cumulativeSize >= targetBytesToFree) + break; + } + } + else { + const avgSizePerFolder = storage.usedBytes / dateFolders.length; + const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder)); + foldersToTrash.push(...dateFolders.slice(0, estCount)); + cumulativeSize = estCount * avgSizePerFolder; + } + 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 ok = await trashFiles(cookie, fidsToTrash); + if (ok) { + console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${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: [err.message] }; + } +} diff --git a/source_clean/src/cloud/drivers/quark-rename.ts b/source_clean/src/cloud/drivers/quark-rename.ts new file mode 100644 index 0000000..7741df0 --- /dev/null +++ b/source_clean/src/cloud/drivers/quark-rename.ts @@ -0,0 +1,202 @@ +// @ts-nocheck +const NOISE_CJK = '的了在是不有会可对所之也同与及但或如且乃而岂乎焉兮哉亦犹尚乃其若故盖诸焉欤' + + '么个着过把对为从以到说时要就这那和上人家下能出得发来年心开物力些长样吧啊哦嗯嚯哇咯呗哟嘿呵哈'; +// ==================== Helpers ==================== +/** Convert Chinese text to homophonic (substitute chars with same sound) */ +function homophonicText(text) { + let result = ''; + for (const ch of text) { + if (/[\u4e00-\u9fff]/.test(ch)) { + const homophone = HOMOPHONE_MAP[ch]; + result += homophone || ch; + } + else { + result += ch; + } + } + return result; +} +/** Convert Chinese text to pinyin-initial-like string (each char → first pinyin letter or fallback) */ +function pinyinLike(text) { + let result = ''; + for (const ch of text) { + if (/[\u4e00-\u9fff]/.test(ch)) { + const homophone = HOMOPHONE_MAP[ch]; + if (homophone) { + result += pinyinInitial(homophone); + } + else { + const code = ch.charCodeAt(0); + result += String.fromCharCode(97 + (code % 26)); + } + } + else if (/[a-zA-Z0-9]/.test(ch)) { + result += ch; + } + else if (/[\s._-]/.test(ch)) { + result += '_'; + } + } + return result.replace(/_+/g, '_').replace(/^_|_$/g, ''); +} +/** Get pinyin initial (first letter of pinyin) for a Chinese character */ +function pinyinInitial(ch) { + const code = ch.charCodeAt(0); + if (code >= 0x4E00 && code <= 0x9FFF) { + const initials = ['b', 'p', 'm', 'f', 'd', 't', 'n', 'l', 'g', 'k', 'h', 'j', 'q', 'x', 'zh', 'ch', 'sh', 'r', 'z', 'c', 's', 'y', 'w']; + const idx = Math.min(Math.floor((code - 0x4E00) / 700), initials.length - 1); + return initials[idx]; + } + return ch.toLowerCase(); +} +// ==================== Public API ==================== +/** + * Anti-harmony rename for directories. + * 80%: light homophonic replacement, 20%: partial pinyin. + */ +export function magicRenameDir(dirName) { + const hash = crypto.createHash('md5').update(dirName + Date.now()).digest('hex').slice(0, 4); + let cleanName = dirName.trim().replace(/\s+/g, ' '); + if (!cleanName) { + return `media_${hash}`; + } + let baseName; + if (Math.random() < 0.2) { + // Partial pinyin: 30% of CJK chars → pinyin initial, 70% stay as-is + const chars = [...cleanName]; + const result = []; + for (const ch of chars) { + if (/[\u4e00-\u9fff]/.test(ch) && Math.random() < 0.3) { + result.push(pinyinInitial(ch)); + } + else { + result.push(ch); + } + } + baseName = result.join(''); + } + else { + // Light homophonic: replace each CJK char, keep everything else as-is + const chars = [...cleanName]; + const result = []; + for (const ch of chars) { + if (/[\u4e00-\u9fff]/.test(ch)) { + result.push(HOMOPHONE_MAP[ch] || ch); + } + else { + result.push(ch); + } + } + baseName = result.join(''); + // Optional: insert 0-2 light noise chars (low probability) + const noiseCount = Math.random() < 0.3 ? (Math.random() < 0.5 ? 1 : 2) : 0; + for (let n = 0; n < noiseCount; n++) { + const pos = Math.floor(Math.random() * (baseName.length + 1)); + const ink = NOISE_CJK[Math.floor(Math.random() * NOISE.length)]; + baseName = baseName.slice(0, pos) + ink + baseName.slice(pos); + } + } + baseName = baseName.replace(/[^\u4e00-\u9fff\w]/g, '_'); + baseName = baseName.replace(/_+/g, '_').replace(/^_|_$/g, ''); + if (baseName.length > 30) + baseName = baseName.slice(0, 30); + return `${baseName}_${hash}`; +} +/** + * Anti-harmony rename for files. + * KEEPS: episode numbers, quality, language tags, original extension. + * REPLACES: Chinese title with homophonic/pinyin. + */ +export function magicRename(filename) { + const hash = crypto.createHash('md5').update(filename + Date.now()).digest('hex').slice(0, 8); + let ext = ''; + const extMatch = filename.match(/\.[a-zA-Z0-9]+$/); + if (extMatch) { + ext = extMatch[0]; + filename = filename.slice(0, -ext.length); + } + // Extract and REMEMBER: episode info, quality, language, year + const episodePatterns = [ + { regex: /第\s*(\d+)\s*[集话話話話话回章期]/, format: (m) => 'Ep' + m.replace(/[^\d]/g, '') }, + { regex: /Ep\d+|ep\d+/i, format: (m) => m.toUpperCase() }, + { regex: /Part\s*\d+/i, format: (m) => m.replace(/\s+/g, '') }, + { regex: /E\d{2,}/i, format: (m) => m.toUpperCase() }, + ]; + let episodeTag = ''; + for (const { regex, format } of episodePatterns) { + const m = filename.match(regex); + if (m) { + episodeTag = format(m[0]); + filename = filename.replace(m[0], ''); + break; + } + } + // Extract and REMEMBER: quality tags + const qualityPattern = /\b(4k|1080p|1080P|2160p|720p|HD|BluRay|Blu-ray|HDR|WEB-DL|WEBRip|BDRip|REMUX|DV|Dovi|HEVC|x264|x265|H\.264|H\.265)\b/; + const qualityMatch = filename.match(qualityPattern); + const qualityTag = qualityMatch ? qualityMatch[0] : ''; + if (qualityMatch) + filename = filename.replace(qualityMatch[0], ''); + // Extract and REMEMBER: language tags + const langPattern = /\b(CHS|CHT|JP|EN|BIG5|GB|粤语|国语|日语|英语|中字|日字|英字|繁体中字)\b/; + const langMatch = filename.match(langPattern); + const langTag = langMatch ? langMatch[0] : ''; + if (langMatch) + filename = filename.replace(langMatch[0], ''); + // Extract and REMEMBER: year + const yearMatch = filename.match(/\b(20\d{2})\b/); + const yearTag = yearMatch ? yearMatch[0] : ''; + if (yearMatch) + filename = filename.replace(yearMatch[0], ''); + // Extract and REMEMBER: season info + const seasonMatch = filename.match(/第?\s*(\d+)\s*[季部期]/); + const seasonTag = seasonMatch ? `${seasonMatch[1]}季` : ''; + if (seasonMatch) + filename = filename.replace(seasonMatch[0], ''); + // Now process the remaining name (mostly Chinese title) + filename = filename.replace(/[._\-【】\[\]()()\s]+/g, '_').trim(); + const useHomophonic = Math.random() > 0.5; + let titlePart; + if (useHomophonic) { + titlePart = homophonicText(filename); + titlePart = titlePart.replace(/[^\u4e00-\u9fff\wa-zA-Z0-9]/g, '_'); + titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); + if (titlePart.length > 15) + titlePart = titlePart.slice(0, 15); + } + else { + titlePart = pinyinLike(filename); + titlePart = titlePart.replace(/[^a-zA-Z0-9]/g, '_'); + titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); + if (titlePart.length > 15) + titlePart = titlePart.slice(0, 15); + } + // Remove sensitive keywords from title part + const sensitiveWords = /斗破|完美|凡人|仙逆|遮天|吞噬|大主宰|绝世|武动|星辰变|一念永恒|修罗|神墓|长生|剑来|诡秘|全职|斗罗|盘龙|雪鹰|莽荒纪|天珠变|神印王座|牧神记|沧元图|紫川|百炼成神|大王饶命|全球高考/ig; + titlePart = titlePart.replace(sensitiveWords, ''); + titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); + // Build preserved tags + const tags = []; + if (seasonTag) + tags.push(seasonTag); + if (episodeTag) + tags.push(episodeTag); + if (qualityTag) + tags.push(qualityTag.toUpperCase()); + if (langTag) + tags.push(langTag); + if (yearTag) + tags.push(yearTag); + tags.push(hash); // Always add hash for uniqueness + const newExt = ext || '.bin'; + const parts = [titlePart, ...tags].filter(Boolean); + let result = parts.join('_'); + if (result.length > 80) { + result = result.slice(0, 80); + } + if (result.length < 10) { + const filler = crypto.randomBytes(4).toString('hex'); + result = `${filler}_${result}`; + } + return result + newExt; +} diff --git a/source_clean/src/cloud/drivers/quark-share.ts b/source_clean/src/cloud/drivers/quark-share.ts new file mode 100644 index 0000000..c146ee7 --- /dev/null +++ b/source_clean/src/cloud/drivers/quark-share.ts @@ -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' }; +} diff --git a/source_clean/src/cloud/drivers/quark-storage.ts b/source_clean/src/cloud/drivers/quark-storage.ts new file mode 100644 index 0000000..26298e0 --- /dev/null +++ b/source_clean/src/cloud/drivers/quark-storage.ts @@ -0,0 +1,316 @@ +// @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) { + 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); + const targetPdirFid = saveDirFid || '0'; + if (saveDirFid) { + console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`); + } + 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 || ''; + } + // 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) { + 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: '0', + 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) { + try { + const rootFiles = await quark_api.listDirAllPages(cookie, '0'); + 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); + 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 }; +} diff --git a/source_clean/src/cloud/drivers/quark.driver.ts b/source_clean/src/cloud/drivers/quark.driver.ts index 9ed3808..1243983 100755 --- a/source_clean/src/cloud/drivers/quark.driver.ts +++ b/source_clean/src/cloud/drivers/quark.driver.ts @@ -1,5 +1,57 @@ -// Native fetch available in Node 20+ -import * as crypto from 'crypto'; +/** + * QuarkDriver — 夸克网盘统一驱动(向后兼容壳子) + * + * 所有方法委托到子模块(纯函数,不持有状态)。 + * 新代码应直接导入子模块函数。 + * + * 模块结构: + * quark-api.ts — HTTP 封装、headers、params、共享工具函数 + * quark-auth.ts — Cookie 验证 + * quark-storage.ts — 转存流水线、目录管理、递归统计 + * quark-share.ts — 分享链接解析、转存任务、创建分享链接 + * quark-rename.ts — 防和谐重命名(文件名/目录名) + * quark-cleanup.ts — 容量信息、空间清理 + * quark-ad-cleanup.ts — 广告清理 + * + * This file (quark.driver.ts) is kept for backward compatibility. + * All sub-module exports are re-exported from here. + */ +import { validate, fetchNickname } from './quark-auth'; +import { + saveFromShare, + createDir, + findOrCreateDir, + countRecursive, +} from './quark-storage'; +import { + acquireStoken, + createShareLink, + getDetailAt, + getShareFiles, + renameFile, + saveFiles, + waitForTask, +} from './quark-share'; +import { + calculateUsedSpace, + cleanupBySpaceThreshold, + cleanupOldDateFolders, + emptyTrash, + getStorageInfo, + getStorageInfoQuick, + trashFiles, +} from './quark-cleanup'; +import { listRootDir } from './quark-api'; + +// Re-export all sub-module functions +export * from './quark-api'; +export * from './quark-auth'; +export * from './quark-storage'; +export * from './quark-share'; +export * from './quark-rename'; +export * from './quark-cleanup'; +export * from './quark-ad-cleanup'; +export { validate }; export interface QuarkConfig { cookie: string; @@ -21,1513 +73,87 @@ interface ShareFile { size?: number; } +/** + * QuarkDriver — 向后兼容的驱动类。 + * 所有方法委托到纯函数模块。 + */ export class QuarkDriver { private config: QuarkConfig; - private baseUrl = 'https://drive-pc.quark.cn'; - private cachedUsedSpace: { bytes: number; hourBlock: number } | null = null; constructor(config: QuarkConfig) { this.config = config; } - private getHeaders(): Record { - 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': this.config.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', - }; + get cookie(): string { + return this.config.cookie; } - private getCommonParams(): Record { - return { pr: 'ucpro', fr: 'pc' }; - } - - /** Random delay to mimic human behavior (500-2000ms) */ - private async humanDelay(): Promise { - const ms = Math.floor(Math.random() * 1500) + 500; - await new Promise(r => setTimeout(r, ms)); - } - - /** Generate a random password for share links */ - private randomSharePwd(): string { - return Math.floor(1000 + Math.random() * 9000).toString(); - } - - /** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */ - private dailyFolderName(): string { - const d = new Date(); - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; - } - - /** Generate a random folder name for saving (fallback) */ - private randomFolderName(): string { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let name = ''; - for (let i = 0; i < 12; i++) { - name += chars[Math.floor(Math.random() * chars.length)]; - } - return name; - } - - /** Generate query string with common params + random timing to mimic browser */ - private makeQuery(extra: Record = {}): string { - const __dt = Math.floor(Math.random() * 240000 + 60000); - const __t = Date.now() / 1000; - return new URLSearchParams({ - ...this.getCommonParams(), - uc_param_str: '', - app: 'clouddrive', - __dt: String(__dt), - __t: String(__t), - ...extra, - }).toString(); - } - - /** - * Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix). - */ - private getMparam(): { kps?: string; sign?: string; vcode?: string } { - const cookie = this.config.cookie; - const kpsMatch = cookie.match(/(? = { - // 网盘热门番名 — 谐音替换 (same sound, different char) - '斗':'陡','破':'坡','苍':'仓','穹':'穷', - '完':'玩','美':'每','世':'士','界':'介', - '凡':'烦','人':'仁','修':'休','罗':'络', - '仙':'先','逆':'腻','遮':'折','天':'添', - '吞':'屯','噬':'逝','大':'达','主':'嘱','宰':'崽', - '星':'惺','辰':'晨','变':'便','一':'伊','念':'捻', - '永':'泳','恒':'横','神':'申','墓':'暮','长':'尝','生':'甥', - '剑':'箭','来':'莱','诡':'鬼','秘':'蜜', - '全':'泉','职':'值','盘':'磐','龙':'笼', - '雪':'血','鹰':'莺','莽':'蟒','荒':'慌','纪':'记', - '珠':'株','王':'亡','座':'坐','牧':'木','记':'计', - '沧':'舱','元':'圆','图':'涂','紫':'仔','川':'串', - '百':'白','炼':'恋','成':'程','饶':'绕','命':'冥', - // 通用谐音替换 - '的':'得','了':'啦','是':'事','不':'布','我':'窝', - '你':'尼','他':'她','有':'友','和':'合','与':'予', - '上':'尚','下':'夏','中':'忠','第':'弟','集':'级', - '话':'划','季':'际','年':'念','月':'阅','日':'曰', - '新':'心','版':'板','高':'糕','清':'青','原':'源', - '小':'晓','片':'篇','视':'市','频':'贫','道':'到', - '动':'洞','画':'话','声':'升','音':'因','文':'闻', - '明':'名','暗':'黯','光':'广','影':'映','色':'瑟', - '风':'疯','雨':'语','花':'华','国':'果','家':'佳', - '战':'站','争':'挣','士':'仕','兵':'宾', - '皇':'惶','帝':'谛','魔':'磨','鬼':'诡','怪':'乖', - '精':'经','灵':'铃','妖':'夭','武':'舞','侠':'狭', - '杀':'刹','血':'雪','刀':'叨','枪':'呛','炮':'泡', - '时':'石','空':'孔','前':'钱','后':'厚','东':'冬', - '南':'难','西':'夕','北':'备','开':'凯','关':'官', - '出':'初','进':'近','去':'趣', - '短':'短','多':'多','少':'少','真':'贞','假':'价', - '好':'郝','坏':'怀','对':'队','错':'措','以':'已', - '从':'从','被':'被','把':'把','将':'将','在':'在', - '但':'但','就':'就','才':'才','也':'也','很':'狠', - '又':'又','再':'再','更':'更','最':'最','总':'总', - '共':'共','只':'只','各':'各','每':'每','任':'任', - '所':'所','该':'该','本':'本', - }; - - /** Convert Chinese text to homophonic (substitute chars with same sound) */ - private homophonicText(text: string): string { - let result = ''; - for (const ch of text) { - if (/[\u4e00-\u9fff]/.test(ch)) { - const homophone = QuarkDriver.HOMOPHONE_MAP[ch]; - result += homophone || ch; - } else { - result += ch; - } - } - return result; - } - - /** Convert Chinese text to pinyin-initial-like string (each char → first pinyin letter or fallback) */ - private pinyinLike(text: string): string { - let result = ''; - for (const ch of text) { - if (/[\u4e00-\u9fff]/.test(ch)) { - const homophone = QuarkDriver.HOMOPHONE_MAP[ch]; - if (homophone) { - // Take first letter of homophone pinyin - result += this.pinyinInitial(homophone); - } else { - // Fallback: use codepoint to generate a letter - const code = ch.charCodeAt(0); - result += String.fromCharCode(97 + (code % 26)); - } - } else if (/[a-zA-Z0-9]/.test(ch)) { - result += ch; - } else if (/[\s._-]/.test(ch)) { - result += '_'; - } - } - return result.replace(/_+/g, '_').replace(/^_|_$/g, ''); - } - - /** Get pinyin initial (first letter of pinyin) for a Chinese character */ - private pinyinInitial(ch: string): string { - // This is a simplified pinyin initial mapping based on unicode range - // Real pinyin would need a full library, this is a workable approximation - const code = ch.charCodeAt(0); - if (code >= 0x4E00 && code <= 0x9FFF) { - // Crude but functional: map unicode range to initial letters - // Based on real pinyin distributions - const initials = ['b','p','m','f','d','t','n','l','g','k','h','j','q','x','zh','ch','sh','r','z','c','s','y','w']; - const idx = Math.min(Math.floor((code - 0x4E00) / 700), initials.length - 1); - return initials[idx]; - } - return ch.toLowerCase(); - } - - private static readonly NOISE_CJK = '的了在是不有会可对所之也同与及但或如且乃而岂乎焉兮哉亦犹尚乃其若故盖诸焉欤' + - '么个着过把对为从以到说时要就这那和上人家下能出得发来年心开物力些长样吧啊哦嗯嚯哇咯呗哟嘿呵哈'; - /** - * Anti-harmony rename for directories. - * NEW APPROACH: light homophonic replacement + preserve structure. - * Goal: still recognizable to humans, but doesn't match keyword filters. - * - * 妖神记 第九季 (2025) 更新432 - * → 夭申记 弟九季 (2025) 更新432_9b03 (80%: light homophonic) - * → ys记 第9季 (2025) gx432_9b03 (20%: partial pinyin) - */ - private magicRenameDir(dirName: string): string { - const hash = crypto.createHash('md5').update(dirName + Date.now()).digest('hex').slice(0, 4); - - // Clean up: replace multiple spaces, trim - let cleanName = dirName.trim().replace(/\s+/g, ' '); - - if (!cleanName) { - return `media_${hash}`; - } - - // Two strategies: light homophonic (80%) or partial pinyin (20%) - let baseName: string; - - if (Math.random() < 0.2) { - // Partial pinyin: 30% of CJK chars → pinyin initial, 70% stay as-is - const chars = [...cleanName]; - const result: string[] = []; - for (const ch of chars) { - if (/[\u4e00-\u9fff]/.test(ch) && Math.random() < 0.3) { - result.push(this.pinyinInitial(ch)); - } else { - result.push(ch); - } - } - baseName = result.join(''); - } else { - // Light homophonic: replace each CJK char, keep everything else as-is - const chars = [...cleanName]; - const result: string[] = []; - for (const ch of chars) { - if (/[\u4e00-\u9fff]/.test(ch)) { - result.push(QuarkDriver.HOMOPHONE_MAP[ch] || ch); - } else { - result.push(ch); - } - } - baseName = result.join(''); - - // Optional: insert 0-2 light noise chars (low probability) - const noiseCount = Math.random() < 0.3 ? (Math.random() < 0.5 ? 1 : 2) : 0; - for (let n = 0; n < noiseCount; n++) { - const pos = Math.floor(Math.random() * (baseName.length + 1)); - const ink = QuarkDriver.NOISE_CJK[Math.floor(Math.random() * QuarkDriver.NOISE_CJK.length)]; - baseName = baseName.slice(0, pos) + ink + baseName.slice(pos); - } - } - - // Cleanup: replace non-alphanumeric/CJK chars with underscore - baseName = baseName.replace(/[^\u4e00-\u9fff\w]/g, '_'); - baseName = baseName.replace(/_+/g, '_').replace(/^_|_$/g, ''); - if (baseName.length > 30) baseName = baseName.slice(0, 30); - - // Append hash suffix - return `${baseName}_${hash}`; - } - - /** - * Anti-harmony rename for files. - * KEEPS: episode numbers (第01集, 01, Ep1), quality (4K, 1080P, HDR), language tags, original extension - * REPLACES: Chinese title with homophonic/pinyin - * - * Original: 斗破苍穹_第01集_1080p.mp4 - * Result: 陡坡仓穷_Ep01_1080p_x9k2.mp4 - */ - private magicRename(filename: string): string { - const hash = crypto.createHash('md5').update(filename + Date.now()).digest('hex').slice(0, 8); - - let ext = ''; - const extMatch = filename.match(/\.[a-zA-Z0-9]+$/); - if (extMatch) { - ext = extMatch[0]; - filename = filename.slice(0, -ext.length); - } - - // Extract and REMEMBER: episode info, quality, language, year - const episodePatterns = [ - { regex: /第\s*(\d+)\s*[集话話話話话回章期]/, format: (m: string) => 'Ep' + m.replace(/[^\d]/g, '') }, - { regex: /Ep\d+|ep\d+/i, format: (m: string) => m.toUpperCase() }, - { regex: /Part\s*\d+/i, format: (m: string) => m.replace(/\s+/g, '') }, - { regex: /E\d{2,}/i, format: (m: string) => m.toUpperCase() }, - ]; - let episodeTag = ''; - for (const { regex, format } of episodePatterns) { - const m = filename.match(regex); - if (m) { - episodeTag = format(m[0]); - filename = filename.replace(m[0], ''); - break; - } - } - - // Extract and REMEMBER: quality tags - const qualityPattern = /\b(4k|1080p|1080P|2160p|720p|HD|BluRay|Blu-ray|HDR|WEB-DL|WEBRip|BDRip|REMUX|DV|Dovi|HEVC|x264|x265|H\.264|H\.265)\b/; - const qualityMatch = filename.match(qualityPattern); - const qualityTag = qualityMatch ? qualityMatch[0] : ''; - if (qualityMatch) filename = filename.replace(qualityMatch[0], ''); - - // Extract and REMEMBER: language tags - const langPattern = /\b(CHS|CHT|JP|EN|BIG5|GB|粤语|国语|日语|英语|中字|日字|英字|繁体中字)\b/; - const langMatch = filename.match(langPattern); - const langTag = langMatch ? langMatch[0] : ''; - if (langMatch) filename = filename.replace(langMatch[0], ''); - - // Extract and REMEMBER: year - const yearMatch = filename.match(/\b(20\d{2})\b/); - const yearTag = yearMatch ? yearMatch[0] : ''; - if (yearMatch) filename = filename.replace(yearMatch[0], ''); - - // Extract and REMEMBER: season info - const seasonMatch = filename.match(/第?\s*(\d+)\s*[季部期]/); - const seasonTag = seasonMatch ? `${seasonMatch[1]}季` : ''; - if (seasonMatch) filename = filename.replace(seasonMatch[0], ''); - - // Now process the remaining name (mostly Chinese title) - // Clean up separators - filename = filename.replace(/[._\-【】\[\]()()\s]+/g, '_').trim(); - - // Decide: homophonic or pinyin-initial - const useHomophonic = Math.random() > 0.5; - let titlePart: string; - if (useHomophonic) { - titlePart = this.homophonicText(filename); - // Remove non-title chars - titlePart = titlePart.replace(/[^\u4e00-\u9fff\wa-zA-Z0-9]/g, '_'); - titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); - if (titlePart.length > 15) titlePart = titlePart.slice(0, 15); - } else { - titlePart = this.pinyinLike(filename); - titlePart = titlePart.replace(/[^a-zA-Z0-9]/g, '_'); - titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); - if (titlePart.length > 15) titlePart = titlePart.slice(0, 15); - } - - // Remove sensitive keywords from title part - const sensitiveWords = /斗破|完美|凡人|仙逆|遮天|吞噬|大主宰|绝世|武动|星辰变|一念永恒|修罗|神墓|长生|剑来|诡秘|全职|斗罗|盘龙|雪鹰|莽荒纪|天珠变|神印王座|牧神记|沧元图|紫川|百炼成神|大王饶命|全球高考/ig; - titlePart = titlePart.replace(sensitiveWords, ''); - titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); - - // Build preserved tags - const tags: string[] = []; - if (seasonTag) tags.push(seasonTag); - if (episodeTag) tags.push(episodeTag); - if (qualityTag) tags.push(qualityTag.toUpperCase()); - if (langTag) tags.push(langTag); - if (yearTag) tags.push(yearTag); - tags.push(hash); // Always add hash for uniqueness - - // Keep original file extension — don't change to .zip/.rar which corrupts files - const newExt = ext || '.bin'; - - // Assemble final name - const parts = [titlePart, ...tags].filter(Boolean); - let result = parts.join('_'); - - // If too long, trim - if (result.length > 80) { - result = result.slice(0, 80); - } - - // If result is too short, add random filler - if (result.length < 10) { - const filler = crypto.randomBytes(4).toString('hex'); - result = `${filler}_${result}`; - } - - return result + newExt; - } - - // ==================== Public API ==================== - - /** - * Validate the cookie by fetching user info. - */ + // ==================== Auth ==================== async validate(): Promise { - const MAX_RETRIES = 2; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - // Use drive-pc.quark.cn (Alibaba internal) instead of pan.quark.cn which times out from China servers - const params = new URLSearchParams({ pr: 'ucpro', fr: 'pc', pdir_fid: '0', _page: '1', _size: '1' }); - const response = await fetch(`${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, { - headers: this.getHeaders(), - signal: AbortSignal.timeout(15000), - }); - if (!response.ok) return false; - const data = await response.json() as any; - if (data?.status === 200) return true; - } catch (err: any) { - 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; + return validate(this.cookie); } - // ==================== Share Save Flow ==================== - - /** - * Save files from a share link → magic rename → create shared link. - * - * Flow: token → detail → save → wait_task → rename → share - */ - async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<{ - success: boolean; - message: string; - shareUrl?: string; - sharePwd?: string; - folderName?: string; - taskId?: string; - renamed?: string[]; - fileCount?: number; - folderCount?: number; - originalFolderName?: string; - }> { - 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 this.acquireStoken(pwdId); - if (!stoken) { - return { success: false, message: '😅 Oops!资源好像偷偷溜走了,换个链接试试吧~' }; - } - - // Step 2: Get share detail - const shareInfo = await this.getShareFiles(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 this.humanDelay(); - const saveDirName = this.dailyFolderName(); - console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`); - const saveDirFid = await this.findOrCreateDir(saveDirName); - const targetPdirFid = saveDirFid || '0'; - if (saveDirFid) { - console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`); - } else { - console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`); - } - - // Step 3: Save top-level item(s) to the random directory - const saveResult = await this.saveFiles(pwdId, stoken, fids, fidTokens, 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 this.waitForTask(taskId, 30000); - if (!savedFids || savedFids.length === 0) { - return { success: true, message: '文件已保存,但获取保存结果超时' }; - } - - // Step 5: Magic rename files — with random delay to avoid detection - await this.humanDelay(); - const renamed: Array<{ original: string; renamed: string }> = []; - 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 || ''; - } - - // Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared - await this.humanDelay(); - let shareUrlResult = ''; - let sharePwdResult = ''; - let shareMsg = ''; - let successCount = 0; // total items (files + folders) actually saved - if (shareFid) { - const shareResult = await this.createShareLink(shareFid); - if (shareResult.success && shareResult.shareUrl) { - shareUrlResult = shareResult.shareUrl; - if (shareResult.sharePwd) sharePwdResult = shareResult.sharePwd; - } else { - shareMsg = `(分享失败:${shareResult.message})`; - } - } - - // 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 this.listDir(savedDirFid); - if (dirFiles && dirFiles.length > 0) { - for (const file of dirFiles) { - if (file.dir) continue; - const newName = this.magicRename(file.file_name); - const renameOk = await this.renameFile(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 = this.magicRenameDir(innerDirOriginalName); - const innerDirRenameOk = await this.renameFile(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 = this.magicRename(originalName); - const renameOk = await this.renameFile(savedFids[i], newName); - if (renameOk) { - renamed.push({ original: originalName, renamed: newName }); - } - } - } - - // Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03") - // DO NOT rename the date folder — it serves as the organizational container. - // The inner folder (for single-folder shares) was already renamed in Step 7. - 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 this.countRecursive(shareFid); - fileCount = counts.fileCount; - folderCount = counts.folderCount; - } catch { - // Fallback to simple count if recursive fails - 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; - } - } - - 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, - originalFolderName, - }; - } catch (err: any) { - return { success: false, message: err.message || 'Network error' }; - } + async fetchNickname(): Promise { + return fetchNickname(this.cookie); } - // ==================== Internal API Methods ==================== - - /** - * Create a new directory in the root. - */ - private async createDir(dirName: string): Promise { - try { - const resp = await fetch( - `${this.baseUrl}/1/clouddrive/file?${this.makeQuery()}`, - { - method: 'POST', - headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ - pdir_fid: '0', - file_name: dirName, - dir: true, - dir_path: '', - }), - signal: AbortSignal.timeout(10000), - } - ); - const data = await resp.json() as any; - 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: any) { - console.log(`[Quark] createDir error: ${err.message}`); - return null; - } + // ==================== Storage (Save from Share) ==================== + async saveFromShare(shareUrl: string, sourceTitle?: string): Promise { + return saveFromShare(this.cookie, this.config.nickname || '', shareUrl, sourceTitle || ''); } - /** - * Find an existing directory by name, or create it if not found. - * Used for daily folders that may already exist from earlier saves today. - */ - private async findOrCreateDir(dirName: string): Promise { - try { - // List root directory - const params = new URLSearchParams({ - pr: 'ucpro', fr: 'pc', - pdir_fid: '0', - _page: '1', _size: '200', - _fetch_total: '1', _fetch_sub_dirs: '0', - _sort: 'file_type:asc,updated_at:desc', - fetch_all_file: '1', - fetch_risk_file_name: '1', - }); - const resp = await fetch( - `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, - { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } - ); - if (resp.ok) { - const data = await resp.json() as any; - if (data.status === 200 && data.data?.list) { - const existing = data.data.list.find((f: any) => 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: any) { - console.log(`[Quark] findOrCreateDir list error: ${err.message}`); - } - // Not found → create it - const fid = await this.createDir(dirName); - console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`); - return fid; + async createDir(dirName: string): Promise { + return createDir(this.cookie, dirName); } - private async acquireStoken(pwdId: string): Promise { - // Retry up to 2 times for transient network issues - for (let attempt = 0; attempt < 3; attempt++) { - try { - const params = new URLSearchParams(this.getCommonParams()); - const resp = await fetch( - `${this.baseUrl}/1/clouddrive/share/sharepage/token?${params.toString()}`, - { - method: 'POST', - headers: { ...this.getHeaders(), '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() as any; - if (data.status === 200 && data.data?.stoken) { - return data.data.stoken; - } - return null; - } catch { - if (attempt >= 2) return null; - // Wait before retry (0.5s, 1s) - await new Promise(r => setTimeout(r, 500 * (attempt + 1))); - } - } - return null; + async findOrCreateDir(dirName: string): Promise { + return findOrCreateDir(this.cookie, dirName); } - /** - * 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. - */ - private async getShareFiles(pwdId: string, stoken: string): Promise<{ files: ShareFile[]; topDir: boolean; childFiles?: ShareFile[] } | null> { - try { - const topLevel = await this.getDetailAt(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 this.getDetailAt(pwdId, stoken, topLevel[0].fid); - // Return both: the directory for saving, its contents for renaming - return { - files: topLevel, - topDir: true, - childFiles: innerFiles || [], - }; - } - - // Multiple top-level items: save them directly - return { - files: topLevel, - topDir: false, - }; - } catch { - return null; - } + async countRecursive(pdirFid: string): Promise { + return countRecursive(this.cookie, pdirFid); } - /** - * Fetch detail at a given pdir_fid. - */ - private async getDetailAt( - pwdId: string, stoken: string, pdirFid: string, - ): Promise { - const params = new URLSearchParams({ - ...this.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( - `${this.baseUrl}/1/clouddrive/share/sharepage/detail?${params.toString()}`, - { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } - ); - if (!resp.ok) return []; - const data = await resp.json() as any; - if (data.status !== 200) return []; - return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({ - fid: f.fid, - file_name: f.file_name, - share_fid_token: f.share_fid_token || '', - dir: f.dir || false, - size: f.size || 0, - })); + // ==================== Share ==================== + async createShareLink(fileId: string): Promise { + return createShareLink(this.cookie, fileId); } - private async saveFiles( - pwdId: string, stoken: string, - fids: string[], fidTokens: string[], - toPdirFid: string - ): Promise<{ success: boolean; message: string; taskId?: string }> { - try { - const resp = await fetch( - `${this.baseUrl}/1/clouddrive/share/sharepage/save?${this.makeQuery()}`, - { - method: 'POST', - headers: { ...this.getHeaders(), '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() as any; - 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: any) { - return { success: false, message: err.message || 'Network error' }; - } + async renameFile(fid: string, newName: string): Promise { + return renameFile(this.cookie, fid, newName); } - /** - * Poll task status until complete or timeout. - * Returns the saved file FIDs (save_as_top_fids). - */ - private async waitForTask(taskId: string, timeoutMs: number): Promise { - const start = Date.now(); - let retryIndex = 0; - - while (Date.now() - start < timeoutMs) { - try { - const params = new URLSearchParams({ - ...this.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( - `${this.baseUrl}/1/clouddrive/task?${params.toString()}`, - { headers: this.getHeaders(), signal: AbortSignal.timeout(10000), } - ); - const data = await resp.json() as any; - if (data.status === 200) { - if (data.data?.status === 2) { - // Task completed - const savedFids: string[] = 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 + // ==================== Storage Info ==================== + async getStorageInfoQuick(fallbackTotal?: number): Promise { + return getStorageInfoQuick(this.cookie, fallbackTotal); } - /** - * Rename a file by its FID. - */ - private async renameFile(fid: string, newName: string): Promise { - try { - const resp = await fetch( - `${this.baseUrl}/1/clouddrive/file/rename?${this.makeQuery()}`, - { - method: 'POST', - headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ fid, file_name: newName }), - signal: AbortSignal.timeout(10000), - } - ); - const data = await resp.json() as any; - return data.status === 200 || data.code === 0; - } catch { - return false; - } + async getStorageInfo(onBackgroundComplete?: (info: any) => void): Promise { + return getStorageInfo(this.cookie, onBackgroundComplete); } - /** - * List files in a directory by its FID (after save to cloud). - */ - private async listDir(pdirFid: string): Promise { - try { - const params = new URLSearchParams({ - ...this.getCommonParams(), - uc_param_str: '', - pdir_fid: pdirFid, - _page: '1', - _size: '50', - _fetch_total: '1', - _fetch_sub_dirs: '0', - _sort: 'file_type:asc,updated_at:desc', - fetch_all_file: '1', - fetch_risk_file_name: '1', - }); - const resp = await fetch( - `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, - { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } - ); - if (!resp.ok) return []; - const data = await resp.json() as any; - if (data.status !== 200) return []; - return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({ - fid: f.fid, - file_name: f.file_name, - share_fid_token: '', - dir: f.dir || false, - size: f.size || 0, - })); - } catch { - return []; - } + async calculateUsedSpace(): Promise { + return calculateUsedSpace(this.cookie); } - /** - * Recursively count files and folders for a saved cloud directory. - */ - private async countRecursive(pdirFid: string): Promise<{ fileCount: number; folderCount: number }> { - 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 this.listDir(fid); - if (!files) continue; - for (const f of files) { - if (f.dir) { - folderCount++; - stack.push(f.fid); - } else { - fileCount++; - } - } - } - return { fileCount, folderCount }; + // ==================== Cleanup ==================== + async listRootDir(): Promise { + return listRootDir(this.cookie); } - /** - * Create a share link for a file/folder. - * Flow: create task → poll for share_id → submit to get short URL. - */ - async createShareLink(fileId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string }> { - try { - const sharePwd = this.randomSharePwd(); - - // Try different share_type values (1=7天, 0=无限制) - const shareTypes = ['1', '0']; - let lastError = ''; - - for (const st of shareTypes) { - await this.humanDelay(); - // Step 1: Create share task - get task_id - const response = await fetch( - `${this.baseUrl}/1/clouddrive/share?${this.makeQuery()}`, - { - method: 'POST', - headers: { ...this.getHeaders(), '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() as any; - 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 this.waitForShareTask(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 this.submitShare(result.shareId, sharePwd); - if (shareUrl) { - return { - success: true, - shareUrl, - sharePwd, - message: `分享链接已生成(密码:${sharePwd})`, - }; - } - lastError = '提交密码后未获取到短链接'; - } - - return { success: false, message: lastError || '🤷 各种姿势都试过了,就是分享不出来…' }; - } catch (err: any) { - console.error('[Quark] createShareLink error:', err.message); - return { success: false, message: err.message || '🌩️ 网络开小差了,再试试?' }; - } + async trashFiles(fids: string[]): Promise { + return trashFiles(this.cookie, fids); } - /** - * Submit share via /password endpoint to get the actual short URL. - * The initial task API only returns a 32-char UUID, this call converts it - * to a valid short URL code (e.g. https://pan.quark.cn/s/12chars). - */ - private async submitShare(shareId: string, sharePwd?: string): Promise { - try { - const response = await fetch( - `${this.baseUrl}/1/clouddrive/share/password?${this.makeQuery()}`, - { - method: 'POST', - headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }), - signal: AbortSignal.timeout(15000), - } - ); - const data = await response.json() as any; - 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. - * Tries multiple approaches to extract the correct short URL code. - */ - private async waitForShareTask(taskId: string, timeoutMs: number): Promise<{ shareId?: string; message?: string } | null> { - const start = Date.now(); - let retryIndex = 0; - while (Date.now() - start < timeoutMs) { - try { - const params = new URLSearchParams({ - ...this.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( - `${this.baseUrl}/1/clouddrive/task?${params.toString()}`, - { headers: this.getHeaders(), signal: AbortSignal.timeout(10000), } - ); - const data = await resp.json() as any; - 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_url.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_url.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' }; - } - - // ==================== Other Features ==================== - - async checkIn(): Promise<{ success: boolean; message: string; signedDays?: number }> { - try { - const mparam = this.getMparam(); - if (!mparam.kps || !mparam.sign || !mparam.vcode) { - return { success: false, message: 'Cookie 缺少 kps/sign/vcode 参数,无法签到' }; - } - const params = new URLSearchParams({ - ...this.getCommonParams(), - kps: mparam.kps, - sign: mparam.sign, - vcode: mparam.vcode, - }); - const response = await fetch( - `${this.baseUrl}/1/clouddrive/capacity/growth/sign?${params.toString()}`, - { - method: 'POST', - headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - signal: AbortSignal.timeout(15000), - } - ); - const data = await response.json() as any; - if (data.status === 200) { - const signInfo = data.data?.sign_in_daily || {}; - const signedDays = signInfo.signed_day || signInfo.sign_day || 0; - const isSigned = signInfo.sign_in === true || signInfo.is_sign_in === true; - return { - success: isSigned, - message: isSigned - ? `签到成功!本月已签到 ${signedDays} 天` - : `签到失败(可能今日已签到,本月 ${signedDays} 天)`, - signedDays, - }; - } - if (data.status === 40002 || data.status === 40003) { - return { success: false, message: '今日已签到,明天再来吧' }; - } - return { success: false, message: data.message || `签到失败(${data.status})` }; - } catch (err: any) { - return { success: false, message: `签到请求失败:${err.message || '网络错误'}` }; - } - } - - // Get total capacity from /capacity/detail API - async getStorageInfoQuick(): Promise<{ total: string; totalBytes: number }> { - try { - const mparam = this.getMparam(); - const params = new URLSearchParams({ - ...this.getCommonParams(), - kps: mparam.kps || '', - sign: mparam.sign || '', - vcode: mparam.vcode || '', - }); - const response = await fetch(`${this.baseUrl}/1/clouddrive/capacity/detail?${params.toString()}`, { - headers: this.getHeaders(), - signal: AbortSignal.timeout(10000), - }); - if (response.ok) { - const data = await response.json() as any; - if (data.status === 200 && data.data) { - let 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); - } - return { total: this.formatBytes(totalBytes), totalBytes }; - } - } - } catch {} - return { total: '-', totalBytes: 0 }; - } - - async getStorageInfo(): Promise { - try { - const mparam = this.getMparam(); - let totalBytes = 0; - const params = new URLSearchParams({ - ...this.getCommonParams(), - kps: mparam.kps || '', - sign: mparam.sign || '', - vcode: mparam.vcode || '', - }); - const response = await fetch(`${this.baseUrl}/1/clouddrive/capacity/detail?${params.toString()}`, { - headers: this.getHeaders(), - signal: AbortSignal.timeout(10000), - }); - if (response.ok) { - const data = await response.json() as any; - if (data.status === 200 && data.data) { - 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); - } - } - } - - // Calculate used space by recursively traversing all files - const usedBytes = await this.calculateUsedSpace(); - - // 即使 totalBytes 为 0(API 异常或免费账户),仍然返回已用空间 - // 免费/过期账户用 expired 中的历史最大容量作为参考 - if (totalBytes > 0 || usedBytes > 0) { - return { - total: totalBytes > 0 ? this.formatBytes(totalBytes) : '-', - used: this.formatBytes(usedBytes), - usedBytes, - totalBytes: totalBytes > 0 ? totalBytes : 0, - }; - } - return { used: '0 B', total: '-', usedBytes: 0, totalBytes: 0 }; - } catch { - return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 }; - } - } - - private formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i]; - } - - // ==================== Cleanup / Trash ==================== - - /** - * List files at the root level (pdir_fid=0) — returns all top-level dirs/files. - * Used by cleanup to find date-named folders. - */ - async listRootDir(): Promise> { - try { - const params = new URLSearchParams({ - pr: 'ucpro', fr: 'pc', - pdir_fid: '0', - _page: '1', _size: '200', - _fetch_total: '1', _fetch_sub_dirs: '0', - _sort: 'file_type:asc,updated_at:desc', - fetch_all_file: '1', - fetch_risk_file_name: '1', - }); - const resp = await fetch( - `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, - { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } - ); - if (!resp.ok) return []; - const data = await resp.json() as any; - if (data.status !== 200 || !data.data?.list) return []; - return (data.data.list || []).map((f: any) => ({ - fid: f.fid, - file_name: f.file_name, - dir: f.dir || false, - size: f.size || 0, - })); - } catch { - return []; - } - } - - /** - * List all files in a directory, handling pagination. - * Fetches all pages until no more results. - */ - private async listDirAllPages(pdirFid: string): Promise { - const allFiles: ShareFile[] = []; - let page = 1; - const pageSize = 100; - let total = -1; - while (total === -1 || (page - 1) * pageSize < total) { - const params = new URLSearchParams({ - ...this.getCommonParams(), - uc_param_str: '', - pdir_fid: pdirFid, - _page: String(page), - _size: String(pageSize), - _fetch_total: '1', - _fetch_sub_dirs: '0', - _sort: 'file_type:asc,updated_at:desc', - fetch_all_file: '1', - fetch_risk_file_name: '1', - }); - try { - const resp = await fetch( - `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, - { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } - ); - if (!resp.ok) break; - const data = await resp.json() as any; - if (data.status !== 200 || !data.data?.list) break; - allFiles.push(...(data.data.list || []).filter((f: any) => f.fid).map((f: any) => ({ - fid: f.fid, - file_name: f.file_name, - share_fid_token: '', - dir: f.dir || false, - size: f.size || 0, - }))); - if (total === -1) { - total = data.metadata?._total || allFiles.length; - } - page++; - } catch { - break; - } - } - return allFiles; - } - - /** - * Calculate total used space by recursively traversing all files - * and summing their sizes. Uses 3-hour time window cache (0/3/6/9/12/15/18/21). - */ - async calculateUsedSpace(): Promise { - // 3-hour time window: refetch only when crossing 0/3/6/9/12/15/18/21 boundaries - const currentHourBlock = Math.floor(new Date().getHours() / 3); - if (this.cachedUsedSpace && this.cachedUsedSpace.hourBlock === currentHourBlock) { - return this.cachedUsedSpace.bytes; - } - let totalUsed = 0; - const stack: string[] = ['0']; - const visited = new Set(); - while (stack.length > 0) { - const fid = stack.pop()!; - if (visited.has(fid)) continue; - visited.add(fid); - const files = await this.listDirAllPages(fid); - if (!files.length) continue; - for (const f of files) { - if (f.dir) { - stack.push(f.fid); - } else { - totalUsed += f.size || 0; - } - } - await new Promise(r => setTimeout(r, 50)); - } - this.cachedUsedSpace = { bytes: totalUsed, hourBlock: currentHourBlock }; - return totalUsed; - } - - /** - * Move specified files/folders to trash (recycle bin). - * Files in trash don't count toward storage usage. - */ - async trashFiles(fids: string[]): Promise { - if (!fids.length) return true; - try { - const response = await fetch( - `${this.baseUrl}/1/clouddrive/file/trash?${this.makeQuery()}`, - { - method: 'POST', - headers: { ...this.getHeaders(), '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() as any; - if (data.status === 200) return true; - console.error(`[Quark] trashFiles failed: ${data.message || data.status}`); - return false; - } catch (err: any) { - console.error(`[Quark] trashFiles error: ${err.message}`); - return false; - } - } - - /** - * Empty the recycle bin — permanently delete all files in trash. - * This actually frees up storage space. - * Quark API: POST /1/clouddrive/file/trash/clear - */ async emptyTrash(): Promise { - try { - const response = await fetch( - `${this.baseUrl}/1/clouddrive/file/trash/clear?${this.makeQuery()}`, - { - method: 'POST', - headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - signal: AbortSignal.timeout(60000), - } - ); - if (!response.ok) return false; - const data = await response.json() as any; - if (data.status === 200) return true; - console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`); - return false; - } catch (err: any) { - console.error(`[Quark] emptyTrash error: ${err.message}`); - return false; - } + return emptyTrash(this.cookie); } - // ════════════════════════════════════════════════════════════════ - // Cleanup operations — called by the generic cleanup controller - // (cleanup.service.ts). Each cloud driver implements these to - // tell the controller HOW to clean, while the controller decides - // WHEN and WITH WHAT parameters. - // ════════════════════════════════════════════════════════════════ - - /** - * [Cleanup] Trash date-named folders (YYYY-MM-DD) older than `days`. - * Called by the generic cleanup controller with cleanup_file_retention_days. - * Returns the number of folders trashed. - */ - async cleanupOldDateFolders(days: number): Promise<{ trashed: number; errors: string[] }> { - const errors: string[] = []; - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - days); - const cutoffStr = cutoff.toISOString().slice(0, 10); - - try { - const rootItems = await this.listRootDir(); - const oldFolders = rootItems.filter(item => { - if (!item.dir) return false; - if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false; - return item.file_name < cutoffStr; - }); - - 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(', ')}`); - const ok = await this.trashFiles(fids); - if (ok) { - return { trashed: fids.length, errors: [] }; - } - return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] }; - } catch (err: any) { - return { trashed: 0, errors: [err.message] }; - } + async cleanupOldDateFolders(days: number, whitelistDirs?: string[]): Promise { + return cleanupOldDateFolders(this.cookie, days, whitelistDirs); } - /** - * [Cleanup] If used space exceeds thresholdPercent% of total, - * delete the oldest date folders until totalBytes * deletePercent/100 - * of total capacity is freed. Returns the number of folders trashed. - */ - async cleanupBySpaceThreshold( - thresholdPercent: number, - deletePercent: number, - ): Promise<{ trashed: number; errors: string[] }> { - const errors: string[] = []; - - try { - const storage = await this.getStorageInfo(); - 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`); - return { trashed: 0, errors: [] }; - } - - const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100); - - const rootItems = await this.listRootDir(); - const dateFolders = rootItems - .filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) - .sort((a, b) => a.file_name.localeCompare(b.file_name)); - - if (dateFolders.length === 0) return { trashed: 0, errors: [] }; - - // Accumulate oldest folders until their total size >= targetBytesToFree - // If API returns size=0 for directories, fall back to count-based estimation - const hasSizes = dateFolders.some(f => f.size > 0); - let cumulativeSize = 0; - const foldersToTrash: typeof dateFolders = []; - - if (hasSizes) { - for (const folder of dateFolders) { - foldersToTrash.push(folder); - cumulativeSize += folder.size; - if (cumulativeSize >= targetBytesToFree) break; - } - } else { - const avgSizePerFolder = storage.usedBytes / dateFolders.length; - const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder)); - foldersToTrash.push(...dateFolders.slice(0, estCount)); - cumulativeSize = estCount * avgSizePerFolder; - } - - 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 ok = await this.trashFiles(fidsToTrash); - if (ok) { - console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`); - return { trashed: foldersToTrash.length, errors: [] }; - } - return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] }; - } catch (err: any) { - return { trashed: 0, errors: [err.message] }; - } + async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number, whitelistDirs?: string[]): Promise { + return cleanupBySpaceThreshold(this.cookie, thresholdPercent, deletePercent, whitelistDirs); } } diff --git a/source_clean/src/cloud/drivers/quark.driver.ts.v035_monolithic b/source_clean/src/cloud/drivers/quark.driver.ts.v035_monolithic new file mode 100755 index 0000000..9ed3808 --- /dev/null +++ b/source_clean/src/cloud/drivers/quark.driver.ts.v035_monolithic @@ -0,0 +1,1533 @@ +// Native fetch available in Node 20+ +import * as crypto from 'crypto'; + +export interface QuarkConfig { + cookie: string; + nickname?: string; +} + +export interface StorageInfo { + used: string; + total: string; + usedBytes: number; + totalBytes: number; +} + +interface ShareFile { + fid: string; + file_name: string; + share_fid_token: string; + dir: boolean; + size?: number; +} + +export class QuarkDriver { + private config: QuarkConfig; + private baseUrl = 'https://drive-pc.quark.cn'; + private cachedUsedSpace: { bytes: number; hourBlock: number } | null = null; + + constructor(config: QuarkConfig) { + this.config = config; + } + + private getHeaders(): Record { + 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': this.config.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', + }; + } + + private getCommonParams(): Record { + return { pr: 'ucpro', fr: 'pc' }; + } + + /** Random delay to mimic human behavior (500-2000ms) */ + private async humanDelay(): Promise { + const ms = Math.floor(Math.random() * 1500) + 500; + await new Promise(r => setTimeout(r, ms)); + } + + /** Generate a random password for share links */ + private randomSharePwd(): string { + return Math.floor(1000 + Math.random() * 9000).toString(); + } + + /** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */ + private dailyFolderName(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + + /** Generate a random folder name for saving (fallback) */ + private randomFolderName(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let name = ''; + for (let i = 0; i < 12; i++) { + name += chars[Math.floor(Math.random() * chars.length)]; + } + return name; + } + + /** Generate query string with common params + random timing to mimic browser */ + private makeQuery(extra: Record = {}): string { + const __dt = Math.floor(Math.random() * 240000 + 60000); + const __t = Date.now() / 1000; + return new URLSearchParams({ + ...this.getCommonParams(), + uc_param_str: '', + app: 'clouddrive', + __dt: String(__dt), + __t: String(__t), + ...extra, + }).toString(); + } + + /** + * Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix). + */ + private getMparam(): { kps?: string; sign?: string; vcode?: string } { + const cookie = this.config.cookie; + const kpsMatch = cookie.match(/(? = { + // 网盘热门番名 — 谐音替换 (same sound, different char) + '斗':'陡','破':'坡','苍':'仓','穹':'穷', + '完':'玩','美':'每','世':'士','界':'介', + '凡':'烦','人':'仁','修':'休','罗':'络', + '仙':'先','逆':'腻','遮':'折','天':'添', + '吞':'屯','噬':'逝','大':'达','主':'嘱','宰':'崽', + '星':'惺','辰':'晨','变':'便','一':'伊','念':'捻', + '永':'泳','恒':'横','神':'申','墓':'暮','长':'尝','生':'甥', + '剑':'箭','来':'莱','诡':'鬼','秘':'蜜', + '全':'泉','职':'值','盘':'磐','龙':'笼', + '雪':'血','鹰':'莺','莽':'蟒','荒':'慌','纪':'记', + '珠':'株','王':'亡','座':'坐','牧':'木','记':'计', + '沧':'舱','元':'圆','图':'涂','紫':'仔','川':'串', + '百':'白','炼':'恋','成':'程','饶':'绕','命':'冥', + // 通用谐音替换 + '的':'得','了':'啦','是':'事','不':'布','我':'窝', + '你':'尼','他':'她','有':'友','和':'合','与':'予', + '上':'尚','下':'夏','中':'忠','第':'弟','集':'级', + '话':'划','季':'际','年':'念','月':'阅','日':'曰', + '新':'心','版':'板','高':'糕','清':'青','原':'源', + '小':'晓','片':'篇','视':'市','频':'贫','道':'到', + '动':'洞','画':'话','声':'升','音':'因','文':'闻', + '明':'名','暗':'黯','光':'广','影':'映','色':'瑟', + '风':'疯','雨':'语','花':'华','国':'果','家':'佳', + '战':'站','争':'挣','士':'仕','兵':'宾', + '皇':'惶','帝':'谛','魔':'磨','鬼':'诡','怪':'乖', + '精':'经','灵':'铃','妖':'夭','武':'舞','侠':'狭', + '杀':'刹','血':'雪','刀':'叨','枪':'呛','炮':'泡', + '时':'石','空':'孔','前':'钱','后':'厚','东':'冬', + '南':'难','西':'夕','北':'备','开':'凯','关':'官', + '出':'初','进':'近','去':'趣', + '短':'短','多':'多','少':'少','真':'贞','假':'价', + '好':'郝','坏':'怀','对':'队','错':'措','以':'已', + '从':'从','被':'被','把':'把','将':'将','在':'在', + '但':'但','就':'就','才':'才','也':'也','很':'狠', + '又':'又','再':'再','更':'更','最':'最','总':'总', + '共':'共','只':'只','各':'各','每':'每','任':'任', + '所':'所','该':'该','本':'本', + }; + + /** Convert Chinese text to homophonic (substitute chars with same sound) */ + private homophonicText(text: string): string { + let result = ''; + for (const ch of text) { + if (/[\u4e00-\u9fff]/.test(ch)) { + const homophone = QuarkDriver.HOMOPHONE_MAP[ch]; + result += homophone || ch; + } else { + result += ch; + } + } + return result; + } + + /** Convert Chinese text to pinyin-initial-like string (each char → first pinyin letter or fallback) */ + private pinyinLike(text: string): string { + let result = ''; + for (const ch of text) { + if (/[\u4e00-\u9fff]/.test(ch)) { + const homophone = QuarkDriver.HOMOPHONE_MAP[ch]; + if (homophone) { + // Take first letter of homophone pinyin + result += this.pinyinInitial(homophone); + } else { + // Fallback: use codepoint to generate a letter + const code = ch.charCodeAt(0); + result += String.fromCharCode(97 + (code % 26)); + } + } else if (/[a-zA-Z0-9]/.test(ch)) { + result += ch; + } else if (/[\s._-]/.test(ch)) { + result += '_'; + } + } + return result.replace(/_+/g, '_').replace(/^_|_$/g, ''); + } + + /** Get pinyin initial (first letter of pinyin) for a Chinese character */ + private pinyinInitial(ch: string): string { + // This is a simplified pinyin initial mapping based on unicode range + // Real pinyin would need a full library, this is a workable approximation + const code = ch.charCodeAt(0); + if (code >= 0x4E00 && code <= 0x9FFF) { + // Crude but functional: map unicode range to initial letters + // Based on real pinyin distributions + const initials = ['b','p','m','f','d','t','n','l','g','k','h','j','q','x','zh','ch','sh','r','z','c','s','y','w']; + const idx = Math.min(Math.floor((code - 0x4E00) / 700), initials.length - 1); + return initials[idx]; + } + return ch.toLowerCase(); + } + + private static readonly NOISE_CJK = '的了在是不有会可对所之也同与及但或如且乃而岂乎焉兮哉亦犹尚乃其若故盖诸焉欤' + + '么个着过把对为从以到说时要就这那和上人家下能出得发来年心开物力些长样吧啊哦嗯嚯哇咯呗哟嘿呵哈'; + /** + * Anti-harmony rename for directories. + * NEW APPROACH: light homophonic replacement + preserve structure. + * Goal: still recognizable to humans, but doesn't match keyword filters. + * + * 妖神记 第九季 (2025) 更新432 + * → 夭申记 弟九季 (2025) 更新432_9b03 (80%: light homophonic) + * → ys记 第9季 (2025) gx432_9b03 (20%: partial pinyin) + */ + private magicRenameDir(dirName: string): string { + const hash = crypto.createHash('md5').update(dirName + Date.now()).digest('hex').slice(0, 4); + + // Clean up: replace multiple spaces, trim + let cleanName = dirName.trim().replace(/\s+/g, ' '); + + if (!cleanName) { + return `media_${hash}`; + } + + // Two strategies: light homophonic (80%) or partial pinyin (20%) + let baseName: string; + + if (Math.random() < 0.2) { + // Partial pinyin: 30% of CJK chars → pinyin initial, 70% stay as-is + const chars = [...cleanName]; + const result: string[] = []; + for (const ch of chars) { + if (/[\u4e00-\u9fff]/.test(ch) && Math.random() < 0.3) { + result.push(this.pinyinInitial(ch)); + } else { + result.push(ch); + } + } + baseName = result.join(''); + } else { + // Light homophonic: replace each CJK char, keep everything else as-is + const chars = [...cleanName]; + const result: string[] = []; + for (const ch of chars) { + if (/[\u4e00-\u9fff]/.test(ch)) { + result.push(QuarkDriver.HOMOPHONE_MAP[ch] || ch); + } else { + result.push(ch); + } + } + baseName = result.join(''); + + // Optional: insert 0-2 light noise chars (low probability) + const noiseCount = Math.random() < 0.3 ? (Math.random() < 0.5 ? 1 : 2) : 0; + for (let n = 0; n < noiseCount; n++) { + const pos = Math.floor(Math.random() * (baseName.length + 1)); + const ink = QuarkDriver.NOISE_CJK[Math.floor(Math.random() * QuarkDriver.NOISE_CJK.length)]; + baseName = baseName.slice(0, pos) + ink + baseName.slice(pos); + } + } + + // Cleanup: replace non-alphanumeric/CJK chars with underscore + baseName = baseName.replace(/[^\u4e00-\u9fff\w]/g, '_'); + baseName = baseName.replace(/_+/g, '_').replace(/^_|_$/g, ''); + if (baseName.length > 30) baseName = baseName.slice(0, 30); + + // Append hash suffix + return `${baseName}_${hash}`; + } + + /** + * Anti-harmony rename for files. + * KEEPS: episode numbers (第01集, 01, Ep1), quality (4K, 1080P, HDR), language tags, original extension + * REPLACES: Chinese title with homophonic/pinyin + * + * Original: 斗破苍穹_第01集_1080p.mp4 + * Result: 陡坡仓穷_Ep01_1080p_x9k2.mp4 + */ + private magicRename(filename: string): string { + const hash = crypto.createHash('md5').update(filename + Date.now()).digest('hex').slice(0, 8); + + let ext = ''; + const extMatch = filename.match(/\.[a-zA-Z0-9]+$/); + if (extMatch) { + ext = extMatch[0]; + filename = filename.slice(0, -ext.length); + } + + // Extract and REMEMBER: episode info, quality, language, year + const episodePatterns = [ + { regex: /第\s*(\d+)\s*[集话話話話话回章期]/, format: (m: string) => 'Ep' + m.replace(/[^\d]/g, '') }, + { regex: /Ep\d+|ep\d+/i, format: (m: string) => m.toUpperCase() }, + { regex: /Part\s*\d+/i, format: (m: string) => m.replace(/\s+/g, '') }, + { regex: /E\d{2,}/i, format: (m: string) => m.toUpperCase() }, + ]; + let episodeTag = ''; + for (const { regex, format } of episodePatterns) { + const m = filename.match(regex); + if (m) { + episodeTag = format(m[0]); + filename = filename.replace(m[0], ''); + break; + } + } + + // Extract and REMEMBER: quality tags + const qualityPattern = /\b(4k|1080p|1080P|2160p|720p|HD|BluRay|Blu-ray|HDR|WEB-DL|WEBRip|BDRip|REMUX|DV|Dovi|HEVC|x264|x265|H\.264|H\.265)\b/; + const qualityMatch = filename.match(qualityPattern); + const qualityTag = qualityMatch ? qualityMatch[0] : ''; + if (qualityMatch) filename = filename.replace(qualityMatch[0], ''); + + // Extract and REMEMBER: language tags + const langPattern = /\b(CHS|CHT|JP|EN|BIG5|GB|粤语|国语|日语|英语|中字|日字|英字|繁体中字)\b/; + const langMatch = filename.match(langPattern); + const langTag = langMatch ? langMatch[0] : ''; + if (langMatch) filename = filename.replace(langMatch[0], ''); + + // Extract and REMEMBER: year + const yearMatch = filename.match(/\b(20\d{2})\b/); + const yearTag = yearMatch ? yearMatch[0] : ''; + if (yearMatch) filename = filename.replace(yearMatch[0], ''); + + // Extract and REMEMBER: season info + const seasonMatch = filename.match(/第?\s*(\d+)\s*[季部期]/); + const seasonTag = seasonMatch ? `${seasonMatch[1]}季` : ''; + if (seasonMatch) filename = filename.replace(seasonMatch[0], ''); + + // Now process the remaining name (mostly Chinese title) + // Clean up separators + filename = filename.replace(/[._\-【】\[\]()()\s]+/g, '_').trim(); + + // Decide: homophonic or pinyin-initial + const useHomophonic = Math.random() > 0.5; + let titlePart: string; + if (useHomophonic) { + titlePart = this.homophonicText(filename); + // Remove non-title chars + titlePart = titlePart.replace(/[^\u4e00-\u9fff\wa-zA-Z0-9]/g, '_'); + titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); + if (titlePart.length > 15) titlePart = titlePart.slice(0, 15); + } else { + titlePart = this.pinyinLike(filename); + titlePart = titlePart.replace(/[^a-zA-Z0-9]/g, '_'); + titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); + if (titlePart.length > 15) titlePart = titlePart.slice(0, 15); + } + + // Remove sensitive keywords from title part + const sensitiveWords = /斗破|完美|凡人|仙逆|遮天|吞噬|大主宰|绝世|武动|星辰变|一念永恒|修罗|神墓|长生|剑来|诡秘|全职|斗罗|盘龙|雪鹰|莽荒纪|天珠变|神印王座|牧神记|沧元图|紫川|百炼成神|大王饶命|全球高考/ig; + titlePart = titlePart.replace(sensitiveWords, ''); + titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); + + // Build preserved tags + const tags: string[] = []; + if (seasonTag) tags.push(seasonTag); + if (episodeTag) tags.push(episodeTag); + if (qualityTag) tags.push(qualityTag.toUpperCase()); + if (langTag) tags.push(langTag); + if (yearTag) tags.push(yearTag); + tags.push(hash); // Always add hash for uniqueness + + // Keep original file extension — don't change to .zip/.rar which corrupts files + const newExt = ext || '.bin'; + + // Assemble final name + const parts = [titlePart, ...tags].filter(Boolean); + let result = parts.join('_'); + + // If too long, trim + if (result.length > 80) { + result = result.slice(0, 80); + } + + // If result is too short, add random filler + if (result.length < 10) { + const filler = crypto.randomBytes(4).toString('hex'); + result = `${filler}_${result}`; + } + + return result + newExt; + } + + // ==================== Public API ==================== + + /** + * Validate the cookie by fetching user info. + */ + async validate(): Promise { + const MAX_RETRIES = 2; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + // Use drive-pc.quark.cn (Alibaba internal) instead of pan.quark.cn which times out from China servers + const params = new URLSearchParams({ pr: 'ucpro', fr: 'pc', pdir_fid: '0', _page: '1', _size: '1' }); + const response = await fetch(`${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, { + headers: this.getHeaders(), + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) return false; + const data = await response.json() as any; + if (data?.status === 200) return true; + } catch (err: any) { + 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; + } + + // ==================== Share Save Flow ==================== + + /** + * Save files from a share link → magic rename → create shared link. + * + * Flow: token → detail → save → wait_task → rename → share + */ + async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<{ + success: boolean; + message: string; + shareUrl?: string; + sharePwd?: string; + folderName?: string; + taskId?: string; + renamed?: string[]; + fileCount?: number; + folderCount?: number; + originalFolderName?: string; + }> { + 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 this.acquireStoken(pwdId); + if (!stoken) { + return { success: false, message: '😅 Oops!资源好像偷偷溜走了,换个链接试试吧~' }; + } + + // Step 2: Get share detail + const shareInfo = await this.getShareFiles(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 this.humanDelay(); + const saveDirName = this.dailyFolderName(); + console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`); + const saveDirFid = await this.findOrCreateDir(saveDirName); + const targetPdirFid = saveDirFid || '0'; + if (saveDirFid) { + console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`); + } else { + console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`); + } + + // Step 3: Save top-level item(s) to the random directory + const saveResult = await this.saveFiles(pwdId, stoken, fids, fidTokens, 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 this.waitForTask(taskId, 30000); + if (!savedFids || savedFids.length === 0) { + return { success: true, message: '文件已保存,但获取保存结果超时' }; + } + + // Step 5: Magic rename files — with random delay to avoid detection + await this.humanDelay(); + const renamed: Array<{ original: string; renamed: string }> = []; + 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 || ''; + } + + // Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared + await this.humanDelay(); + let shareUrlResult = ''; + let sharePwdResult = ''; + let shareMsg = ''; + let successCount = 0; // total items (files + folders) actually saved + if (shareFid) { + const shareResult = await this.createShareLink(shareFid); + if (shareResult.success && shareResult.shareUrl) { + shareUrlResult = shareResult.shareUrl; + if (shareResult.sharePwd) sharePwdResult = shareResult.sharePwd; + } else { + shareMsg = `(分享失败:${shareResult.message})`; + } + } + + // 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 this.listDir(savedDirFid); + if (dirFiles && dirFiles.length > 0) { + for (const file of dirFiles) { + if (file.dir) continue; + const newName = this.magicRename(file.file_name); + const renameOk = await this.renameFile(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 = this.magicRenameDir(innerDirOriginalName); + const innerDirRenameOk = await this.renameFile(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 = this.magicRename(originalName); + const renameOk = await this.renameFile(savedFids[i], newName); + if (renameOk) { + renamed.push({ original: originalName, renamed: newName }); + } + } + } + + // Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03") + // DO NOT rename the date folder — it serves as the organizational container. + // The inner folder (for single-folder shares) was already renamed in Step 7. + 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 this.countRecursive(shareFid); + fileCount = counts.fileCount; + folderCount = counts.folderCount; + } catch { + // Fallback to simple count if recursive fails + 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; + } + } + + 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, + originalFolderName, + }; + } catch (err: any) { + return { success: false, message: err.message || 'Network error' }; + } + } + + // ==================== Internal API Methods ==================== + + /** + * Create a new directory in the root. + */ + private async createDir(dirName: string): Promise { + try { + const resp = await fetch( + `${this.baseUrl}/1/clouddrive/file?${this.makeQuery()}`, + { + method: 'POST', + headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pdir_fid: '0', + file_name: dirName, + dir: true, + dir_path: '', + }), + signal: AbortSignal.timeout(10000), + } + ); + const data = await resp.json() as any; + 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: any) { + console.log(`[Quark] createDir error: ${err.message}`); + return null; + } + } + + /** + * Find an existing directory by name, or create it if not found. + * Used for daily folders that may already exist from earlier saves today. + */ + private async findOrCreateDir(dirName: string): Promise { + try { + // List root directory + const params = new URLSearchParams({ + pr: 'ucpro', fr: 'pc', + pdir_fid: '0', + _page: '1', _size: '200', + _fetch_total: '1', _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + const resp = await fetch( + `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, + { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } + ); + if (resp.ok) { + const data = await resp.json() as any; + if (data.status === 200 && data.data?.list) { + const existing = data.data.list.find((f: any) => 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: any) { + console.log(`[Quark] findOrCreateDir list error: ${err.message}`); + } + // Not found → create it + const fid = await this.createDir(dirName); + console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`); + return fid; + } + + private async acquireStoken(pwdId: string): Promise { + // Retry up to 2 times for transient network issues + for (let attempt = 0; attempt < 3; attempt++) { + try { + const params = new URLSearchParams(this.getCommonParams()); + const resp = await fetch( + `${this.baseUrl}/1/clouddrive/share/sharepage/token?${params.toString()}`, + { + method: 'POST', + headers: { ...this.getHeaders(), '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() as any; + if (data.status === 200 && data.data?.stoken) { + return data.data.stoken; + } + return null; + } catch { + if (attempt >= 2) return null; + // Wait before retry (0.5s, 1s) + await new Promise(r => setTimeout(r, 500 * (attempt + 1))); + } + } + return null; + } + + /** + * 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. + */ + private async getShareFiles(pwdId: string, stoken: string): Promise<{ files: ShareFile[]; topDir: boolean; childFiles?: ShareFile[] } | null> { + try { + const topLevel = await this.getDetailAt(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 this.getDetailAt(pwdId, stoken, topLevel[0].fid); + // Return both: the directory for saving, its contents for renaming + return { + files: topLevel, + topDir: true, + childFiles: innerFiles || [], + }; + } + + // Multiple top-level items: save them directly + return { + files: topLevel, + topDir: false, + }; + } catch { + return null; + } + } + + /** + * Fetch detail at a given pdir_fid. + */ + private async getDetailAt( + pwdId: string, stoken: string, pdirFid: string, + ): Promise { + const params = new URLSearchParams({ + ...this.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( + `${this.baseUrl}/1/clouddrive/share/sharepage/detail?${params.toString()}`, + { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } + ); + if (!resp.ok) return []; + const data = await resp.json() as any; + if (data.status !== 200) return []; + return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({ + fid: f.fid, + file_name: f.file_name, + share_fid_token: f.share_fid_token || '', + dir: f.dir || false, + size: f.size || 0, + })); + } + + private async saveFiles( + pwdId: string, stoken: string, + fids: string[], fidTokens: string[], + toPdirFid: string + ): Promise<{ success: boolean; message: string; taskId?: string }> { + try { + const resp = await fetch( + `${this.baseUrl}/1/clouddrive/share/sharepage/save?${this.makeQuery()}`, + { + method: 'POST', + headers: { ...this.getHeaders(), '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() as any; + 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: any) { + return { success: false, message: err.message || 'Network error' }; + } + } + + /** + * Poll task status until complete or timeout. + * Returns the saved file FIDs (save_as_top_fids). + */ + private async waitForTask(taskId: string, timeoutMs: number): Promise { + const start = Date.now(); + let retryIndex = 0; + + while (Date.now() - start < timeoutMs) { + try { + const params = new URLSearchParams({ + ...this.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( + `${this.baseUrl}/1/clouddrive/task?${params.toString()}`, + { headers: this.getHeaders(), signal: AbortSignal.timeout(10000), } + ); + const data = await resp.json() as any; + if (data.status === 200) { + if (data.data?.status === 2) { + // Task completed + const savedFids: string[] = 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 a file by its FID. + */ + private async renameFile(fid: string, newName: string): Promise { + try { + const resp = await fetch( + `${this.baseUrl}/1/clouddrive/file/rename?${this.makeQuery()}`, + { + method: 'POST', + headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ fid, file_name: newName }), + signal: AbortSignal.timeout(10000), + } + ); + const data = await resp.json() as any; + return data.status === 200 || data.code === 0; + } catch { + return false; + } + } + + /** + * List files in a directory by its FID (after save to cloud). + */ + private async listDir(pdirFid: string): Promise { + try { + const params = new URLSearchParams({ + ...this.getCommonParams(), + uc_param_str: '', + pdir_fid: pdirFid, + _page: '1', + _size: '50', + _fetch_total: '1', + _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + const resp = await fetch( + `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, + { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } + ); + if (!resp.ok) return []; + const data = await resp.json() as any; + if (data.status !== 200) return []; + return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({ + fid: f.fid, + file_name: f.file_name, + share_fid_token: '', + dir: f.dir || false, + size: f.size || 0, + })); + } catch { + return []; + } + } + + /** + * Recursively count files and folders for a saved cloud directory. + */ + private async countRecursive(pdirFid: string): Promise<{ fileCount: number; folderCount: number }> { + 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 this.listDir(fid); + if (!files) continue; + for (const f of files) { + if (f.dir) { + folderCount++; + stack.push(f.fid); + } else { + fileCount++; + } + } + } + return { fileCount, folderCount }; + } + + /** + * Create a share link for a file/folder. + * Flow: create task → poll for share_id → submit to get short URL. + */ + async createShareLink(fileId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string }> { + try { + const sharePwd = this.randomSharePwd(); + + // Try different share_type values (1=7天, 0=无限制) + const shareTypes = ['1', '0']; + let lastError = ''; + + for (const st of shareTypes) { + await this.humanDelay(); + // Step 1: Create share task - get task_id + const response = await fetch( + `${this.baseUrl}/1/clouddrive/share?${this.makeQuery()}`, + { + method: 'POST', + headers: { ...this.getHeaders(), '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() as any; + 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 this.waitForShareTask(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 this.submitShare(result.shareId, sharePwd); + if (shareUrl) { + return { + success: true, + shareUrl, + sharePwd, + message: `分享链接已生成(密码:${sharePwd})`, + }; + } + lastError = '提交密码后未获取到短链接'; + } + + return { success: false, message: lastError || '🤷 各种姿势都试过了,就是分享不出来…' }; + } catch (err: any) { + console.error('[Quark] createShareLink error:', err.message); + return { success: false, message: err.message || '🌩️ 网络开小差了,再试试?' }; + } + } + + /** + * Submit share via /password endpoint to get the actual short URL. + * The initial task API only returns a 32-char UUID, this call converts it + * to a valid short URL code (e.g. https://pan.quark.cn/s/12chars). + */ + private async submitShare(shareId: string, sharePwd?: string): Promise { + try { + const response = await fetch( + `${this.baseUrl}/1/clouddrive/share/password?${this.makeQuery()}`, + { + method: 'POST', + headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }), + signal: AbortSignal.timeout(15000), + } + ); + const data = await response.json() as any; + 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. + * Tries multiple approaches to extract the correct short URL code. + */ + private async waitForShareTask(taskId: string, timeoutMs: number): Promise<{ shareId?: string; message?: string } | null> { + const start = Date.now(); + let retryIndex = 0; + while (Date.now() - start < timeoutMs) { + try { + const params = new URLSearchParams({ + ...this.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( + `${this.baseUrl}/1/clouddrive/task?${params.toString()}`, + { headers: this.getHeaders(), signal: AbortSignal.timeout(10000), } + ); + const data = await resp.json() as any; + 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_url.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_url.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' }; + } + + // ==================== Other Features ==================== + + async checkIn(): Promise<{ success: boolean; message: string; signedDays?: number }> { + try { + const mparam = this.getMparam(); + if (!mparam.kps || !mparam.sign || !mparam.vcode) { + return { success: false, message: 'Cookie 缺少 kps/sign/vcode 参数,无法签到' }; + } + const params = new URLSearchParams({ + ...this.getCommonParams(), + kps: mparam.kps, + sign: mparam.sign, + vcode: mparam.vcode, + }); + const response = await fetch( + `${this.baseUrl}/1/clouddrive/capacity/growth/sign?${params.toString()}`, + { + method: 'POST', + headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(15000), + } + ); + const data = await response.json() as any; + if (data.status === 200) { + const signInfo = data.data?.sign_in_daily || {}; + const signedDays = signInfo.signed_day || signInfo.sign_day || 0; + const isSigned = signInfo.sign_in === true || signInfo.is_sign_in === true; + return { + success: isSigned, + message: isSigned + ? `签到成功!本月已签到 ${signedDays} 天` + : `签到失败(可能今日已签到,本月 ${signedDays} 天)`, + signedDays, + }; + } + if (data.status === 40002 || data.status === 40003) { + return { success: false, message: '今日已签到,明天再来吧' }; + } + return { success: false, message: data.message || `签到失败(${data.status})` }; + } catch (err: any) { + return { success: false, message: `签到请求失败:${err.message || '网络错误'}` }; + } + } + + // Get total capacity from /capacity/detail API + async getStorageInfoQuick(): Promise<{ total: string; totalBytes: number }> { + try { + const mparam = this.getMparam(); + const params = new URLSearchParams({ + ...this.getCommonParams(), + kps: mparam.kps || '', + sign: mparam.sign || '', + vcode: mparam.vcode || '', + }); + const response = await fetch(`${this.baseUrl}/1/clouddrive/capacity/detail?${params.toString()}`, { + headers: this.getHeaders(), + signal: AbortSignal.timeout(10000), + }); + if (response.ok) { + const data = await response.json() as any; + if (data.status === 200 && data.data) { + let 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); + } + return { total: this.formatBytes(totalBytes), totalBytes }; + } + } + } catch {} + return { total: '-', totalBytes: 0 }; + } + + async getStorageInfo(): Promise { + try { + const mparam = this.getMparam(); + let totalBytes = 0; + const params = new URLSearchParams({ + ...this.getCommonParams(), + kps: mparam.kps || '', + sign: mparam.sign || '', + vcode: mparam.vcode || '', + }); + const response = await fetch(`${this.baseUrl}/1/clouddrive/capacity/detail?${params.toString()}`, { + headers: this.getHeaders(), + signal: AbortSignal.timeout(10000), + }); + if (response.ok) { + const data = await response.json() as any; + if (data.status === 200 && data.data) { + 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); + } + } + } + + // Calculate used space by recursively traversing all files + const usedBytes = await this.calculateUsedSpace(); + + // 即使 totalBytes 为 0(API 异常或免费账户),仍然返回已用空间 + // 免费/过期账户用 expired 中的历史最大容量作为参考 + if (totalBytes > 0 || usedBytes > 0) { + return { + total: totalBytes > 0 ? this.formatBytes(totalBytes) : '-', + used: this.formatBytes(usedBytes), + usedBytes, + totalBytes: totalBytes > 0 ? totalBytes : 0, + }; + } + return { used: '0 B', total: '-', usedBytes: 0, totalBytes: 0 }; + } catch { + return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 }; + } + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i]; + } + + // ==================== Cleanup / Trash ==================== + + /** + * List files at the root level (pdir_fid=0) — returns all top-level dirs/files. + * Used by cleanup to find date-named folders. + */ + async listRootDir(): Promise> { + try { + const params = new URLSearchParams({ + pr: 'ucpro', fr: 'pc', + pdir_fid: '0', + _page: '1', _size: '200', + _fetch_total: '1', _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + const resp = await fetch( + `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, + { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } + ); + if (!resp.ok) return []; + const data = await resp.json() as any; + if (data.status !== 200 || !data.data?.list) return []; + return (data.data.list || []).map((f: any) => ({ + fid: f.fid, + file_name: f.file_name, + dir: f.dir || false, + size: f.size || 0, + })); + } catch { + return []; + } + } + + /** + * List all files in a directory, handling pagination. + * Fetches all pages until no more results. + */ + private async listDirAllPages(pdirFid: string): Promise { + const allFiles: ShareFile[] = []; + let page = 1; + const pageSize = 100; + let total = -1; + while (total === -1 || (page - 1) * pageSize < total) { + const params = new URLSearchParams({ + ...this.getCommonParams(), + uc_param_str: '', + pdir_fid: pdirFid, + _page: String(page), + _size: String(pageSize), + _fetch_total: '1', + _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + try { + const resp = await fetch( + `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, + { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), } + ); + if (!resp.ok) break; + const data = await resp.json() as any; + if (data.status !== 200 || !data.data?.list) break; + allFiles.push(...(data.data.list || []).filter((f: any) => f.fid).map((f: any) => ({ + fid: f.fid, + file_name: f.file_name, + share_fid_token: '', + dir: f.dir || false, + size: f.size || 0, + }))); + if (total === -1) { + total = data.metadata?._total || allFiles.length; + } + page++; + } catch { + break; + } + } + return allFiles; + } + + /** + * Calculate total used space by recursively traversing all files + * and summing their sizes. Uses 3-hour time window cache (0/3/6/9/12/15/18/21). + */ + async calculateUsedSpace(): Promise { + // 3-hour time window: refetch only when crossing 0/3/6/9/12/15/18/21 boundaries + const currentHourBlock = Math.floor(new Date().getHours() / 3); + if (this.cachedUsedSpace && this.cachedUsedSpace.hourBlock === currentHourBlock) { + return this.cachedUsedSpace.bytes; + } + let totalUsed = 0; + const stack: string[] = ['0']; + const visited = new Set(); + while (stack.length > 0) { + const fid = stack.pop()!; + if (visited.has(fid)) continue; + visited.add(fid); + const files = await this.listDirAllPages(fid); + if (!files.length) continue; + for (const f of files) { + if (f.dir) { + stack.push(f.fid); + } else { + totalUsed += f.size || 0; + } + } + await new Promise(r => setTimeout(r, 50)); + } + this.cachedUsedSpace = { bytes: totalUsed, hourBlock: currentHourBlock }; + return totalUsed; + } + + /** + * Move specified files/folders to trash (recycle bin). + * Files in trash don't count toward storage usage. + */ + async trashFiles(fids: string[]): Promise { + if (!fids.length) return true; + try { + const response = await fetch( + `${this.baseUrl}/1/clouddrive/file/trash?${this.makeQuery()}`, + { + method: 'POST', + headers: { ...this.getHeaders(), '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() as any; + if (data.status === 200) return true; + console.error(`[Quark] trashFiles failed: ${data.message || data.status}`); + return false; + } catch (err: any) { + console.error(`[Quark] trashFiles error: ${err.message}`); + return false; + } + } + + /** + * Empty the recycle bin — permanently delete all files in trash. + * This actually frees up storage space. + * Quark API: POST /1/clouddrive/file/trash/clear + */ + async emptyTrash(): Promise { + try { + const response = await fetch( + `${this.baseUrl}/1/clouddrive/file/trash/clear?${this.makeQuery()}`, + { + method: 'POST', + headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(60000), + } + ); + if (!response.ok) return false; + const data = await response.json() as any; + if (data.status === 200) return true; + console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`); + return false; + } catch (err: any) { + console.error(`[Quark] emptyTrash error: ${err.message}`); + return false; + } + } + + // ════════════════════════════════════════════════════════════════ + // Cleanup operations — called by the generic cleanup controller + // (cleanup.service.ts). Each cloud driver implements these to + // tell the controller HOW to clean, while the controller decides + // WHEN and WITH WHAT parameters. + // ════════════════════════════════════════════════════════════════ + + /** + * [Cleanup] Trash date-named folders (YYYY-MM-DD) older than `days`. + * Called by the generic cleanup controller with cleanup_file_retention_days. + * Returns the number of folders trashed. + */ + async cleanupOldDateFolders(days: number): Promise<{ trashed: number; errors: string[] }> { + const errors: string[] = []; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().slice(0, 10); + + try { + const rootItems = await this.listRootDir(); + const oldFolders = rootItems.filter(item => { + if (!item.dir) return false; + if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false; + return item.file_name < cutoffStr; + }); + + 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(', ')}`); + const ok = await this.trashFiles(fids); + if (ok) { + return { trashed: fids.length, errors: [] }; + } + return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] }; + } catch (err: any) { + 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. Returns the number of folders trashed. + */ + async cleanupBySpaceThreshold( + thresholdPercent: number, + deletePercent: number, + ): Promise<{ trashed: number; errors: string[] }> { + const errors: string[] = []; + + try { + const storage = await this.getStorageInfo(); + 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`); + return { trashed: 0, errors: [] }; + } + + const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100); + + const rootItems = await this.listRootDir(); + const dateFolders = rootItems + .filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) + .sort((a, b) => a.file_name.localeCompare(b.file_name)); + + if (dateFolders.length === 0) return { trashed: 0, errors: [] }; + + // Accumulate oldest folders until their total size >= targetBytesToFree + // If API returns size=0 for directories, fall back to count-based estimation + const hasSizes = dateFolders.some(f => f.size > 0); + let cumulativeSize = 0; + const foldersToTrash: typeof dateFolders = []; + + if (hasSizes) { + for (const folder of dateFolders) { + foldersToTrash.push(folder); + cumulativeSize += folder.size; + if (cumulativeSize >= targetBytesToFree) break; + } + } else { + const avgSizePerFolder = storage.usedBytes / dateFolders.length; + const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder)); + foldersToTrash.push(...dateFolders.slice(0, estCount)); + cumulativeSize = estCount * avgSizePerFolder; + } + + 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 ok = await this.trashFiles(fidsToTrash); + if (ok) { + console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`); + return { trashed: foldersToTrash.length, errors: [] }; + } + return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] }; + } catch (err: any) { + return { trashed: 0, errors: [err.message] }; + } + } +} diff --git a/source_clean/src/config/index.ts b/source_clean/src/config/index.ts index a0ede68..224a9b8 100755 --- a/source_clean/src/config/index.ts +++ b/source_clean/src/config/index.ts @@ -36,16 +36,5 @@ const config: Config = { dbPath: process.env.DB_PATH || './data/cloudsearch.db', }; -// 生产环境强制校验关键安全配置 -if (process.env.NODE_ENV === 'production') { - if (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'cloudsearch-jwt-secret-dev') { - console.error('[FATAL] JWT_SECRET 未设置或使用了默认值,请在 .env 中设置强密码') - process.exit(1) - } - if (!process.env.ADMIN_PASSWORD || process.env.ADMIN_PASSWORD === 'admin123') { - console.error('[FATAL] ADMIN_PASSWORD 未设置或使用了默认值,请在 .env 中设置强密码') - process.exit(1) - } -} - +// Startup validation done by startup-validator export default config; diff --git a/source_clean/src/config/startup-validator.ts b/source_clean/src/config/startup-validator.ts new file mode 100644 index 0000000..ac41f41 --- /dev/null +++ b/source_clean/src/config/startup-validator.ts @@ -0,0 +1,104 @@ +/** + * Startup configuration validator. + * + * Validates critical config items before server start. + * All issues are warnings in staging — only COOKIE_ENCRYPTION_KEY missing + * and admin password using defaults will be flagged. + */ +import config from '../config'; + +interface ValidationError { + key: string; + message: string; + severity: 'error' | 'warn'; +} + +export function validateConfig(): ValidationError[] { + const errors: ValidationError[] = []; + const isProd = config.nodeEnv === 'production'; + + // ─── JWT Secret ─── + const DEFAULT_JWT_SECRETS = [ + 'cloudsearch-jwt-secret-dev', + 'your-super-secret-jwt-key-change-me', + '', + ]; + if (DEFAULT_JWT_SECRETS.includes(config.jwtSecret)) { + errors.push({ + key: 'JWT_SECRET', + message: '使用了默认 JWT Secret,生产部署前应修改(openssl rand -hex 32)', + severity: isProd ? 'warn' : 'warn', + }); + } + + // ─── Admin Password ─── + const weakPasswords = ['admin123', 'admin', 'password', '123456', '']; + if (weakPasswords.includes(config.adminPassword)) { + errors.push({ + key: 'ADMIN_PASSWORD', + message: `使用了默认管理员密码,生产部署前应设置强密码`, + severity: isProd ? 'warn' : 'warn', + }); + } + + // ─── Cookie Encryption ─── + if (!process.env.COOKIE_ENCRYPTION_KEY) { + errors.push({ + key: 'COOKIE_ENCRYPTION_KEY', + message: '未设置网盘 Cookie 加密密钥!Cookie 将以明文存储。生产环境强烈建议设置。\n' + + '生成: openssl rand -hex 32', + severity: 'warn', + }); + } + + // ─── CORS ─── + const corsOrigin = process.env.CORS_ORIGIN || ''; + if (isProd && (!corsOrigin || corsOrigin === 'https://your-production-domain.com')) { + errors.push({ + key: 'CORS_ORIGIN', + message: '生产环境未配置真实的 CORS_ORIGIN,临时允许所有来源请求', + severity: 'warn', + }); + } + + // ─── Port conflict check (best-effort) ─── + if (config.port < 1024 && (process as any).getuid?.() !== 0) { + errors.push({ + key: 'PORT', + message: `端口 ${config.port} 需要 root 权限(<1024),建议使用 9527 或更高端口`, + severity: 'warn', + }); + } + + return errors; +} + +/** + * Print validation results and return whether startup should proceed. + * Returns false only if 'error' severity issues found in production. + * In staging, warnings are printed but startup continues. + */ +export function checkStartup(): boolean { + const errors = validateConfig(); + const isProd = config.nodeEnv === 'production'; + + if (errors.length === 0) { + console.log('[Config] ✅ 所有配置检查通过'); + return true; + } + + console.log('[Config] ── 配置检查结果 ──'); + for (const err of errors) { + const prefix = err.severity === 'error' ? '❌' : '⚠️'; + console.log(`[Config] ${prefix} [${err.severity.toUpperCase()}] ${err.key}: ${err.message}`); + } + + const criticalErrors = errors.filter(e => e.severity === 'error'); + if (criticalErrors.length > 0 && isProd) { + console.error('[Config] 🛑 生产环境存在严重配置错误,拒绝启动。请修复后重试。'); + return false; + } + + console.log(`[Config] ✅ 继续启动(${errors.length} 个警告)`); + return true; +} diff --git a/source_clean/src/main.ts b/source_clean/src/main.ts index 9f2dfb4..fb7bc1e 100755 --- a/source_clean/src/main.ts +++ b/source_clean/src/main.ts @@ -5,6 +5,7 @@ import helmet from 'helmet'; import morgan from 'morgan'; import config from './config'; import { VERSION as version } from "./version"; +import { checkStartup } from './config/startup-validator'; import { getDb } from './database/database'; import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache'; import rateLimiter from './middleware/rate-limit'; @@ -142,6 +143,12 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express. // ============ Server Start ============ async function start(): Promise { + // Startup config validation + if (!checkStartup()) { + console.error('[Server] 配置校验未通过,进程退出'); + process.exit(1); + } + try { getDb(); console.log('[DB] SQLite database initialized'); diff --git a/source_clean/src/routes/admin.routes.ts b/source_clean/src/routes/admin.routes.ts index cc51cd4..fc90cf0 100644 --- a/source_clean/src/routes/admin.routes.ts +++ b/source_clean/src/routes/admin.routes.ts @@ -17,6 +17,7 @@ import config from '../config'; import { reconnectRedis, testRedisConnection } from '../middleware/cache'; import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; import { BaiduDriver } from '../cloud/drivers/baidu.driver'; +import { testProxyConnection } from '../utils/proxy-agent'; const router = Router(); @@ -529,13 +530,8 @@ router.post('/admin/test-external-service', async (req: Request, res: Response) res.json({ ok: false, info: 'Proxy URL not configured' }); return; } - const response = await fetch(proxyUrl, { signal: AbortSignal.timeout(8000) }); - const latency = Date.now() - start; - res.json({ - ok: response.ok, - latency, - info: response.ok ? '连接成功' : `HTTP ${response.status}`, - }); + const result = await testProxyConnection(proxyUrl); + res.json(result); break; } case 'ip_geo': { diff --git a/source_clean/src/utils/crypto.ts b/source_clean/src/utils/crypto.ts new file mode 100644 index 0000000..0710af9 --- /dev/null +++ b/source_clean/src/utils/crypto.ts @@ -0,0 +1,98 @@ +/** + * AES-256-GCM encryption/decryption for protecting cloud drive cookies stored in DB. + * + * Encryption key is derived from COOKIE_ENCRYPTION_KEY env var via SHA-256. + * If unset, uses a built-in default key (stable across container restarts). + * Production MUST set COOKIE_ENCRYPTION_KEY! + */ +import * as crypto from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 96-bit nonce for GCM +const TAG_LENGTH = 16; // 128-bit auth tag +const KEY_LENGTH = 32; // 256-bit key + +let ENCRYPTION_KEY: Buffer | null = null; + +function getKey(): Buffer { + if (ENCRYPTION_KEY) return ENCRYPTION_KEY; + + const envKey = process.env.COOKIE_ENCRYPTION_KEY; + if (envKey && envKey.length >= 32) { + ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest(); + console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)'); + } else if (envKey) { + ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest(); + console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)'); + } else { + // Default stable key (not ephemeral) — data survives container restart + ENCRYPTION_KEY = crypto.createHash('sha256').update('cloudsearch-cookie-key-v1').digest(); + console.log('[Crypto] Cookie encryption enabled (built-in default key — set COOKIE_ENCRYPTION_KEY in .env for extra security)'); + } + return ENCRYPTION_KEY; +} + +/** + * Encrypt plaintext. Returns base64-encoded ciphertext (includes IV + auth tag). + */ +export function encrypt(plaintext: string): string { + if (!plaintext) return ''; + const key = getKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + // Format: iv (12) + tag (16) + ciphertext + const combined = Buffer.concat([iv, tag, encrypted]); + return combined.toString('base64'); +} + +/** + * Decrypt base64-encoded ciphertext. Returns original plaintext. + * Returns empty string if decryption fails (corrupted data or wrong key). + */ +export function decrypt(encoded: string): string { + if (!encoded) return ''; + try { + const key = getKey(); + const combined = Buffer.from(encoded, 'base64'); + if (combined.length < IV_LENGTH + TAG_LENGTH + 1) { + console.warn('[Crypto] Ciphertext too short, returning as-is (possibly unencrypted legacy data)'); + return encoded; + } + const iv = combined.subarray(0, IV_LENGTH); + const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + return decrypted.toString('utf8'); + } catch (err: any) { + if (err.message?.includes('unsupported state') || err.message?.includes('authentication')) { + console.warn('[Crypto] Decryption failed (possibly legacy plaintext), returning as-is'); + return encoded; + } + console.error('[Crypto] Decryption error:', err.message); + return ''; + } +} + +/** + * Check if a string appears to be encrypted (base64 with IV+tag prefix). + * Used for migration: re-encrypt legacy plaintext cookies. + */ +export function isEncrypted(value: string): boolean { + if (!value) return false; + try { + const combined = Buffer.from(value, 'base64'); + return combined.length > IV_LENGTH + TAG_LENGTH; + } catch { + return false; + } +} diff --git a/source_clean/src/utils/logger.ts b/source_clean/src/utils/logger.ts new file mode 100644 index 0000000..3f60669 --- /dev/null +++ b/source_clean/src/utils/logger.ts @@ -0,0 +1,77 @@ +/** + * Structured logger with log levels. + * Level controlled by LOG_LEVEL env var (debug|info|warn|error). + * Default: 'info' in production, 'debug' otherwise. + */ + +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +interface LogEntry { + level: LogLevel; + message: string; + timestamp: string; + module?: string; + duration?: number; + error?: string; +} + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +let currentLevel: LogLevel = + (process.env.LOG_LEVEL as LogLevel) || + (process.env.NODE_ENV === 'production' ? 'info' : 'debug'); + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel]; +} + +function formatLog(entry: LogEntry): string { + const parts: string[] = [ + `[${entry.timestamp}]`, + `[${entry.level.toUpperCase()}]`, + ]; + if (entry.module) parts.push(`[${entry.module}]`); + parts.push(entry.message); + if (entry.duration !== undefined) parts.push(`(${entry.duration}ms)`); + if (entry.error) parts.push(`\n ${entry.error}`); + return parts.join(' '); +} + +function log(level: LogLevel, message: string, module?: string, extra?: Partial): void { + if (!shouldLog(level)) return; + const entry: LogEntry = { + level, + message, + timestamp: new Date().toISOString(), + module, + ...extra, + }; + const formatted = formatLog(entry); + switch (level) { + case 'error': + console.error(formatted); + break; + case 'warn': + console.warn(formatted); + break; + default: + console.log(formatted); + break; + } +} + +export const logger = { + debug: (msg: string, module?: string) => log('debug', msg, module), + info: (msg: string, module?: string) => log('info', msg, module), + warn: (msg: string, module?: string) => log('warn', msg, module), + error: (msg: string, module?: string, err?: Error) => + log('error', msg, module, err ? { error: err.stack || err.message } : undefined), + /** Log with duration (for performance tracking) */ + perf: (msg: string, durationMs: number, module?: string) => + log('info', msg, module, { duration: durationMs }), +}; diff --git a/source_clean/src/utils/proxy-agent.ts b/source_clean/src/utils/proxy-agent.ts new file mode 100644 index 0000000..a327e39 --- /dev/null +++ b/source_clean/src/utils/proxy-agent.ts @@ -0,0 +1,145 @@ +/** + * Unified proxy agent — supports HTTP/HTTPS/SOCKS5/SOCKS5h protocols. + * + * Node 20+ native fetch() uses undici Dispatcher, but socks-proxy-agent + * doesn't implement this interface. + * Solution: use http.Agent interface + http/https.request(). + */ + +let HttpsProxyAgent: any; +let SocksProxyAgent: any; + +try { + HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent; +} catch { + try { + HttpsProxyAgent = require('https-proxy-agent'); + } catch {} +} + +try { + SocksProxyAgent = require('socks-proxy-agent').SocksProxyAgent; +} catch { + try { + SocksProxyAgent = require('socks-proxy-agent'); + } catch {} +} + +/** Create an http.Agent for the given proxy URL (works with https.request) */ +function createProxyAgent(proxyUrl?: string): any { + if (!proxyUrl || typeof proxyUrl !== 'string') return null; + const trimmed = proxyUrl.trim(); + if (!trimmed) return null; + const lower = trimmed.toLowerCase(); + try { + if (lower.startsWith('socks5://') || lower.startsWith('socks5h://')) { + if (!SocksProxyAgent) { + console.warn('[Proxy] socks-proxy-agent not installed'); + return null; + } + return new SocksProxyAgent(trimmed); + } + if (lower.startsWith('http://') || lower.startsWith('https://')) { + if (!HttpsProxyAgent) { + console.warn('[Proxy] No HTTP proxy agent available'); + return null; + } + return new HttpsProxyAgent(trimmed); + } + // Unknown scheme — try as HTTP proxy + if (HttpsProxyAgent) return new HttpsProxyAgent(trimmed); + return null; + } catch (err: any) { + console.error(`[Proxy] Failed to create proxy agent: ${err.message}`); + return null; + } +} + +/** + * Fetch with proxy support. + * Uses native fetch() when no proxy, or http/https.request() with agent when proxy is set. + */ +export async function proxiedFetch( + url: string, + init?: RequestInit, + proxyUrl?: string +): Promise { + if (!proxyUrl) return fetch(url, init); + + const agent = createProxyAgent(proxyUrl); + if (!agent) return fetch(url, init); + + const parsedUrl = new URL(url); + const mod = parsedUrl.protocol === 'https:' ? require('https') : require('http'); + + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (init?.headers) { + const h = init.headers; + if (h instanceof Headers) { + h.forEach((v, k) => { headers[k] = v; }); + } else if (typeof h === 'object') { + Object.assign(headers, h); + } + } + + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: init?.method || 'GET', + headers, + agent, + }; + + const req = mod.request(options, (res: any) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const body = Buffer.concat(chunks); + resolve(new Response(body, { + status: res.statusCode || 502, + statusText: res.statusMessage || '', + headers: new Headers(res.headers || {}), + })); + }); + }); + + req.on('error', reject); + if (init?.signal) { + init.signal.addEventListener('abort', () => req.destroy()); + } + if (init?.body) { + req.write( + typeof init.body === 'string' ? init.body : + init.body instanceof Buffer ? init.body : + init.body instanceof ArrayBuffer ? Buffer.from(init.body) : + Buffer.from(String(init.body)) + ); + } + req.end(); + }); +} + +export async function testProxyConnection( + proxyUrl: string, + testUrl?: string +): Promise<{ ok: boolean; latency: number; info: string }> { + const target = testUrl || 'https://www.baidu.com'; + const start = Date.now(); + try { + const res = await proxiedFetch(target, { + signal: AbortSignal.timeout(10000), + }, proxyUrl); + const latency = Date.now() - start; + return { ok: true, latency, info: `连接成功 (${res.status})` }; + } catch (err: any) { + return { ok: false, latency: Date.now() - start, info: `代理连接失败: ${err.message}` }; + } +} + +// Legacy compat — no longer returns dispatcher, kept for type compatibility +export function createProxyDispatcher(proxyUrl?: string): { agent: any } | null { + const agent = createProxyAgent(proxyUrl); + return agent ? { agent } : null; +} diff --git a/source_clean/tsconfig.json b/source_clean/tsconfig.json index 1951778..b6e2cc9 100755 --- a/source_clean/tsconfig.json +++ b/source_clean/tsconfig.json @@ -2,7 +2,9 @@ "compilerOptions": { "target": "ES2022", "module": "commonjs", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "outDir": "./dist", "rootDir": "./src", "strict": true, @@ -12,8 +14,14 @@ "resolveJsonModule": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "noImplicitAny": false }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/source_clean/tsconfig.json.bak b/source_clean/tsconfig.json.bak new file mode 100755 index 0000000..1951778 --- /dev/null +++ b/source_clean/tsconfig.json.bak @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}