From 7f4ab505575af542cc5301829cb41c66514f1029 Mon Sep 17 00:00:00 2001 From: admin <362324317@qq.com> Date: Mon, 18 May 2026 01:06:28 +0800 Subject: [PATCH] =?UTF-8?q?v0.3.49:=20Dedup=20validation=20=E2=80=94=20val?= =?UTF-8?q?idate=20cached=20link=20before=20returning=20to=20avoid=20showi?= =?UTF-8?q?ng=20invalid=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- source_clean/VERSION | 2 +- .../src/pages/admin/SystemConfig.vue | 1 - source_clean/src/cloud/cloud.service.ts | 64 +-- source_clean/src/cloud/cloud.service.ts.bak | 389 ++++++++++++++++++ source_clean/src/cloud/cloud.service.ts.bak3 | 389 ++++++++++++++++++ .../src/cloud/drivers/quark-ad-cleanup.ts | 2 +- .../src/cloud/drivers/quark-ad-cleanup.ts.bak | 230 +++++++++++ .../src/cloud/drivers/quark-rename.ts | 2 +- .../src/cloud/drivers/quark-rename.ts.bak | 244 +++++++++++ source_clean/src/database/database.ts | 3 + source_clean/src/database/database.ts.bak | 334 +++++++++++++++ .../src/validation/link-validator.service.ts | 10 +- 13 files changed, 1635 insertions(+), 37 deletions(-) create mode 100644 source_clean/src/cloud/cloud.service.ts.bak create mode 100644 source_clean/src/cloud/cloud.service.ts.bak3 create mode 100644 source_clean/src/cloud/drivers/quark-ad-cleanup.ts.bak create mode 100644 source_clean/src/cloud/drivers/quark-rename.ts.bak create mode 100755 source_clean/src/database/database.ts.bak diff --git a/VERSION b/VERSION index b463c01..b3682c1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.41 +0.3.49 diff --git a/source_clean/VERSION b/source_clean/VERSION index a02af24..b3682c1 100644 --- a/source_clean/VERSION +++ b/source_clean/VERSION @@ -1 +1 @@ -0.3.40 +0.3.49 diff --git a/source_clean/frontend-src/src/pages/admin/SystemConfig.vue b/source_clean/frontend-src/src/pages/admin/SystemConfig.vue index e03cc09..9c62064 100644 --- a/source_clean/frontend-src/src/pages/admin/SystemConfig.vue +++ b/source_clean/frontend-src/src/pages/admin/SystemConfig.vue @@ -828,7 +828,6 @@ const searchAllChannels = computed({ set: (val: boolean) => { configs.search_all_channels = val ? 'true' : 'false' }, }) -const autoUpdateEnabled = computed({ get: () => String(configs.auto_update_enabled) === 'true', set: (val: boolean) => { configs.auto_update_enabled = val ? 'true' : 'false' }, }) diff --git a/source_clean/src/cloud/cloud.service.ts b/source_clean/src/cloud/cloud.service.ts index 3c78db8..8604c95 100644 --- a/source_clean/src/cloud/cloud.service.ts +++ b/source_clean/src/cloud/cloud.service.ts @@ -58,35 +58,51 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? dedupCutoff = recentCutoff.cutoff; const recentRecord = db.prepare( - `SELECT share_url, share_pwd, status, error_message, folder_name, original_folder_name FROM save_records + `SELECT share_url, share_pwd, status, file_size, 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; + share_url: string | null; share_pwd: string | null; status: string; file_size: string | null; 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, - }; + // Validate the cached link before returning — avoid returning an invalid link + let dedupLinkInvalid = false; + try { + const { LinkValidator: DedupLinkValidator } = await import('../validation/link-validator.service'); + const dedupValidator = new DedupLinkValidator(); + const dedupValidation = await dedupValidator.validate(recentRecord.share_url, 'quark'); + if (dedupValidation.status !== 'valid') { + dedupLinkInvalid = true; + console.log(`[Share] 🛡️ Dedup link invalid (${dedupValidation.message}), falling through to normal save`); + } + } catch (err: any) { + console.log(`[Share] 🛡️ Dedup validation error: ${err.message}, falling through`); + dedupLinkInvalid = true; + } + if (!dedupLinkInvalid) { + 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, + recentRecord.file_size || 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) { @@ -98,10 +114,10 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? if (reuseEnabled !== 'false') { try { const existing = db.prepare( - `SELECT share_url, share_pwd, folder_name, original_folder_name FROM save_records + `SELECT share_url, share_pwd, file_size, 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; + ).get(shareUrl) as { share_url: string; share_pwd: string; file_size: string | null; folder_name: string | null; original_folder_name: string | null } | undefined; if (existing?.share_url) { const { LinkValidator } = await import('../validation/link-validator.service'); @@ -123,7 +139,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? ).run( cloudType, sourceTitle || null, shareUrl, cloudType, existing.share_url, existing.share_pwd || null, - null, 0, 0, 0, reuseStatus, null, + existing.file_size || null, 0, 0, 0, reuseStatus, null, existing.folder_name || null, existing.original_folder_name || null, ipAddress || null, ipLocation, localTimestamp(), ); @@ -220,7 +236,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle? ).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, + (driverResult as any).fileSize || null, driverResult.fileCount || 0, driverResult.folderCount || 0, durationMs, driverResult.success ? 'success' : 'failed', driverResult.success ? null : driverResult.message, driverResult.folderName || null, driverResult.originalFolderName || null, diff --git a/source_clean/src/cloud/cloud.service.ts.bak b/source_clean/src/cloud/cloud.service.ts.bak new file mode 100644 index 0000000..3c78db8 --- /dev/null +++ b/source_clean/src/cloud/cloud.service.ts.bak @@ -0,0 +1,389 @@ +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>(); + +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 { + 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 { + 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 { + const configs = getActiveCloudConfigs().filter(c => c.cookie); + if (configs.length === 0) return; + + // Driver mapping: cloud_type → { module, class } + const DRIVER_REGISTRY: Record = { + 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); + } + } +} diff --git a/source_clean/src/cloud/cloud.service.ts.bak3 b/source_clean/src/cloud/cloud.service.ts.bak3 new file mode 100644 index 0000000..92768ce --- /dev/null +++ b/source_clean/src/cloud/cloud.service.ts.bak3 @@ -0,0 +1,389 @@ +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>(); + +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 { + 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, file_size, 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; file_size: string | null; + 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, + recentRecord.file_size || 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, file_size, 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; file_size: string | null; 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, + existing.file_size || 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, + (driverResult as any).fileSize || 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 { + 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 { + const configs = getActiveCloudConfigs().filter(c => c.cookie); + if (configs.length === 0) return; + + // Driver mapping: cloud_type → { module, class } + const DRIVER_REGISTRY: Record = { + 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); + } + } +} diff --git a/source_clean/src/cloud/drivers/quark-ad-cleanup.ts b/source_clean/src/cloud/drivers/quark-ad-cleanup.ts index bd94a37..7bbdc40 100644 --- a/source_clean/src/cloud/drivers/quark-ad-cleanup.ts +++ b/source_clean/src/cloud/drivers/quark-ad-cleanup.ts @@ -73,7 +73,7 @@ export async function deleteAdFiles(cookie, dirFid, keywords) { const toKeep = []; const extensions = getSusExtensions(); for (const file of files) { - const ext = file.file.split(".").pop()?.toLowerCase() || ""; + const ext = file.file_name.split(".").pop()?.toLowerCase() || ""; const isSusExt = extensions.includes(ext); if (containsAdKeyword(file.file_name, keywords) || isSusExt) { toDelete.push(file.fid); diff --git a/source_clean/src/cloud/drivers/quark-ad-cleanup.ts.bak b/source_clean/src/cloud/drivers/quark-ad-cleanup.ts.bak new file mode 100644 index 0000000..bd94a37 --- /dev/null +++ b/source_clean/src/cloud/drivers/quark-ad-cleanup.ts.bak @@ -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-rename.ts b/source_clean/src/cloud/drivers/quark-rename.ts index b4c9cd4..bfc9793 100644 --- a/source_clean/src/cloud/drivers/quark-rename.ts +++ b/source_clean/src/cloud/drivers/quark-rename.ts @@ -134,7 +134,7 @@ export function magicRenameDir(dirName) { 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)]; + const ink = NOISE_CJK[Math.floor(Math.random() * NOISE_CJK.length)]; baseName = baseName.slice(0, pos) + ink + baseName.slice(pos); } } diff --git a/source_clean/src/cloud/drivers/quark-rename.ts.bak b/source_clean/src/cloud/drivers/quark-rename.ts.bak new file mode 100644 index 0000000..b4c9cd4 --- /dev/null +++ b/source_clean/src/cloud/drivers/quark-rename.ts.bak @@ -0,0 +1,244 @@ +// @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; +} diff --git a/source_clean/src/database/database.ts b/source_clean/src/database/database.ts index 4f6ad4d..9003011 100755 --- a/source_clean/src/database/database.ts +++ b/source_clean/src/database/database.ts @@ -315,6 +315,9 @@ function seedSystemConfigs(db: Database.Database): void { { key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' }, { key: 'cleanup_last_run', value: '', description: '上次自动清理时间' }, { key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' }, + { key: 'search_all_channels', value: 'false', description: '使用所有频道参与搜索(包含未启用频道)' }, + { key: 'ip_geo_provider', value: 'apihz', description: 'IP 归属地查询接口提供商' }, + { key: 'auto_update_enabled', value: 'false', description: '自动更新镜像(预留,暂未实现)' }, ]; const insert = db.prepare( 'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)' diff --git a/source_clean/src/database/database.ts.bak b/source_clean/src/database/database.ts.bak new file mode 100755 index 0000000..4f6ad4d --- /dev/null +++ b/source_clean/src/database/database.ts.bak @@ -0,0 +1,334 @@ +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; diff --git a/source_clean/src/validation/link-validator.service.ts b/source_clean/src/validation/link-validator.service.ts index 23e3ffc..5cf9139 100755 --- a/source_clean/src/validation/link-validator.service.ts +++ b/source_clean/src/validation/link-validator.service.ts @@ -161,20 +161,14 @@ export class LinkValidator { return { url, status: 'valid', cloudType, checkedAt, message: summary }; } - // 2. 自定义确认关键词(用户配置的"有效"信号) - const validKeywords = loadCustomKeywords('link_valid_keywords'); - if (validKeywords.some(kw => summary.includes(kw))) { - return { url, status: 'valid', cloudType, checkedAt, message: summary }; - } - // 3. 自定义失效关键词(用户配置的"失效"信号) const invalidKeywords = loadCustomKeywords('link_invalid_keywords'); if (invalidKeywords.some(kw => summary.includes(kw))) { return { url, status: 'invalid', cloudType, checkedAt, message: summary }; } - // 4. 其余全部返回 unknown - return { url, status: 'unknown', cloudType, checkedAt, message: summary || '盘搜无法确认' }; + // 4. 其余全部返回 valid(无失效关键词命中则有效) + return { url, status: 'valid', cloudType, checkedAt, message: summary || '盘搜验证通过' }; } catch { return null; }