chore: initial commit - CloudSearch v0.0.2

This commit is contained in:
2026-05-15 05:50:50 +08:00
commit d83225d736
102 changed files with 37926 additions and 0 deletions
@@ -0,0 +1,315 @@
import { getHeaders, getCommonParams, getMparam, listRootDir, listDirAllPages, formatBytes, humanDelay, makeQuery, listDir, QuarkFile } from './quark-api';
/**
* 容量信息 & 空间清理模块。
*/
const BASE_URL = 'https://drive-pc.quark.cn';
// ==================== Storage Info ====================
/** Cached used space, keyed by hour block (3h window) */
const cachedUsedSpace: { bytes: number; hourBlock: number } | null = null;
// We use a function-scoped cache instead of instance field
const storageCache: { bytes: number; hourBlock: number } = { 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: string, fallbackTotal?: string): Promise<{ total: string; totalBytes: number; used: string; usedBytes: number }> {
try {
const mparam = getMparam(cookie);
const params = new URLSearchParams({
...getCommonParams(),
kps: mparam.kps || '',
sign: mparam.sign || '',
vcode: mparam.vcode || '',
});
const capResponse = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
headers: getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
let totalBytes = 0;
if (capResponse.ok) {
const data = await capResponse.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);
}
}
}
// Quick used-space estimate: sum root-level file sizes + subdir sizes
let usedBytes = 0;
try {
const rootFiles = await 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: formatBytes(totalBytes),
totalBytes,
used: 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: Record<string, number> = { 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.
*/
export async function getStorageInfo(cookie: string): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> {
try {
const mparam = getMparam(cookie);
let totalBytes = 0;
const params = new URLSearchParams({
...getCommonParams(),
kps: mparam.kps || '',
sign: mparam.sign || '',
vcode: mparam.vcode || '',
});
const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
headers: getHeaders(cookie),
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);
}
}
}
const usedBytes = await calculateUsedSpace(cookie);
if (totalBytes > 0 || usedBytes > 0) {
return {
total: totalBytes > 0 ? formatBytes(totalBytes) : '-',
used: 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 };
}
}
/**
* Calculate total used space by recursively traversing all files
* and summing their sizes. Uses 3-hour time window cache.
*/
export async function calculateUsedSpace(cookie: string): Promise<number> {
const currentHourBlock = Math.floor(new Date().getHours() / 3);
if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) {
return storageCache.bytes;
}
let totalUsed = 0;
const stack: string[] = ['0'];
const visited = new Set<string>();
while (stack.length > 0) {
const fid = stack.pop()!;
if (visited.has(fid)) continue;
visited.add(fid);
const files = await 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: string, fids: string[]): Promise<boolean> {
if (!fids.length) return true;
try {
const response = await fetch(
`${BASE_URL}/1/clouddrive/file/trash?${makeQuery()}`,
{
method: 'POST',
headers: { ...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() 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.
*/
export async function emptyTrash(cookie: string): Promise<boolean> {
try {
const response = await fetch(
`${BASE_URL}/1/clouddrive/file/trash/clear?${makeQuery()}`,
{
method: 'POST',
headers: { ...getHeaders(cookie), '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: trash date-named folders (YYYY-MM-DD) older than `days`.
*/
export async function cleanupOldDateFolders(cookie: string, 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 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;
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: 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.
*/
export async function cleanupBySpaceThreshold(
cookie: string,
thresholdPercent: number,
deletePercent: number,
): Promise<{ trashed: number; errors: string[] }> {
const errors: string[] = [];
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 listRootDir(cookie);
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: [] };
const hasSizes = dateFolders.some(f => f.size && f.size > 0);
let cumulativeSize = 0;
const foldersToTrash: typeof dateFolders = [];
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: any) {
return { trashed: 0, errors: [err.message] };
}
}