v0.3.50: Retry save creates new folder with random suffix to produce genuinely different share link
This commit is contained in:
@@ -1 +1 @@
|
||||
0.3.49
|
||||
0.3.50
|
||||
|
||||
@@ -48,6 +48,9 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
const db = getDb();
|
||||
const ipLocation = await lookupIpLocation(ipAddress || '');
|
||||
|
||||
// ── Track if this is a re-save after link was found invalid
|
||||
let retrySave = false;
|
||||
|
||||
// ── Short-term dedup: prevent duplicate saves of the same URL within 60 seconds ──
|
||||
const DEDUP_WINDOW_SEC = 60;
|
||||
let dedupCutoff = '';
|
||||
@@ -78,6 +81,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
if (dedupValidation.status !== 'valid') {
|
||||
dedupLinkInvalid = true;
|
||||
console.log(`[Share] 🛡️ Dedup link invalid (${dedupValidation.message}), falling through to normal save`);
|
||||
retrySave = true;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`[Share] 🛡️ Dedup validation error: ${err.message}, falling through`);
|
||||
@@ -150,6 +154,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
file_count: 0, folder_count: 0, duration_ms: 0,
|
||||
};
|
||||
}
|
||||
retrySave = true;
|
||||
console.log(`[Share] Existing share link for ${shareUrl} is invalid/expired, will re-save`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -171,7 +176,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
switch (cloudType) {
|
||||
case 'quark': {
|
||||
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle, retrySave);
|
||||
break;
|
||||
}
|
||||
case 'baidu': {
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp, formatLocalDateTime } from '../utils/time';
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
import { QuarkDriver } from './drivers/quark.driver';
|
||||
import { BaiduDriver } from './drivers/baidu.driver';
|
||||
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
||||
import { lookupIpLocation } from './ip-lookup';
|
||||
import { notifyConfigEvent } from './notification.service';
|
||||
|
||||
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
|
||||
const inFlightSaves = new Map<string, Promise<SaveResult>>();
|
||||
|
||||
export interface SaveResult {
|
||||
success: boolean;
|
||||
shareUrl?: string;
|
||||
share_url?: string;
|
||||
sharePwd?: string;
|
||||
folderName?: string;
|
||||
message: string;
|
||||
file_count?: number;
|
||||
folder_count?: number;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
export interface SaveRecord {
|
||||
id: number;
|
||||
source_type: string;
|
||||
source_title: string | null;
|
||||
source_url: string;
|
||||
target_cloud: string;
|
||||
share_url: string | null;
|
||||
share_pwd: string | null;
|
||||
file_size: string | null;
|
||||
file_count: number;
|
||||
folder_count: number;
|
||||
duration_ms: number;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
folder_name: string | null;
|
||||
original_folder_name: string | null;
|
||||
ip_address: string | null;
|
||||
ip_location: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Core save logic extracted so inFlight dedup can wrap it */
|
||||
async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise<SaveResult> {
|
||||
const db = getDb();
|
||||
const ipLocation = await lookupIpLocation(ipAddress || '');
|
||||
|
||||
// ── Short-term dedup: prevent duplicate saves of the same URL within 60 seconds ──
|
||||
const DEDUP_WINDOW_SEC = 60;
|
||||
let dedupCutoff = '';
|
||||
try {
|
||||
const recentCutoff = db.prepare(
|
||||
`SELECT datetime('now','localtime', '-${DEDUP_WINDOW_SEC} seconds') as cutoff`
|
||||
).get() as { cutoff: string };
|
||||
dedupCutoff = recentCutoff.cutoff;
|
||||
|
||||
const recentRecord = db.prepare(
|
||||
`SELECT share_url, share_pwd, status, error_message, folder_name, original_folder_name FROM save_records
|
||||
WHERE source_url = ? AND created_at >= ?
|
||||
ORDER BY created_at DESC LIMIT 1`
|
||||
).get(shareUrl, dedupCutoff) as {
|
||||
share_url: string | null; share_pwd: string | null; status: string;
|
||||
error_message: string | null; folder_name: string | null; original_folder_name: string | null;
|
||||
} | undefined;
|
||||
|
||||
if (recentRecord) {
|
||||
const alreadySaved = recentRecord.status === 'success' || recentRecord.status === 'reused';
|
||||
if (alreadySaved && recentRecord.share_url) {
|
||||
console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`);
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
cloudType, sourceTitle || null, shareUrl, cloudType,
|
||||
recentRecord.share_url, recentRecord.share_pwd || null,
|
||||
null, 0, 0, 0, 'reused', null,
|
||||
recentRecord.folder_name || null, recentRecord.original_folder_name || null,
|
||||
ipAddress || null, ipLocation, localTimestamp(),
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: `🛡️ 此资源刚在 ${DEDUP_WINDOW_SEC} 秒内转存过,直接返回已有分享链接`,
|
||||
share_url: recentRecord.share_url, shareUrl: recentRecord.share_url,
|
||||
sharePwd: recentRecord.share_pwd || '', folderName: '',
|
||||
file_count: 0, folder_count: 0, duration_ms: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`[Share] Dedup check failed: ${err.message}, proceeding with normal save`);
|
||||
}
|
||||
|
||||
// ── Share link reuse: if same source URL was already saved successfully, validate and reuse ──
|
||||
const reuseEnabled = getSystemConfig('save_reuse_enabled');
|
||||
if (reuseEnabled !== 'false') {
|
||||
try {
|
||||
const existing = db.prepare(
|
||||
`SELECT share_url, share_pwd, folder_name, original_folder_name FROM save_records
|
||||
WHERE source_url = ? AND status IN ('success', 'reused') AND share_url IS NOT NULL AND share_url != ''
|
||||
ORDER BY created_at DESC LIMIT 1`
|
||||
).get(shareUrl) as { share_url: string; share_pwd: string; folder_name: string | null; original_folder_name: string | null } | undefined;
|
||||
|
||||
if (existing?.share_url) {
|
||||
const { LinkValidator } = await import('../validation/link-validator.service');
|
||||
const validator = new LinkValidator();
|
||||
const validation = await validator.validate(existing.share_url, 'quark');
|
||||
if (validation.status === 'valid') {
|
||||
const isFirstReuse = dedupCutoff ? !db.prepare(
|
||||
`SELECT 1 FROM save_records WHERE source_url = ? AND created_at >= ? AND status = 'reused' LIMIT 1`
|
||||
).get(shareUrl, dedupCutoff) : true;
|
||||
const reuseStatus = isFirstReuse ? 'success' : 'reused';
|
||||
const reuseMsg = isFirstReuse
|
||||
? `♻️ 检测到此资源之前已转存过,直接复用已存在的分享链接`
|
||||
: `♻️ 短时间内重复请求,复用已有分享链接`;
|
||||
|
||||
console.log(`[Share] ♻️ Reusing existing share link for ${shareUrl}: ${existing.share_url} (firstReuse=${isFirstReuse})`);
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
cloudType, sourceTitle || null, shareUrl, cloudType,
|
||||
existing.share_url, existing.share_pwd || null,
|
||||
null, 0, 0, 0, reuseStatus, null,
|
||||
existing.folder_name || null, existing.original_folder_name || null,
|
||||
ipAddress || null, ipLocation, localTimestamp(),
|
||||
);
|
||||
return {
|
||||
success: true, message: reuseMsg,
|
||||
share_url: existing.share_url, shareUrl: existing.share_url,
|
||||
sharePwd: existing.share_pwd || '', folderName: '',
|
||||
file_count: 0, folder_count: 0, duration_ms: 0,
|
||||
};
|
||||
}
|
||||
console.log(`[Share] Existing share link for ${shareUrl} is invalid/expired, will re-save`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`[Share] Link reuse check failed: ${err.message}, proceeding with normal save`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unified credential validation ──
|
||||
const credential = await getAndValidateCredential(cloudType);
|
||||
if (!credential.valid || !credential.config) {
|
||||
return { success: false, message: credential.message };
|
||||
}
|
||||
const config = credential.config;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
|
||||
|
||||
switch (cloudType) {
|
||||
case 'quark': {
|
||||
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
break;
|
||||
}
|
||||
case 'baidu': {
|
||||
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
break;
|
||||
}
|
||||
case 'aliyun':
|
||||
return { success: false, message: '阿里云盘保存功能暂未实现' };
|
||||
default:
|
||||
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (driverResult.success) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?`
|
||||
).run(config.id);
|
||||
const nickname = config.nickname || cloudType;
|
||||
notifyConfigEvent(config.id, 'save_success', `✅ 转存成功`, `**${cloudType}** · ${nickname}
|
||||
文件: ${driverResult.folderName || sourceTitle || shareUrl}
|
||||
耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`, 'info', {
|
||||
file_name: driverResult.folderName || sourceTitle || shareUrl || '',
|
||||
file_size: '',
|
||||
cloud_type: cloudType,
|
||||
nickname: nickname || '',
|
||||
duration: ((Date.now() - startTime) / 1000).toFixed(1),
|
||||
share_url: shareUrl,
|
||||
});
|
||||
} else if ((driverResult as any).cookieExpired) {
|
||||
// Cookie expired — don't count as failure, user needs to re-login
|
||||
notifyConfigEvent(config.id, 'cookie_expire', `⚠️ Cookie过期`, `**${cloudType}** · ${config.nickname || '未知'}
|
||||
链接: ${shareUrl}
|
||||
请重新登录`, 'error', {
|
||||
cloud_type: cloudType,
|
||||
nickname: config.nickname || '',
|
||||
share_url: shareUrl,
|
||||
});
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||
).run(config.id);
|
||||
const failCount = (db.prepare(`SELECT consecutive_failures FROM cloud_configs WHERE id = ?`).get(config.id) as any)?.consecutive_failures || 0;
|
||||
if (failCount >= 3) {
|
||||
notifyConfigEvent(config.id, 'save_fail', `❌ 转存连续失败 ${failCount} 次`, `**${cloudType}** · ${config.nickname || '未知'}
|
||||
链接: ${shareUrl}
|
||||
错误: ${driverResult.message}`, 'warn', {
|
||||
file_name: sourceTitle || shareUrl || '',
|
||||
fail_count: String(failCount),
|
||||
cloud_type: cloudType,
|
||||
nickname: config.nickname || '',
|
||||
error: driverResult.message || '',
|
||||
share_url: shareUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, config_id, promotion_account, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType, config.id, config.promotion_account || null,
|
||||
driverResult.shareUrl || null, driverResult.sharePwd || null,
|
||||
null, driverResult.fileCount || 0, driverResult.folderCount || 0,
|
||||
durationMs, driverResult.success ? 'success' : 'failed',
|
||||
driverResult.success ? null : driverResult.message,
|
||||
driverResult.folderName || null, driverResult.originalFolderName || null,
|
||||
ipAddress || null, ipLocation, localTimestamp(),
|
||||
);
|
||||
|
||||
return {
|
||||
success: driverResult.success,
|
||||
message: driverResult.message,
|
||||
share_url: driverResult.shareUrl || '',
|
||||
shareUrl: driverResult.shareUrl,
|
||||
sharePwd: (driverResult as any).sharePwd || '',
|
||||
folderName: driverResult.folderName || '',
|
||||
file_count: driverResult.fileCount || 0,
|
||||
folder_count: driverResult.folderCount || 0,
|
||||
duration_ms: durationMs,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const errorMessage = err.message || 'Failed to save to cloud';
|
||||
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||
).run(config.id);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_url, target_cloud, config_id, promotion_account, duration_ms, status, error_message, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(cloudType, shareUrl, cloudType, config.id, config.promotion_account || null, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp());
|
||||
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise<SaveResult> {
|
||||
const key = `${cloudType}:${shareUrl}`;
|
||||
|
||||
const inflight = inFlightSaves.get(key);
|
||||
if (inflight) {
|
||||
console.log(`[Share] ⏳ In-flight: ${shareUrl} — another save is already running, awaiting result`);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const promise = doSaveFromShare(shareUrl, cloudType, sourceTitle, ipAddress);
|
||||
inFlightSaves.set(key, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
inFlightSaves.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save Records ──────────────────────────────────────────────────
|
||||
|
||||
export function getSaveRecords(page: number = 1, pageSize: number = 20, startDate?: string, endDate?: string, status?: string, sourceType?: string, keyword?: string): { total: number; records: SaveRecord[]; summary?: { total: number; success: number; failed: number; reused: number } } {
|
||||
const db = getDb();
|
||||
const offset = (page - 1) * pageSize;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
const summaryConditions: string[] = [];
|
||||
const summaryParams: any[] = [];
|
||||
if (startDate) {
|
||||
conditions.push('created_at >= ?'); params.push(startDate);
|
||||
summaryConditions.push('created_at >= ?'); summaryParams.push(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
conditions.push('created_at < ?'); params.push(endDate);
|
||||
summaryConditions.push('created_at < ?'); summaryParams.push(endDate);
|
||||
}
|
||||
if (status) { conditions.push('status = ?'); params.push(status); }
|
||||
if (sourceType) {
|
||||
conditions.push('source_type = ?'); params.push(sourceType);
|
||||
summaryConditions.push('source_type = ?'); summaryParams.push(sourceType);
|
||||
}
|
||||
if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); }
|
||||
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${where}`).get(...params) as any).count;
|
||||
const records = db.prepare(
|
||||
`SELECT * FROM save_records ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(...params, pageSize, offset) as SaveRecord[];
|
||||
|
||||
const summaryWhere = summaryConditions.length > 0 ? 'WHERE ' + summaryConditions.join(' AND ') : '';
|
||||
const summaryRows = db.prepare(
|
||||
`SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere} GROUP BY status`
|
||||
).all(...summaryParams) as { status: string; cnt: number }[];
|
||||
let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0;
|
||||
for (const r of summaryRows) {
|
||||
sumTotal += r.cnt;
|
||||
if (r.status === 'success') sumSuccess = r.cnt;
|
||||
else if (r.status === 'failed') sumFailed = r.cnt;
|
||||
else if (r.status === 'reused') sumReused = r.cnt;
|
||||
}
|
||||
const summary = { total: sumTotal, success: sumSuccess, failed: sumFailed, reused: sumReused };
|
||||
|
||||
return { total, records, summary };
|
||||
}
|
||||
|
||||
export function cleanupOldSaveRecords(): void {
|
||||
const db = getDb();
|
||||
const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000));
|
||||
const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff);
|
||||
console.log(`[Cleanup] Deleted ${deleted.changes} save records older than 60 days (before ${cutoff})`);
|
||||
}
|
||||
|
||||
// ── Storage Refresh ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refresh storage info for all active cloud configs that have a getStorageInfo method.
|
||||
* Supports quark and baidu drivers.
|
||||
*/
|
||||
export async function refreshAllStorageInfo(): Promise<void> {
|
||||
const configs = getActiveCloudConfigs().filter(c => c.cookie);
|
||||
if (configs.length === 0) return;
|
||||
|
||||
// Driver mapping: cloud_type → { module, class }
|
||||
const DRIVER_REGISTRY: Record<string, { module: string; cls: string }> = {
|
||||
quark: { module: './drivers/quark.driver', cls: 'QuarkDriver' },
|
||||
baidu: { module: './drivers/baidu.driver', cls: 'BaiduDriver' },
|
||||
};
|
||||
|
||||
for (const cfg of configs) {
|
||||
const entry = DRIVER_REGISTRY[cfg.cloud_type];
|
||||
if (!entry) continue; // no getStorageInfo support for this cloud type
|
||||
|
||||
try {
|
||||
const mod = require(entry.module);
|
||||
const Driver = mod[entry.cls];
|
||||
if (!Driver) continue;
|
||||
|
||||
const driver = new Driver({ cookie: cfg.cookie, nickname: cfg.nickname });
|
||||
|
||||
// Try getStorageInfo (now uses fast /member API for accurate data)
|
||||
let storage: any;
|
||||
try {
|
||||
storage = await driver.getStorageInfo();
|
||||
} catch {
|
||||
if (typeof driver.getStorageInfoQuick === 'function') {
|
||||
storage = await driver.getStorageInfoQuick();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!storage) continue;
|
||||
|
||||
// Get formatted strings — some drivers return {used, total, usedBytes, totalBytes}
|
||||
const used = storage.used || '计算中...';
|
||||
const total = storage.total || '-';
|
||||
|
||||
// Only update if we got meaningful data
|
||||
const hasRealData =
|
||||
(storage.totalBytes > 0 || storage.usedBytes > 0) || // quark returns these
|
||||
(used !== '-' && used !== '0 B' && used !== '计算中...'); // baidu check
|
||||
|
||||
if (hasRealData) {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?`
|
||||
).run(used, total, cfg.id);
|
||||
console.log(`[Storage] Refreshed ${cfg.cloud_type}#${cfg.id}: ${used} / ${total}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Storage] Failed to refresh ${cfg.cloud_type}#${cfg.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
|
||||
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||
|
||||
export interface CloudConfig {
|
||||
id: number;
|
||||
cloud_type: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
is_active: number;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
|
||||
last_checkin_at?: string;
|
||||
checkin_message?: string;
|
||||
consecutive_failures: number;
|
||||
last_used_at?: string;
|
||||
total_saves: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
verification_status?: string;
|
||||
}
|
||||
|
||||
// ── 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];
|
||||
m = cookie.match(/b-user-id=([a-zA-Z0-9-]+)/);
|
||||
if (m) return m[1];
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── Config CRUD ──────────────────────────────────────────────────
|
||||
|
||||
export function getCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return 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 ORDER BY id ASC`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
export function getAvailableClouds(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, 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
|
||||
FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
/** Returns the first active config matching the given cloud type. */
|
||||
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
|
||||
const db = getDb();
|
||||
const cfg = db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, 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();
|
||||
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). */
|
||||
export function getActiveCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return 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
|
||||
FROM cloud_configs WHERE is_active = 1
|
||||
ORDER BY cloud_type ASC, id ASC`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
export function saveCloudConfig(data: {
|
||||
id?: number;
|
||||
cloud_type: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
cookie_uid?: string;
|
||||
promotion_account?: string;
|
||||
is_active?: number;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
}): CloudConfig {
|
||||
const db = getDb();
|
||||
|
||||
const cookieUidForUpdate = data.cookie ? extractCookieUid(data.cookie) : null;
|
||||
const encryptedCookie = data.cookie ? encrypt(data.cookie) : null;
|
||||
|
||||
if (data.id) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
cloud_type = COALESCE(?, cloud_type),
|
||||
cookie = COALESCE(?, cookie),
|
||||
nickname = COALESCE(?, nickname),
|
||||
cookie_uid = COALESCE(?, cookie_uid),
|
||||
promotion_account = COALESCE(?, promotion_account),
|
||||
is_active = COALESCE(?, is_active),
|
||||
storage_used = COALESCE(?, storage_used),
|
||||
storage_total = COALESCE(?, storage_total),
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.id);
|
||||
} else {
|
||||
const existing = db.prepare(
|
||||
'SELECT id, nickname FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
|
||||
).get(data.cloud_type) as any;
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
cookie = COALESCE(?, cookie),
|
||||
nickname = COALESCE(?, nickname),
|
||||
cookie_uid = COALESCE(?, cookie_uid),
|
||||
promotion_account = COALESCE(?, promotion_account),
|
||||
is_active = COALESCE(?, is_active),
|
||||
storage_used = COALESCE(?, storage_used),
|
||||
storage_total = COALESCE(?, storage_total),
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.id);
|
||||
} else {
|
||||
db.prepare(
|
||||
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, cookie_uid, promotion_account, is_active, storage_used, storage_total, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)'
|
||||
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null);
|
||||
}
|
||||
}
|
||||
|
||||
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
FROM cloud_configs WHERE id = ?`
|
||||
).get(savedId) as CloudConfig;
|
||||
}
|
||||
|
||||
export function deleteCloudConfig(id: number): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ── Cookie Validation ────────────────────────────────────────────
|
||||
|
||||
async function fetchQuarkNickname(cookie: string): Promise<string | null> {
|
||||
const MAX_RETRIES = 2;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await fetch('https://pan.quark.cn/account/info', {
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://pan.quark.cn/',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as any;
|
||||
if (data?.data?.nickname) return data.data.nickname;
|
||||
} catch {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function testCloudConnection(id: number): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
nickname?: string;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
}> {
|
||||
const config = getCloudConfigById(id);
|
||||
if (!config) {
|
||||
return { success: false, message: 'Cloud config not found' };
|
||||
}
|
||||
|
||||
if (!config.cookie) {
|
||||
return { success: false, message: 'Cookie not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
let valid = false;
|
||||
let nickname = '';
|
||||
let storageUsed = config.storage_used || '';
|
||||
let storageTotal = config.storage_total || '';
|
||||
|
||||
if (config.cloud_type === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||
const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
const info = await driver.getUserInfo();
|
||||
if (info) {
|
||||
nickname = config.nickname || info.nickname || '百度网盘';
|
||||
const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB';
|
||||
storageUsed = fmt(info.usedBytes);
|
||||
storageTotal = fmt(info.totalBytes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
nickname = config.nickname || (await fetchQuarkNickname(config.cookie)) || '夸克网盘';
|
||||
const storage = await driver.getStorageInfoQuick();
|
||||
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
if (!valid) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), id);
|
||||
return { success: false, message: '连接失败:Cookie 无效或已过期,或网络暂时异常' };
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
|
||||
).run(nickname, storageTotal, storageUsed, localTimestamp(), id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
nickname,
|
||||
storage_used: storageUsed,
|
||||
storage_total: storageTotal,
|
||||
};
|
||||
} catch (err: any) {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), id);
|
||||
} catch {}
|
||||
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
nickname?: string;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
}> {
|
||||
try {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie, nickname: '' });
|
||||
const valid = await driver.validate();
|
||||
if (!valid) {
|
||||
return { success: false, message: '连接失败:Cookie 无效或已过期' };
|
||||
}
|
||||
const nickname = (await fetchQuarkNickname(cookie)) || cloudType;
|
||||
const storage = await driver.getStorageInfo();
|
||||
return {
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
nickname,
|
||||
storage_used: storage.used,
|
||||
storage_total: storage.total,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unified Credential Validation ─────────────────────────────────
|
||||
|
||||
export interface CredentialValidationResult {
|
||||
valid: boolean;
|
||||
config?: CloudConfig;
|
||||
errorCode?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and validate a credential for the given cloud type.
|
||||
*
|
||||
* This is the unified entry point for all save/transfer operations.
|
||||
* It handles:
|
||||
* 1. Finding an active config with < 5 consecutive failures (round-robin)
|
||||
* 2. Validating cookie freshness via driver.validate()
|
||||
* 3. Returning structured result with error codes
|
||||
*
|
||||
* Reference: search-ucmao get_and_validate_credential() pattern.
|
||||
*/
|
||||
export async function getAndValidateCredential(cloudType: string): Promise<CredentialValidationResult> {
|
||||
const db = getDb();
|
||||
|
||||
const config = db.prepare(
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = ? AND is_active = 1
|
||||
AND consecutive_failures < 5
|
||||
ORDER BY last_used_at ASC NULLS FIRST
|
||||
LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'NO_AVAILABLE_DRIVE',
|
||||
message: `Cloud type "${cloudType}" is not configured or no available drives`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.cookie) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'COOKIE_MISSING',
|
||||
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let cookieValid = false;
|
||||
if (cloudType === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||
const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
cookieValid = await driver.validate();
|
||||
} else {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
cookieValid = await driver.validate();
|
||||
}
|
||||
|
||||
if (!cookieValid) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), config.id);
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'COOKIE_EXPIRED',
|
||||
message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
config,
|
||||
message: 'ok',
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'VALIDATION_ERROR',
|
||||
message: `Credential validation failed: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
// @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 };
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import crypto from "crypto";
|
||||
|
||||
const HOMOPHONE_MAP = {
|
||||
// 网盘热门番名 — 谐音替换 (same sound, different char)
|
||||
'斗': '陡', '破': '坡', '苍': '仓', '穹': '穷',
|
||||
'完': '玩', '美': '每', '世': '士', '界': '介',
|
||||
'凡': '烦', '人': '仁', '修': '休', '罗': '络',
|
||||
'仙': '先', '逆': '腻', '遮': '折', '天': '添',
|
||||
'吞': '屯', '噬': '逝', '大': '达', '主': '嘱', '宰': '崽',
|
||||
'星': '惺', '辰': '晨', '变': '便', '一': '伊', '念': '捻',
|
||||
'永': '泳', '恒': '横', '神': '申', '墓': '暮', '长': '尝', '生': '甥',
|
||||
'剑': '箭', '来': '莱', '诡': '鬼', '秘': '蜜',
|
||||
'全': '泉', '职': '值', '盘': '磐', '龙': '笼',
|
||||
'雪': '血', '鹰': '莺', '莽': '蟒', '荒': '慌', '纪': '记',
|
||||
'珠': '株', '王': '亡', '座': '坐', '牧': '木', '记': '计',
|
||||
'沧': '舱', '元': '圆', '图': '涂', '紫': '仔', '川': '串',
|
||||
'百': '白', '炼': '恋', '成': '程', '饶': '绕', '命': '冥',
|
||||
// 通用谐音替换
|
||||
'的': '得', '了': '啦', '是': '事', '不': '布', '我': '窝',
|
||||
'你': '尼', '他': '她', '有': '友', '和': '合', '与': '予',
|
||||
'上': '尚', '下': '夏', '中': '忠', '第': '弟', '集': '级',
|
||||
'话': '划', '季': '际', '年': '念', '月': '阅', '日': '曰',
|
||||
'新': '心', '版': '板', '高': '糕', '清': '青', '原': '源',
|
||||
'小': '晓', '片': '篇', '视': '市', '频': '贫', '道': '到',
|
||||
'动': '洞', '画': '话', '声': '升', '音': '因', '文': '闻',
|
||||
'明': '名', '暗': '黯', '光': '广', '影': '映', '色': '瑟',
|
||||
'风': '疯', '雨': '语', '花': '华', '国': '果', '家': '佳',
|
||||
'战': '站', '争': '挣', '士': '仕', '兵': '宾',
|
||||
'皇': '惶', '帝': '谛', '魔': '磨', '鬼': '诡', '怪': '乖',
|
||||
'精': '经', '灵': '铃', '妖': '夭', '武': '舞', '侠': '狭',
|
||||
'杀': '刹', '血': '雪', '刀': '叨', '枪': '呛', '炮': '泡',
|
||||
'时': '石', '空': '孔', '前': '钱', '后': '厚', '东': '冬',
|
||||
'南': '难', '西': '夕', '北': '备', '开': '凯', '关': '官',
|
||||
'出': '初', '进': '近', '去': '趣',
|
||||
'短': '短', '多': '多', '少': '少', '真': '贞', '假': '价',
|
||||
'好': '郝', '坏': '怀', '对': '队', '错': '措', '以': '已',
|
||||
'从': '从', '被': '被', '把': '把', '将': '将', '在': '在',
|
||||
'但': '但', '就': '就', '才': '才', '也': '也', '很': '狠',
|
||||
'又': '又', '再': '再', '更': '更', '最': '最', '总': '总',
|
||||
'共': '共', '只': '只', '各': '各', '每': '每', '任': '任',
|
||||
'所': '所', '该': '该', '本': '本',
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||
*
|
||||
* Flow: token → detail → save → wait_task → rename → share
|
||||
*/
|
||||
export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle) {
|
||||
export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, retrySave = false) {
|
||||
try {
|
||||
// Parse share token from URL
|
||||
const urlObj = new URL(shareUrl);
|
||||
@@ -69,8 +69,10 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle) {
|
||||
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 saveDirName = retrySave
|
||||
? quark_api.dailyFolderName() + '_' + Math.random().toString(36).slice(2, 6)
|
||||
: quark_api.dailyFolderName();
|
||||
console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"${retrySave ? ' (retry)' : ''}`);
|
||||
const saveDirFid = await findOrCreateDir(cookie, saveDirName);
|
||||
const targetPdirFid = saveDirFid || '0';
|
||||
if (saveDirFid) {
|
||||
|
||||
@@ -98,8 +98,8 @@ export class QuarkDriver {
|
||||
}
|
||||
|
||||
// ==================== Storage (Save from Share) ====================
|
||||
async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<any> {
|
||||
return saveFromShare(this.cookie, this.config.nickname || '', shareUrl, sourceTitle || '');
|
||||
async saveFromShare(shareUrl: string, sourceTitle?: string, retrySave?: boolean): Promise<any> {
|
||||
return saveFromShare(this.cookie, this.config.nickname || '', shareUrl, sourceTitle || '', retrySave);
|
||||
}
|
||||
|
||||
async createDir(dirName: string): Promise<any> {
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import config from '../config';
|
||||
import { formatLocalDateTime } from '../utils/time';
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const dbDir = path.dirname(config.dbPath);
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(config.dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Performance indexes (IF NOT EXISTS ensures idempotent)
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_type_active ON cloud_configs(cloud_type, is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_uid ON cloud_configs(cookie_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_verification ON cloud_configs(verification_status);
|
||||
`);
|
||||
|
||||
runMigrations(db);
|
||||
seedAdmin(db);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function runMigrations(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
last_login TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cloud_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_type TEXT NOT NULL,
|
||||
cookie TEXT,
|
||||
nickname TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
storage_used TEXT,
|
||||
storage_total TEXT,
|
||||
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||
last_checkin_at TEXT,
|
||||
checkin_message TEXT,
|
||||
consecutive_failures INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
total_saves INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS promotions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
link_url TEXT,
|
||||
position TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
click_count INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS save_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_type TEXT,
|
||||
source_title TEXT,
|
||||
source_url TEXT,
|
||||
target_cloud TEXT,
|
||||
share_url TEXT,
|
||||
share_pwd TEXT,
|
||||
file_size TEXT,
|
||||
file_count INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS search_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT,
|
||||
intent TEXT,
|
||||
result_count INTEGER DEFAULT 0,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hot_keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT UNIQUE NOT NULL,
|
||||
search_count INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_configs (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
description TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
tags TEXT,
|
||||
cover TEXT,
|
||||
source TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
`);
|
||||
seedSystemConfigs(db);
|
||||
migrateSaveRecords(db);
|
||||
migrateContentCache(db);
|
||||
migrateCloudConfigs(db);
|
||||
cleanupOldSaveRecords(db);
|
||||
}
|
||||
|
||||
/** 迁移: 给已有 save_records 表补充新列 */
|
||||
function migrateSaveRecords(db: Database.Database): void {
|
||||
const newCols: { col: string; def: string }[] = [
|
||||
{ col: 'share_pwd', def: 'TEXT' },
|
||||
{ col: 'file_count', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'folder_count', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'duration_ms', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'status', def: "TEXT NOT NULL DEFAULT ''" },
|
||||
{ col: 'error_message', def: 'TEXT' },
|
||||
{ col: 'folder_name', def: 'TEXT' },
|
||||
{ col: 'request_url', def: 'TEXT' },
|
||||
{ col: 'ip_location', def: 'TEXT' },
|
||||
{ col: 'original_folder_name', def: 'TEXT' },
|
||||
];
|
||||
for (const { col, def } of newCols) {
|
||||
try {
|
||||
db.exec(`ALTER TABLE save_records ADD COLUMN ${col} ${def}`);
|
||||
} catch {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移: 给 content_cache 表加 douban_url 列 */
|
||||
function migrateContentCache(db: Database.Database): void {
|
||||
const columns: { col: string; def: string }[] = [
|
||||
{ col: 'douban_url', def: 'TEXT' },
|
||||
{ col: 'rating', def: 'TEXT' },
|
||||
{ col: 'rating_count', def: 'TEXT' },
|
||||
{ col: 'year', def: 'TEXT' },
|
||||
{ col: 'genres', def: 'TEXT' },
|
||||
{ col: 'directors', def: 'TEXT' },
|
||||
{ col: 'actors', def: 'TEXT' },
|
||||
{ col: 'region', def: 'TEXT' },
|
||||
{ col: 'duration', def: 'TEXT' },
|
||||
];
|
||||
for (const { col, def } of columns) {
|
||||
try {
|
||||
db.exec(`ALTER TABLE content_cache ADD COLUMN ${col} ${def}`);
|
||||
} catch {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
// 修复旧记录:source 为 NULL 但实际有 TMDB 数据的,标记为 tmdb
|
||||
db.exec(`UPDATE content_cache SET source = 'tmdb' WHERE source IS NULL AND title IS NOT NULL AND title != ''`);
|
||||
}
|
||||
|
||||
/** 迁移: 给 cloud_configs 表去UNIQUE约束 + 加签到/轮训字段 */
|
||||
function migrateCloudConfigs(db: Database.Database): void {
|
||||
// 加新列
|
||||
const newCols: { col: string; def: string }[] = [
|
||||
{ col: 'checkin_status', def: "TEXT NOT NULL DEFAULT 'none'" },
|
||||
{ col: 'last_checkin_at', def: 'TEXT' },
|
||||
{ col: 'checkin_message', def: 'TEXT' },
|
||||
{ col: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'last_used_at', def: 'TEXT' },
|
||||
{ col: 'total_saves', def: 'INTEGER DEFAULT 0' },
|
||||
];
|
||||
for (const { col, def } of newCols) {
|
||||
try { db.exec(`ALTER TABLE cloud_configs ADD COLUMN ${col} ${def}`); } catch {}
|
||||
}
|
||||
// 检查旧表是否有 UNIQUE 约束,有则重建表
|
||||
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='cloud_configs'`).get() as any;
|
||||
if (row && row.sql && row.sql.includes('cloud_type TEXT UNIQUE')) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cloud_configs_v2 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_type TEXT NOT NULL,
|
||||
cookie TEXT,
|
||||
nickname TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
storage_used TEXT,
|
||||
storage_total TEXT,
|
||||
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||
last_checkin_at TEXT,
|
||||
checkin_message TEXT,
|
||||
consecutive_failures INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
total_saves INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
INSERT INTO cloud_configs_v2 (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)
|
||||
SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs;
|
||||
DROP TABLE cloud_configs;
|
||||
ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs;
|
||||
`);
|
||||
console.log('[DB] cloud_configs migration: UNIQUE constraint removed, new fields added');
|
||||
}
|
||||
|
||||
// Migration 2: Add verification_status column
|
||||
const row2 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%verification_status%'").get();
|
||||
if (!row2) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN verification_status TEXT DEFAULT NULL");
|
||||
console.log('[DB] cloud_configs migration: verification_status column added');
|
||||
}
|
||||
|
||||
// Migration 3: Add cookie_uid column
|
||||
const hasCookieUid = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cookie_uid%'").get();
|
||||
if (!hasCookieUid) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN cookie_uid TEXT DEFAULT NULL");
|
||||
console.log('[DB] cloud_configs migration: cookie_uid column added');
|
||||
}
|
||||
|
||||
// Migration 4: Add promotion_account column
|
||||
const hasPromotionAccount = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%promotion_account%'").get();
|
||||
if (!hasPromotionAccount) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT NULL");
|
||||
console.log('[DB] cloud_configs migration: promotion_account column added');
|
||||
|
||||
// v0.3.5: notify_config for per-cloud push notification settings
|
||||
const hasNotifyConfig = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%notify_config%'").get();
|
||||
if (!hasNotifyConfig) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN notify_config TEXT DEFAULT NULL");
|
||||
console.log('[DB] cloud_configs migration: notify_config column added');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin(db: Database.Database): void {
|
||||
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
|
||||
if (existing) return;
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hash = bcrypt.hashSync(config.adminPassword, salt);
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO admins (username, password_hash) VALUES (?, ?)'
|
||||
).run(config.adminUsername, hash);
|
||||
|
||||
console.log(`[DB] Admin user "${config.adminUsername}" created`);
|
||||
}
|
||||
|
||||
function seedSystemConfigs(db: Database.Database): void {
|
||||
const defaults: { key: string; value: string; description: string }[] = [
|
||||
{ key: 'pansou_url', value: config.pansouUrl, description: 'PanSou 搜索引擎服务地址' },
|
||||
{ key: 'video_parser_url', value: config.videoParserUrl, description: '视频解析服务地址' },
|
||||
{ key: 'validation_concurrency', value: String(config.validation.concurrency), description: '链接验证并发数' },
|
||||
{ key: 'validation_timeout', value: String(config.validation.timeout), description: '链接验证超时(ms)' },
|
||||
{ key: 'validation_cache_ttl_valid', value: String(config.validation.cacheTtlValid), description: '有效链接缓存时间(s)' },
|
||||
{ key: 'validation_cache_ttl_invalid', value: String(config.validation.cacheTtlInvalid), description: '无效链接缓存时间(s)' },
|
||||
{ key: 'search_proxy_enabled', value: 'false', description: '搜索代理开关(true/false)' },
|
||||
{ key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' },
|
||||
{ key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' },
|
||||
{ key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' },
|
||||
{ key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' },
|
||||
{ key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' },
|
||||
{ key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' },
|
||||
{ key: 'cloud_enabled_115', value: 'true', description: '115 网盘' },
|
||||
{ key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' },
|
||||
{ key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' },
|
||||
{ key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' },
|
||||
{ key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' },
|
||||
{ key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' },
|
||||
{ key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' },
|
||||
{ key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' },
|
||||
{ key: 'cloud_enabled_others', value: 'false', description: '其他类型(默认关闭)' },
|
||||
{ key: 'search_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' },
|
||||
{ key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' },
|
||||
{ key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' },
|
||||
{ key: 'site_name', value: 'CloudSearch', description: '网站名称(显示在首页标题/页脚)' },
|
||||
{ key: 'site_disclaimer', value: '本站为非盈利性个人站点,所有资源仅供学习、研究使用,版权归原作者所有。请于下载后24小时内删除,切勿用于商业或非法用途。若侵犯了您的权益,请联系我们(邮箱:3337598077@qq.com),我们将及时处理。', description: '网站底部免责声明' },
|
||||
{ key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' },
|
||||
{ key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' },
|
||||
{ key: 'ip_geo_api_url', value: '', description: 'IP 归属地查询接口({ip} 会被替换为实际IP,留空则禁用)' },
|
||||
{ key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' },
|
||||
{ key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' },
|
||||
{ key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' },
|
||||
{ key: 'redis_url', value: 'redis://redis:6379', description: 'Redis 连接地址(用于缓存优化)' },
|
||||
{ key: 'pansou_auth_token', value: '', description: 'PanSou API 认证令牌(用于私有搜索服务)' },
|
||||
{ key: 'pansou_web_enabled', value: 'false', description: '启用 PanSou Web 端访问(在 /pansou 路径提供 PanSou 搜索引擎管理界面)' },
|
||||
{ key: 'cleanup_enabled', value: 'true', description: '启用自动清理(每天检查一次,移入回收站+清空日志+清空回收站)' },
|
||||
{ key: 'cleanup_file_retention_days', value: '7', description: '云盘文件保留天数(超过此天数的日期文件夹将被移入回收站)' },
|
||||
{ key: 'cleanup_log_retention_days', value: '30', description: '转存日志保留天数' },
|
||||
{ key: 'cleanup_empty_trash', value: 'true', description: '清理时是否清空回收站(永久删除释放空间)' },
|
||||
{ key: 'cleanup_space_threshold_enabled', value: 'false', description: '启用空间阈值自动清理(已用空间超过XX%时按比例删除最旧的转存文件)' },
|
||||
{ key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' },
|
||||
{ key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' },
|
||||
{ key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' },
|
||||
{ key: 'cleanup_last_run', value: '', description: '上次自动清理时间' },
|
||||
{ key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' },
|
||||
];
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
||||
);
|
||||
for (const entry of defaults) {
|
||||
insert.run(entry.key, entry.value, entry.description);
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理 60 天前的转存记录 */
|
||||
function cleanupOldSaveRecords(db: Database.Database): void {
|
||||
const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000));
|
||||
const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff);
|
||||
console.log(`[DB] Cleaned up ${deleted.changes} save records older than 60 days (before ${cutoff})`);
|
||||
}
|
||||
|
||||
export default getDb;
|
||||
Reference in New Issue
Block a user