release: v0.4.0

This commit is contained in:
2026-05-18 05:11:57 +08:00
parent b758391861
commit da5bd01535
147 changed files with 8876 additions and 6941 deletions

View File

@@ -1,3 +1,4 @@
import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api';
import { getDb } from '../database/database';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
@@ -184,11 +185,11 @@ async function fetchQuarkNickname(cookie: string): Promise<string | null> {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch('https://pan.quark.cn/account/info', {
const response = await fetch(QUARK_PAN_HOST + QE.ACCOUNT_INFO, {
headers: {
'Cookie': cookie,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://pan.quark.cn/',
'Referer': QUARK_PAN_HOST + '/',
},
signal: AbortSignal.timeout(15000),
});

View File

@@ -0,0 +1,401 @@
import { getDb } from '../database/database';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
export interface CloudConfig {
id: number;
cloud_type: string;
cookie?: string;
nickname?: string;
is_active: number;
storage_used?: string;
storage_total?: string;
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
last_checkin_at?: string;
checkin_message?: string;
consecutive_failures: number;
last_used_at?: string;
total_saves: number;
created_at: string;
updated_at: string;
verification_status?: string;
promotion_account?: string;
cloud_type_uid?: string;
cookie_uid?: string;
}
// ── Cookie UID Extraction ────────────────────────────────────────
function decryptCookie(encrypted: string): string {
if (!encrypted) return '';
if (!isEncrypted(encrypted)) return encrypted;
return decrypt(encrypted);
}
function extractCookieUid(cookie: string): string {
if (!cookie) return '';
let m = cookie.match(/__uid=([a-zA-Z0-9+/=_-]+)/);
if (m) return m[1];
m = cookie.match(/b-user-id=([a-zA-Z0-9-]+)/);
if (m) return m[1];
return '';
}
// ── Config CRUD ──────────────────────────────────────────────────
export function getCloudConfigs(): CloudConfig[] {
const db = getDb();
const rows = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs ORDER BY id ASC`
).all() as CloudConfig[];
rows.forEach(r => { if (r.cookie) r.cookie = decryptCookie(r.cookie); });
return rows;
}
export function getAvailableClouds(): CloudConfig[] {
const db = getDb();
const rows = db.prepare(
`SELECT id, cloud_type, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC`
).all() as CloudConfig[];
rows.forEach(r => { if (r.cookie) r.cookie = decryptCookie(r.cookie); });
return rows;
}
/** Returns the first active config matching the given cloud type. */
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
const db = getDb();
const cfg = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs WHERE cloud_type = ? AND is_active = 1
ORDER BY id ASC LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
if (cfg && cfg.cookie) cfg.cookie = decryptCookie(cfg.cookie);
return cfg;
}
export function getCloudConfigById(id: number): CloudConfig | undefined {
const db = getDb();
const cfg = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs WHERE id = ?`
).get(id) as CloudConfig | undefined;
if (cfg && cfg.cookie) cfg.cookie = decryptCookie(cfg.cookie);
return cfg;
}
/** Returns all active cloud configs (used by save flow for cloud type switching). */
export function getActiveCloudConfigs(): CloudConfig[] {
const db = getDb();
const rows = db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE is_active = 1
ORDER BY cloud_type ASC, id ASC`
).all() as CloudConfig[];
rows.forEach(r => { if (r.cookie) r.cookie = decryptCookie(r.cookie); });
return rows;
}
export function saveCloudConfig(data: {
id?: number;
cloud_type: string;
cookie?: string;
nickname?: string;
cookie_uid?: string;
promotion_account?: string;
is_active?: number;
storage_used?: string;
storage_total?: string;
}): CloudConfig {
const db = getDb();
const cookieUidForUpdate = data.cookie ? extractCookieUid(data.cookie) : null;
const encryptedCookie = data.cookie ? encrypt(data.cookie) : null;
if (data.id) {
db.prepare(
`UPDATE cloud_configs SET
cloud_type = COALESCE(?, cloud_type),
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
cookie_uid = COALESCE(?, cookie_uid),
promotion_account = COALESCE(?, promotion_account),
is_active = COALESCE(?, is_active),
storage_used = COALESCE(?, storage_used),
storage_total = COALESCE(?, storage_total),
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.id);
} else {
const existing = db.prepare(
'SELECT id, nickname FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
).get(data.cloud_type) as any;
if (existing) {
db.prepare(
`UPDATE cloud_configs SET
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
cookie_uid = COALESCE(?, cookie_uid),
promotion_account = COALESCE(?, promotion_account),
is_active = COALESCE(?, is_active),
storage_used = COALESCE(?, storage_used),
storage_total = COALESCE(?, storage_total),
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.id);
} else {
db.prepare(
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, cookie_uid, promotion_account, is_active, storage_used, storage_total, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)'
).run(data.cloud_type, encryptedCookie, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null);
}
}
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, cloud_type_uid, cookie_uid, promotion_account, storage_used, storage_total,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE id = ?`
).get(savedId) as CloudConfig;
}
export function deleteCloudConfig(id: number): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id);
return result.changes > 0;
}
// ── Cookie Validation ────────────────────────────────────────────
async function fetchQuarkNickname(cookie: string): Promise<string | null> {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch('https://pan.quark.cn/account/info', {
headers: {
'Cookie': cookie,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://pan.quark.cn/',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok) return null;
const data = await response.json() as any;
if (data?.data?.nickname) return data.data.nickname;
} catch {
if (attempt < MAX_RETRIES) {
await new Promise(r => setTimeout(r, 1500));
continue;
}
}
}
return null;
}
export async function testCloudConnection(id: number): Promise<{
success: boolean;
message: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
}> {
const config = getCloudConfigById(id);
if (!config) {
return { success: false, message: 'Cloud config not found' };
}
if (!config.cookie) {
return { success: false, message: 'Cookie not configured' };
}
const cookie = decryptCookie(config.cookie);
try {
let valid = false;
let nickname = '';
let storageUsed = config.storage_used || '';
let storageTotal = config.storage_total || '';
if (config.cloud_type === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
const info = await driver.getUserInfo();
if (info) {
nickname = config.nickname || info.nickname || '百度网盘';
const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB';
storageUsed = fmt(info.usedBytes);
storageTotal = fmt(info.totalBytes);
}
}
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘';
const storage = await driver.getStorageInfoQuick();
storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
}
}
const db = getDb();
if (!valid) {
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), id);
return { success: false, message: '连接失败Cookie 无效或已过期,或网络暂时异常' };
}
const cookieUid = extractCookieUid(cookie);
db.prepare(
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, cookie_uid = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
).run(nickname, storageTotal, storageUsed, cookieUid, localTimestamp(), id);
return {
success: true,
message: '连接成功',
nickname,
storage_used: storageUsed,
storage_total: storageTotal,
};
} catch (err: any) {
try {
const db = getDb();
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), id);
} catch {}
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
}
}
export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{
success: boolean;
message: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
}> {
try {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie, nickname: '' });
const valid = await driver.validate();
if (!valid) {
return { success: false, message: '连接失败Cookie 无效或已过期' };
}
const nickname = (await fetchQuarkNickname(cookie)) || cloudType;
const storage = await driver.getStorageInfo();
return {
success: true,
message: '连接成功',
nickname,
storage_used: storage.used,
storage_total: storage.total,
};
} catch (err: any) {
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
}
}
// ── Unified Credential Validation ─────────────────────────────────
export interface CredentialValidationResult {
valid: boolean;
config?: CloudConfig;
errorCode?: string;
message: string;
}
/**
* Get and validate a credential for the given cloud type.
*
* This is the unified entry point for all save/transfer operations.
* It handles:
* 1. Finding an active config with < 5 consecutive failures (round-robin)
* 2. Validating cookie freshness via driver.validate()
* 3. Returning structured result with error codes
*
* Reference: search-ucmao get_and_validate_credential() pattern.
*/
export async function getAndValidateCredential(cloudType: string): Promise<CredentialValidationResult> {
const db = getDb();
const config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
if (!config) {
return {
valid: false,
errorCode: 'NO_AVAILABLE_DRIVE',
message: `Cloud type "${cloudType}" is not configured or no available drives`,
};
}
if (!config.cookie) {
return {
valid: false,
errorCode: 'COOKIE_MISSING',
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
};
}
const cookie = decryptCookie(config.cookie);
try {
let cookieValid = false;
if (cloudType === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
cookieValid = await driver.validate();
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
cookieValid = await driver.validate();
}
if (!cookieValid) {
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), config.id);
return {
valid: false,
errorCode: 'COOKIE_EXPIRED',
message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`,
};
}
config.cookie = cookie;
return {
valid: true,
config,
message: 'ok',
};
} catch (err: any) {
return {
valid: false,
errorCode: 'VALIDATION_ERROR',
message: `Credential validation failed: ${err.message}`,
};
}
}

View File

@@ -0,0 +1,26 @@
// @ts-nocheck
// ===================================================================
// 阿里云盘 API 统一模块
// 所有阿里云盘 API 域名、端点集中管理
// 换 API 只需改这个文件
// ===================================================================
// ==================== 域名 ====================
export const ALIYUN_API_HOST = 'https://api.aliyundrive.com';
export const ALIYUN_WEB_HOST = 'https://www.aliyundrive.com';
// ==================== API 端点路径 ====================
export const EP = {
/** 匿名获取分享信息 (无需登录) */
SHARE_GET_BY_ANONYMOUS: '/v2/share_link/get_share_by_anonymous',
} as const;
// ==================== 请求头 ====================
export function getShareHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': ALIYUN_WEB_HOST + '/',
'Accept-Language': 'zh-CN,zh;q=0.9',
};
}

View File

@@ -1,4 +1,7 @@
// Native fetch available in Node 20+
// @ts-nocheck
// 阿里云盘驱动 — 分享链接验证
// 依赖 aliyun-api.ts 统一端点模块 — URL 不再硬编码
import { ALIYUN_API_HOST, ALIYUN_WEB_HOST, EP as AE, getShareHeaders } from './aliyun-api';
export interface AliyunConfig {
cookie?: string;
@@ -7,7 +10,6 @@ export interface AliyunConfig {
export class AliyunDriver {
private config: AliyunConfig;
private baseUrl = 'https://api.aliyundrive.com';
constructor(config: AliyunConfig = {}) {
this.config = config;
@@ -38,13 +40,6 @@ export class AliyunDriver {
/**
* Validate a share link using Aliyun's public anonymous API.
* No cookie or token required — this endpoint is open.
*
* API:
* POST https://api.aliyundrive.com/v2/share_link/get_share_by_anonymous
* Body: { "share_id": "XXXYYY", "share_pwd": "" }
*
* Success: returns share_name, file_infos, creator info
* Failure: returns error code (ShareLinkExpired, ShareLinkCancelled, etc.)
*/
async validateShareLink(shareUrl: string): Promise<{
valid: boolean;
@@ -59,15 +54,10 @@ export class AliyunDriver {
try {
const response = await fetch(
`${this.baseUrl}/v2/share_link/get_share_by_anonymous`,
ALIYUN_API_HOST + AE.SHARE_GET_BY_ANONYMOUS,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.aliyundrive.com/',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
headers: getShareHeaders(),
body: JSON.stringify({
share_id: shareId,
share_pwd: '',
@@ -77,37 +67,42 @@ export class AliyunDriver {
);
if (!response.ok) {
return { valid: false, message: `HTTP ${response.status}: API 请求失败` };
return { valid: false, message: 'HTTP ' + response.status + ': API 请求失败' };
}
const data = await response.json() as any;
const data = await response.json();
// Check for error codes
if (data.code) {
switch (data.code) {
case 'ShareLinkExpired':
return { valid: false, message: '分享已失效(已过期)' };
case 'ShareLinkCancelled':
return { valid: false, message: '分享已被取消' };
case 'NotFound.ShareLink':
return { valid: false, message: '分享链接不存在' };
case 'ShareLinkPasswordIncorrect':
return { valid: true, message: '需要提取码(链接有效)' };
default:
return { valid: false, message: data.message || `未知错误 (${data.code})` };
}
const errorMessages: Record<string, string> = {
'ShareLinkExpired': '分享链接已过期',
'ShareLinkCancelled': '分享链接已取消',
'ShareLinkNotFound': '分享链接不存在',
'ForbiddenFileInTheDrive': '文件已被禁止访问',
'UserNotAuthenticated': '需要登录才能访问',
'InvalidResource.NotFound': '资源不存在',
};
return {
valid: false,
message: errorMessages[data.code] || data.message || 'API 返回错误: ' + data.code,
};
}
// Success — valid share link
const fileInfos = data.file_infos || [];
// 成功:提取分享信息
const fileCount = data.file_infos?.length || 0;
const shareName = data.share_name || data.display_name || '';
return {
valid: true,
message: `有效链接(${fileInfos.length} 个文件)`,
fileCount: fileInfos.length,
shareName: data.share_name || '',
message: '链接有效',
fileCount,
shareName,
};
} catch (err: any) {
return { valid: false, message: `网络错误: ${err.message || err}` };
} catch (err) {
return { valid: false, message: '网络错误: ' + (err instanceof Error ? err.message : String(err)) };
}
}
async getNickname(): Promise<string | null> {
return this.config.nickname || null;
}
}

View File

@@ -0,0 +1,57 @@
// @ts-nocheck
// ===================================================================
// 百度网盘 API 统一模块
// 所有百度网盘 API 域名、端点、请求头集中管理
// 换 API 只需改这个文件
// ===================================================================
// ==================== 域名 ====================
// 修改百度网盘域名只需改下面几行
export const BAIDU_PAN_HOST = 'https://pan.baidu.com';
export const BAIDU_PASSPORT_HOST = 'https://passport.baidu.com';
export const BAIDU_WAPPASS_HOST = 'https://wappass.baidu.com';
// ==================== API 端点路径 ====================
// 所有百度网盘 API 路径集中在此,换版本只需改这里
export const EP = {
// ── 认证/用户 ──
GET_TEMPLATE_VARIABLE: '/api/gettemplatevariable', // GET 获取 bdstoken/token/uk/servertime
USER_INFO: '/api/userinfo', // GET 用户信息
QUOTA: '/api/quota', // GET 容量信息
// ── 文件操作 ──
FILE_LIST: '/api/list', // GET 列出文件 (dir, order, page, num)
FILE_CREATE: '/api/create', // POST 创建文件夹 (a=commit)
FILE_DELETE: '/api/filemanager', // POST 删除文件 (opera=delete)
// ── 分享 ──
SHARE_VERIFY: '/share/verify', // GET 验证分享链接/密码 (surl, bdstoken, t, channel, web, clienttype)
SHARE_TRANSFER: '/share/transfer', // POST 转存分享文件 (shareid, from, bdstoken)
SHARE_SET: '/share/set', // POST 创建分享链接 (bdstoken, channel, web, clienttype, app_id)
// ── NAS ──
NAS_UINFO: '/rest/2.0/xpan/nas', // GET NAS 用户信息 (method=uinfo)
// ── 浏览器页面 (非 API供 Playwright 导航) ──
PAGE_LOGIN_QR: '/v2/', // QR 登录页 (?login&qrlogin&tpl=netdisk)
PAGE_DISK_HOME: '/disk/home', // 网盘首页
PAGE_QR_GEN: '/wp/', // 二维码生成 (?qrlogin&t=&error=0...)
} as const;
// ==================== 请求头 ====================
/** 构建标准百度网盘 API 请求头 */
export function buildHeaders(cookie: string): Record<string, string> {
const base: Record<string, string> = {
'Host': 'pan.baidu.com',
'Connection': 'keep-alive',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
'Referer': BAIDU_PAN_HOST,
};
if (cookie) {
base['Cookie'] = cookie;
}
return base;
}

View File

@@ -1,4 +1,6 @@
import { BAIDU_PAN_HOST, BAIDU_PASSPORT_HOST, BAIDU_WAPPASS_HOST, EP as BE, buildHeaders as bdHeaders } from './baidu-api';
// Baidu Netdisk Driver v4 — Cookie-based (Playwright QR login + HTTP API)
const APP_ID_WEB = "38824127"; // Web app ID from BaiduPanFilesTransfers
// Uses full browser Cookie string for all operations (no OAuth access_token needed).
// Share operations use internal web API (/share/verify, /share/transfer, parse HTML).
// Reference: https://github.com/hxz393/BaiduPanFilesTransfers
@@ -36,28 +38,11 @@ interface ShareDetail {
// ═══════════════════════════════════
// Constants
// ═══════════════════════════════════
const API_HOST = "https://pan.baidu.com";
import config from '../../config';
const CHROMIUM_PATH = config.chromiumPath;
const APP_ID_WEB = "38824127"; // Web app ID from BaiduPanFilesTransfers
// HTTP headers matching BaiduPanFilesTransfers
const WEB_HEADERS: Record<string, string> = {
'Host': 'pan.baidu.com',
'Connection': 'keep-alive',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
'Referer': 'https://pan.baidu.com',
};
function buildHeaders(cookie: string): Record<string, string> {
if (cookie) {
return { ...WEB_HEADERS, 'Cookie': cookie };
}
return { ...WEB_HEADERS };
}
// ═══════════════════════════════════
// Playwright singleton for QR login
@@ -156,9 +141,9 @@ export class BaiduDriver {
if (!cookie) return null;
try {
const url = `${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","token","uk","isdocuser","servertime"]`;
const url = `${BAIDU_PAN_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","token","uk","isdocuser","servertime"]`;
const res = await fetch(url, {
headers: buildHeaders(cookie),
headers: bdHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
@@ -213,7 +198,7 @@ export class BaiduDriver {
// Navigate directly to passport QR login page (the actual login page with QR code)
console.log('[BaiduQR] Navigating to passport QR login...');
await page.goto('https://passport.baidu.com/v2/?login&qrlogin&tpl=netdisk', { waitUntil: 'commit', timeout: 30000 });
await page.goto('BAIDU_PASSPORT_HOST + BE.PAGE_LOGIN_QR?login&qrlogin&tpl=netdisk', { waitUntil: 'commit', timeout: 30000 });
await page.waitForTimeout(4000);
// Check if we landed on the right page
@@ -248,7 +233,7 @@ export class BaiduDriver {
const sign = imgUrlObj.searchParams.get('sign') || '';
const logPage = imgUrlObj.searchParams.get('logPage') || '';
const t = Math.floor(Date.now() / 1000);
qrContent = `https://wappass.baidu.com/wp/?qrlogin&t=${t}&error=0&sign=${sign}&cmd=login&lp=pc&tpl=netdisk&adapter=3&logPage=${encodeURIComponent(logPage)}&qrloginfrom=pc`;
qrContent = `BAIDU_WAPPASS_HOST + BE.PAGE_QR_GEN?qrlogin&t=${t}&error=0&sign=${sign}&cmd=login&lp=pc&tpl=netdisk&adapter=3&logPage=${encodeURIComponent(logPage)}&qrloginfrom=pc`;
} catch {
qrContent = qrImgSrc; // fallback: raw image URL
}
@@ -333,7 +318,7 @@ export class BaiduDriver {
// Navigate to disk home to ensure cookies are fully set
try {
await page.goto('https://pan.baidu.com/disk/home', { waitUntil: 'commit', timeout: 15000 });
await page.goto('BAIDU_PAN_HOST + BE.PAGE_DISK_HOME', { waitUntil: 'commit', timeout: 15000 });
await page.waitForTimeout(2000);
} catch {}
@@ -350,8 +335,8 @@ export class BaiduDriver {
let bdstoken = '';
try {
const bdres = await fetch(
`${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","uk"]`,
{ headers: buildHeaders(cookieStr), signal: AbortSignal.timeout(10000) }
`${BAIDU_PAN_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","uk"]`,
{ headers: bdHeaders(cookieStr), signal: AbortSignal.timeout(10000) }
);
if (bdres.ok) {
const bddata = await bdres.json() as any;
@@ -366,8 +351,8 @@ export class BaiduDriver {
let storage_total = '';
if (bdstoken) {
try {
const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${bdstoken}`, {
headers: buildHeaders(cookieStr),
const qRes = await fetch(`${BAIDU_PAN_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${bdstoken}`, {
headers: bdHeaders(cookieStr),
signal: AbortSignal.timeout(10000),
});
if (qRes.ok) {
@@ -389,8 +374,8 @@ export class BaiduDriver {
// Get nickname from Baidu REST API (baidu_name field)
if (bdstoken) {
try {
const uRes = await fetch(`${API_HOST}/rest/2.0/xpan/nas?method=uinfo`, {
headers: buildHeaders(cookieStr),
const uRes = await fetch(`${BAIDU_PAN_HOST}/rest/2.0/xpan/nas?method=uinfo`, {
headers: bdHeaders(cookieStr),
signal: AbortSignal.timeout(10000),
});
if (uRes.ok) {
@@ -459,8 +444,8 @@ export class BaiduDriver {
let totalBytes = 0;
// Try to get user info from /api/userinfo
const uRes = await fetch(`${API_HOST}/api/userinfo?act=getuserinfo&bdstoken=${await this.getBdstoken()}`, {
headers: buildHeaders(cookie),
const uRes = await fetch(`${BAIDU_PAN_HOST}/api/userinfo?act=getuserinfo&bdstoken=${await this.getBdstoken()}`, {
headers: bdHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (uRes.ok) {
@@ -472,8 +457,8 @@ export class BaiduDriver {
// Get quota
try {
const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${await this.getBdstoken()}`, {
headers: buildHeaders(cookie),
const qRes = await fetch(`${BAIDU_PAN_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${await this.getBdstoken()}`, {
headers: bdHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (qRes.ok) {
@@ -514,9 +499,9 @@ export class BaiduDriver {
if (!bdstoken) return [];
try {
const url = `${API_HOST}/api/list?order=time&desc=1&showempty=0&web=1&page=1&num=1000&dir=/&bdstoken=${bdstoken}`;
const url = `${BAIDU_PAN_HOST}/api/list?order=time&desc=1&showempty=0&web=1&page=1&num=1000&dir=/&bdstoken=${bdstoken}`;
const res = await fetch(url, {
headers: buildHeaders(cookie),
headers: bdHeaders(cookie),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return [];
@@ -544,11 +529,11 @@ export class BaiduDriver {
if (!bdstoken) return false;
try {
const url = `${API_HOST}/api/create?a=commit&bdstoken=${bdstoken}`;
const url = `${BAIDU_PAN_HOST}/api/create?a=commit&bdstoken=${bdstoken}`;
const body = new URLSearchParams({ path, isdir: '1', block_list: '[]' });
const res = await fetch(url, {
method: 'POST',
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
@@ -590,9 +575,9 @@ export class BaiduDriver {
try {
const filelist = JSON.stringify(fsIds);
const body = new URLSearchParams({ async: '2', filelist });
const res = await fetch(`${API_HOST}/api/filemanager?opera=delete&bdstoken=${bdstoken}`, {
const res = await fetch(`${BAIDU_PAN_HOST}/api/filemanager?opera=delete&bdstoken=${bdstoken}`, {
method: 'POST',
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(30000),
});
@@ -651,12 +636,12 @@ export class BaiduDriver {
if (pwd) {
console.log(`[Baidu:Share] Verifying password for surl=${surl}...`);
const t = String(Date.now());
const verifyUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const verifyUrl = `${BAIDU_PAN_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const verifyBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' });
const vRes = await fetch(verifyUrl, {
method: 'POST',
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: verifyBody.toString(),
signal: AbortSignal.timeout(10000),
});
@@ -686,7 +671,7 @@ export class BaiduDriver {
const shareUrl = `https://pan.baidu.com/s/1${surl}`;
console.log(`[Baidu:Share] Fetching share page: ${shareUrl}`);
const sRes = await fetch(shareUrl, {
headers: buildHeaders(workingCookie),
headers: bdHeaders(workingCookie),
signal: AbortSignal.timeout(15000),
redirect: 'follow',
});
@@ -783,12 +768,12 @@ export class BaiduDriver {
// Verify password first if needed
if (pwd) {
const t = String(Date.now());
const vUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const vUrl = `${BAIDU_PAN_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const vBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' });
const vRes = await fetch(vUrl, {
method: 'POST',
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: vBody.toString(),
signal: AbortSignal.timeout(10000),
});
@@ -805,7 +790,7 @@ export class BaiduDriver {
// Get share page to extract shareid + uk
const sRes = await fetch(shareUrl, {
headers: buildHeaders(workingCookie),
headers: bdHeaders(workingCookie),
signal: AbortSignal.timeout(15000),
redirect: 'follow',
});
@@ -827,7 +812,7 @@ export class BaiduDriver {
const fsidlist = `[${fsIds.join(',')}]`;
const path = destPath === '/' ? '/' : `/${destPath.replace(/^\//, '')}`;
const tUrl = `${API_HOST}/share/transfer?shareid=${shareid}&from=${uk}&bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0`;
const tUrl = `${BAIDU_PAN_HOST}/share/transfer?shareid=${shareid}&from=${uk}&bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0`;
const tBody = new URLSearchParams({ fsidlist, path });
// Retry up to 3 times for transient fetch failures
@@ -837,7 +822,7 @@ export class BaiduDriver {
try {
tRes = await fetch(tUrl, {
method: 'POST',
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: tBody.toString(),
signal: AbortSignal.timeout(30000),
});
@@ -1020,8 +1005,8 @@ export class BaiduDriver {
try {
const res = await fetch(
`${API_HOST}/api/list?dir=${encodeURIComponent(parentPath)}&bdstoken=${bdstoken}&order=time&desc=1`,
{ headers: buildHeaders(cookie), signal: AbortSignal.timeout(10000) }
`${BAIDU_PAN_HOST}/api/list?dir=${encodeURIComponent(parentPath)}&bdstoken=${bdstoken}&order=time&desc=1`,
{ headers: bdHeaders(cookie), signal: AbortSignal.timeout(10000) }
);
if (!res.ok) return null;
const data = await res.json() as any;
@@ -1051,10 +1036,10 @@ export class BaiduDriver {
pwd,
});
const url = `${API_HOST}/share/set?bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0&app_id=250528`;
const url = `${BAIDU_PAN_HOST}/share/set?bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0&app_id=250528`;
const res = await fetch(url, {
method: 'POST',
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...bdHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,204 +1,120 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as system_config_service from "../../admin/system-config.service";
/**
* 广告关键词清理模块
* 转存完成后执行
* 1. 遍历转存的目录,删除文件名/文件夹名含广告关键词的内容
* 2. 在转存根目录下创建警示文件夹(置顶提醒)
* 夸克网盘 — 广告关键词清理模块
* 转存完成后:删除广告文件名 + 创建警示文件夹
* 依赖 quark-api.ts 统一端点模块
*/
import * as q from './quark-api';
import * as system_config_service from '../../admin/system-config.service';
// ==================== 配置读取 ====================
/** 从 DB 读取广告关键词列表 */
/** 从数据库读取广告关键词列表 */
export function getAdKeywords() {
const raw = system_config_service.getSystemConfig("quark_ad_keywords") || "";
return raw
.split("\n")
.flatMap((line) => line.split(","))
.map((s) => s.trim())
.filter(Boolean);
const raw = system_config_service.getSystemConfig('quark_ad_keywords') || '';
return raw.split('\n').flatMap(line => line.split(',')).map(s => s.trim()).filter(Boolean);
}
/** 从 DB 读取警示文件夹名称列表 */
/** 从数据库读取警示文件夹名称列表 */
export function getWarningFolderNames() {
const raw = system_config_service.getSystemConfig("quark_warning_folder_names") || "";
return raw
.split("\n")
.flatMap((line) => line.split(","))
.map((s) => s.trim())
.filter(Boolean);
const raw = system_config_service.getSystemConfig('quark_warning_folder_names') || '';
return raw.split('\n').flatMap(line => line.split(',')).map(s => s.trim()).filter(Boolean);
}
/** 从 DB 读取可疑文件后缀列表 */
/** 从数据库读取可疑文件后缀列表 */
export function getSusExtensions() {
const raw = system_config_service.getSystemConfig("quark_sus_extensions") || "";
const raw = system_config_service.getSystemConfig('quark_sus_extensions') || '';
if (raw.trim()) {
return raw
.split("\n")
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
.filter(Boolean);
return raw.split('\n').map(s => s.trim().toLowerCase().replace(/^\./, '')).filter(Boolean);
}
// 默认可疑后缀
return ["bat", "exe", "vbs", "scr", "cmd", "com", "pif", "js", "jar", "msi", "reg", "inf", "ps1"];
return ['bat', 'exe', 'vbs', 'scr', 'cmd', 'com', 'pif', 'js', 'jar', 'msi', 'reg', 'inf', 'ps1'];
}
// ==================== 关键词检测 ====================
/** 检查文件名是否包含任意广告关键词 */
export function containsAdKeyword(fileName, keywords) {
if (!keywords.length)
return false;
if (!keywords.length) return false;
const lower = fileName.toLowerCase();
return keywords.some((kw) => kw && lower.includes(kw.toLowerCase()));
return keywords.some(kw => kw && lower.includes(kw.toLowerCase()));
}
// ==================== 删除操作 ====================
/**
* 遍历指定目录(含子目录),删除匹配广告关键词的文件和文件夹
* 返回删除的文件数
* 遍历指定目录(含子目录),删除匹配广告关键词的文件和文件夹
* 返回删除的文件数
*/
export async function deleteAdFiles(cookie, dirFid, keywords) {
const extensions = getSusExtensions();
if (!keywords.length && !extensions.length)
return 0;
if (!keywords.length && !extensions.length) return 0;
let deletedCount = 0;
const stack = [dirFid];
const visited = new Set();
const visited = new Set<string>();
while (stack.length > 0) {
const fid = stack.pop();
if (visited.has(fid))
continue;
const fid = stack.pop()!;
if (visited.has(fid)) continue;
visited.add(fid);
await quark_api.humanDelay();
const files = await quark_api.listDir(cookie, fid);
if (!files || files.length === 0)
continue;
// 先收集所有需要删除的 fid
const toDelete = [];
const toKeep = [];
const extensions = getSusExtensions();
await q.humanDelay();
const files = await q.listDir(cookie, fid);
if (!files || files.length === 0) continue;
const toDelete: string[] = [];
for (const file of files) {
const ext = file.file_name.split(".").pop()?.toLowerCase() || "";
const ext = file.file_name.split('.').pop()?.toLowerCase() || '';
const isSusExt = extensions.includes(ext);
if (containsAdKeyword(file.file_name, keywords) || isSusExt) {
toDelete.push(file.fid);
console.log(`[Quark-AdCleanup] 标记删除: "${file.file_name}" (fid: ${file.fid})${isSusExt ? " [可疑后缀]" : " [广告关键词]"}`);
}
else {
toKeep.push(file.fid);
// 如果是目录且不删除,继续遍历子目录
if (file.dir) {
stack.push(file.fid);
}
const reason = isSusExt ? '[可疑后缀]' : '[广告关键词]';
console.log('[Quark-AdCleanup] 标记删除: "' + file.file_name + '" (fid: ' + file.fid + ') ' + reason);
} else if (file.dir) {
stack.push(file.fid);
}
}
// 批量删除
if (toDelete.length > 0) {
const deleteOk = await batchDeleteFiles(cookie, toDelete);
const deleteOk = await q.permanentDelete(cookie, toDelete);
if (deleteOk) {
deletedCount += toDelete.length;
console.log(`[Quark-AdCleanup] 已删除 ${toDelete.length} 个广告文件`);
console.log('[Quark-AdCleanup] 已删除 ' + toDelete.length + ' 个广告文件');
}
}
}
return deletedCount;
}
// ==================== 警示文件夹 ====================
/**
* 批量删除文件/文件夹(移入回收站)。
* 在转存根目录下创建警示文件夹
* 文件夹名前加 emoji 和空格,让其按字母排序置顶
*/
async function batchDeleteFiles(cookie, fids) {
if (!fids.length)
return true;
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file/trash?${quark_api.makeQuery()}`, {
method: "POST",
headers: {
...quark_api.getHeaders(cookie),
"Content-Type": "application/json",
},
body: JSON.stringify({
action_type: 1,
filelist: fids,
}),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
console.log(`[Quark-AdCleanup] batchDelete HTTP ${resp.status}`);
return false;
}
const data = (await resp.json());
if (data.status === 200) {
return true;
}
console.log(`[Quark-AdCleanup] batchDelete 返回非200: status=${data.status} msg=${data.message}`);
return false;
}
catch (err) {
console.log(`[Quark-AdCleanup] batchDelete 错误: ${err.message}`);
return false;
}
}
// ==================== 警示文件夹创建 ====================
/**
* 在转存根目录下创建警示文件夹。
* 文件夹名前加 ⚠️ 和空格,让其按字母排序置顶。
* 已存在的则跳过。
*/
export async function createWarningDirectories(cookie, dirNames, parentDirFid = "0") {
if (!dirNames.length)
return;
// 先获取根目录下所有文件夹,避免重复创建
await quark_api.humanDelay();
const rootFiles = await quark_api.listDirAllPages(cookie, parentDirFid);
const existingDirs = new Set(rootFiles.filter((f) => f.dir).map((f) => f.file_name));
export async function createWarningDirectories(cookie, dirNames, parentDirFid = '0') {
if (!dirNames.length) return;
await q.humanDelay();
const rootFiles = await q.listDirAllPages(cookie, parentDirFid);
const existingDirs = new Set(rootFiles.filter(f => f.dir).map(f => f.file_name));
for (const name of dirNames) {
// 格式化名称:确保以 ⚠️ 开头
let formattedName = name;
if (!formattedName.startsWith("⚠️") && !formattedName.startsWith("⚠")) {
formattedName = `⚠️ ${formattedName}`;
if (!formattedName.startsWith('\u26a0\ufe0f') && !formattedName.startsWith('\u26a0')) {
formattedName = '\u26a0\ufe0f ' + formattedName;
}
// 去掉多余空格
formattedName = formattedName.replace(/\s+/g, " ").trim();
formattedName = formattedName.replace(/\s+/g, ' ').trim();
if (existingDirs.has(formattedName)) {
console.log(`[Quark-AdCleanup] 警示文件夹已存在,跳过: "${formattedName}"`);
console.log('[Quark-AdCleanup] 警示文件夹已存在,跳过: "' + formattedName + '"');
continue;
}
await createSingleDir(cookie, formattedName, parentDirFid);
// 加入已存在集合,防止同名重试
const result = await q.quarkCreateDir(cookie, formattedName, parentDirFid);
if (result) {
console.log('[Quark-AdCleanup] 已创建警示文件夹: "' + formattedName + '" (fid: ' + result.fid + ')');
}
existingDirs.add(formattedName);
}
}
/**
* 创建单个文件夹。
*/
async function createSingleDir(cookie, dirName, pdirFid = "0") {
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file?${quark_api.makeQuery()}`, {
method: "POST",
headers: {
...quark_api.getHeaders(cookie),
"Content-Type": "application/json",
},
body: JSON.stringify({
pdir_fid: pdirFid,
file_name: dirName,
dir: true,
dir_path: "",
}),
signal: AbortSignal.timeout(10000),
});
const data = (await resp.json());
if (data.status === 200 && data.data?.fid) {
console.log(`[Quark-AdCleanup] 已创建警示文件夹: "${dirName}" (fid: ${data.data.fid})`);
return true;
}
console.log(`[Quark-AdCleanup] 创建文件夹失败: status=${data.status} msg=${data.message}`);
return false;
}
catch (err) {
console.log(`[Quark-AdCleanup] 创建文件夹错误: "${dirName}" — ${err.message}`);
return false;
}
}
// ==================== 主入口 ====================
/**
* 执行广告清理 + 创建警示文件夹
* 在转存重命名后调用
* 执行广告清理 + 创建警示文件夹
* 在转存重命名后调用
*/
export async function runAdCleanup(cookie, savedDirFid) {
const keywords = getAdKeywords();
@@ -206,24 +122,23 @@ export async function runAdCleanup(cookie, savedDirFid) {
const warningNames = getWarningFolderNames();
let adDeleted = 0;
let warningDirs = 0;
// 1. 广告关键词 + 可疑后缀清理
if (keywords.length > 0 || susExtensions.length > 0) {
console.log(`[Quark-AdCleanup] 开始文件清理: ${keywords.length} 个关键词, ${susExtensions.length} 个可疑后缀`);
console.log('[Quark-AdCleanup] 开始文件清理: ' + keywords.length + ' 个关键词, ' + susExtensions.length + ' 个可疑后缀');
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
console.log(`[Quark-AdCleanup] 清理完成,共删除 ${adDeleted} 个文件/文件夹`);
console.log('[Quark-AdCleanup] 清理完成,共删除 ' + adDeleted + ' 个文件/文件夹');
} else {
console.log('[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理');
}
else {
console.log("[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理");
}
// 2. 创建警示文件夹
if (warningNames.length > 0) {
console.log(`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length}`);
console.log('[Quark-AdCleanup] 开始创建警示文件夹: ' + warningNames.length + ' 个');
await createWarningDirectories(cookie, warningNames, savedDirFid);
warningDirs = warningNames.length;
console.log(`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`);
}
else {
console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建");
console.log('[Quark-AdCleanup] 警示文件夹创建完成(共 ' + warningDirs + ' 个)');
} else {
console.log('[Quark-AdCleanup] 无警示文件夹配置,跳过创建');
}
return { adDeleted, warningDirs };
}

View File

@@ -1,20 +1,61 @@
// @ts-nocheck
// ==================== Headers & Params ====================
const BASE_URL = 'https://drive-pc.quark.cn';
// ===================================================================
// 夸克网盘 API 统一模块
// 所有夸克 API 端点、请求头、工具函数集中管理
// 换 API 只需改这个文件
// ===================================================================
// ==================== 域名 ====================
// 修改夸克 API 域名只需改下面两行
export const QUARK_DRIVE_HOST = 'https://drive-pc.quark.cn';
export const QUARK_PAN_HOST = 'https://pan.quark.cn';
// ==================== API 端点路径 ====================
// 所有夸克 API 路径集中在此,换版本只需改这里
export const EP = {
// ── 账号 ──
ACCOUNT_INFO: '/account/info', // GET 获取账号信息
// ── 文件操作 ──
FILE: '/1/clouddrive/file', // POST 创建文件夹
FILE_SORT: '/1/clouddrive/file/sort', // GET 列出目录文件
FILE_RENAME: '/1/clouddrive/file/rename', // POST 重命名
FILE_DELETE: '/1/clouddrive/file/delete', // POST 永久删除 (action_type=2)
FILE_TRASH_CLEAR: '/1/clouddrive/file/trash/clear', // POST 清空回收站
// ── 分享 ──
SHARE: '/1/clouddrive/share', // POST 创建分享链接
SHARE_PASSWORD: '/1/clouddrive/share/password', // POST 设置分享密码
SHARE_PAGE_TOKEN: '/1/clouddrive/share/sharepage/token', // GET 获取分享页 token
SHARE_PAGE_DETAIL: '/1/clouddrive/share/sharepage/detail', // GET 获取分享详情
SHARE_PAGE_SAVE: '/1/clouddrive/share/sharepage/save', // POST 保存分享文件到网盘
// ── 任务 ──
TASK: '/1/clouddrive/task', // GET 查询异步任务状态
// ── 容量 ──
CAPACITY_DETAIL: '/1/clouddrive/capacity/detail', // GET 容量详情
MEMBER: '/1/clouddrive/member', // GET 会员/容量快速查询
} as const;
// ==================== 请求头 ====================
export function getHeaders(cookie) {
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Cookie': cookie,
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://pan.quark.cn/',
'Origin': 'https://pan.quark.cn',
'Referer': QUARK_PAN_HOST + '/',
'Origin': QUARK_PAN_HOST,
};
}
// ==================== 通用参数 ====================
export function getCommonParams() {
return { pr: 'ucpro', fr: 'pc' };
}
/** Generate query string with common params + random timing to mimic browser */
/** 生成浏览器风格的 query string随机延迟参数 */
export function makeQuery(extra = {}) {
const __dt = Math.floor(Math.random() * 240000 + 60000);
const __t = Date.now() / 1000;
@@ -27,20 +68,21 @@ export function makeQuery(extra = {}) {
...extra,
}).toString();
}
/** Random delay to mimic human behavior (500-2000ms) */
// ==================== 工具函数 ====================
/** 随机延迟 500~2000ms模拟人类操作 */
export async function humanDelay() {
const ms = Math.floor(Math.random() * 1500) + 500;
await new Promise(r => setTimeout(r, ms));
}
/** Generate a random password for share links */
/** 生成 4 位数字分享密码 */
export function randomSharePwd() {
return Math.floor(1000 + Math.random() * 9000).toString();
}
/**
* Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
*/
/** 从 Cookie 中提取 kps/sign/vcode */
export function getMparam(cookie) {
// Match both __kps and kps (with or without __ prefix)
const kpsMatch = cookie.match(/__?kps=([a-zA-Z0-9%+/=]+)/);
const signMatch = cookie.match(/__?sign=([a-zA-Z0-9%+/=]+)/);
const vcodeMatch = cookie.match(/__?vcode=([a-zA-Z0-9%+/=]+)/);
@@ -53,16 +95,40 @@ export function getMparam(cookie) {
}
return {};
}
// ==================== Shared fetch helpers ====================
/**
* Raw fetch wrapper with JSON parse + status check.
* Returns parsed JSON body on 2xx, null on network error.
*/
export async function apiFetch(path, options) {
const { method = 'GET', query, body, cookie, timeout = 10000 } = options;
let url = `${BASE_URL}${path}`;
if (query)
url += `?${new URLSearchParams(query).toString()}`;
/** 格式化字节为人类可读字符串 */
export function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
}
/** 当天日期文件夹名 (YYYY-MM-DD) */
export function dailyFolderName() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** 随机 12 位字母数字文件夹名 */
export function randomFolderName() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let name = '';
for (let i = 0; i < 12; i++) {
name += chars[Math.floor(Math.random() * chars.length)];
}
return name;
}
// ==================== 底层 fetch 封装 ====================
/** 通用 JSON fetch返回解析后的 body 或 null */
export async function apiFetch(endpoint, options) {
const { method = 'GET', query, body, cookie, timeout = 10000, host = QUARK_DRIVE_HOST } = options;
let url = host + endpoint;
if (query) url += '?' + new URLSearchParams(query).toString();
try {
const resp = await fetch(url, {
method,
@@ -73,17 +139,15 @@ export async function apiFetch(path, options) {
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(timeout),
});
if (!resp.ok)
return null;
return (await resp.json());
}
catch {
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
/**
* List files in a directory by FID.
*/
// ==================== 目录操作 ====================
/** 列出指定目录下的文件(单页) */
export async function listDir(cookie, pdirFid, page = 1, pageSize = 50) {
try {
const params = new URLSearchParams({
@@ -98,27 +162,26 @@ export async function listDir(cookie, pdirFid, page = 1, pageSize = 50) {
fetch_all_file: '1',
fetch_risk_file_name: '1',
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const resp = await fetch(QUARK_DRIVE_HOST + EP.FILE_SORT + '?' + params.toString(), {
headers: getHeaders(cookie),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return [];
const data = await resp.json();
if (data.status !== 200)
return [];
return (data.data?.list || []).filter((f) => f.fid).map((f) => ({
if (data.status !== 200) return [];
return (data.data?.list || []).filter(f => f.fid).map(f => ({
fid: f.fid,
file_name: f.file_name,
share_fid_token: '',
dir: f.dir || false,
size: f.size || 0,
}));
}
catch {
} catch {
return [];
}
}
/**
* List root directory (pdir_fid=0) — returns all top-level dirs/files.
*/
/** 列出根目录文件 */
export async function listRootDir(cookie) {
try {
const params = new URLSearchParams({
@@ -130,27 +193,25 @@ export async function listRootDir(cookie) {
fetch_all_file: '1',
fetch_risk_file_name: '1',
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const resp = await fetch(QUARK_DRIVE_HOST + EP.FILE_SORT + '?' + params.toString(), {
headers: getHeaders(cookie),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return [];
const data = await resp.json();
if (data.status !== 200 || !data.data?.list)
return [];
return (data.data.list || []).map((f) => ({
if (data.status !== 200 || !data.data?.list) return [];
return (data.data.list || []).map(f => ({
fid: f.fid,
file_name: f.file_name,
dir: f.dir || false,
size: f.size || 0,
}));
}
catch {
} catch {
return [];
}
}
/**
* List all files in a directory, handling pagination.
* Fetches all pages until no more results.
*/
/** 列出目录全部文件(自动翻页) */
export async function listDirAllPages(cookie, pdirFid) {
const allFiles = [];
let page = 1;
@@ -158,38 +219,119 @@ export async function listDirAllPages(cookie, pdirFid) {
let total = -1;
while (total === -1 || (page - 1) * pageSize < total) {
const files = await listDir(cookie, pdirFid, page, pageSize);
if (!files.length)
break;
if (!files.length) break;
allFiles.push(...files);
if (total === -1) {
total = files.length;
}
if (total === -1) total = files.length;
page++;
}
return allFiles;
}
// ==================== Format utilities ====================
export function formatBytes(bytes) {
if (bytes === 0)
return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
}
/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */
export function dailyFolderName() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** Generate a random folder name for saving (fallback) */
export function randomFolderName() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let name = '';
for (let i = 0; i < 12; i++) {
name += chars[Math.floor(Math.random() * chars.length)];
// ==================== 高级操作封装 ====================
/**
* 永久删除文件/文件夹
* @param cookie 认证 cookie
* @param fids 要删除的文件/文件夹 fid 数组
*/
export async function permanentDelete(cookie, fids) {
if (!fids.length) return true;
try {
const resp = await fetch(QUARK_DRIVE_HOST + EP.FILE_DELETE + '?' + makeQuery(), {
method: 'POST',
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ action_type: 2, filelist: fids }),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) return false;
const data = await resp.json();
return data.status === 200;
} catch (err) {
console.error('[Quark] permanentDelete error:', err.message);
return false;
}
}
/**
* 清空回收站
*/
export async function emptyTrashApi(cookie) {
try {
const resp = await fetch(QUARK_DRIVE_HOST + EP.FILE_TRASH_CLEAR + '?' + makeQuery(), {
method: 'POST',
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({}),
signal: AbortSignal.timeout(60000),
});
if (!resp.ok) return false;
const data = await resp.json();
return data.status === 200;
} catch (err) {
console.error('[Quark] emptyTrashApi error:', err.message);
return false;
}
}
/**
* 创建文件夹
* @returns 成功返回 { fid, file_name },失败返回 null
*/
export async function quarkCreateDir(cookie, dirName, pdirFid = '0') {
try {
const resp = await fetch(QUARK_DRIVE_HOST + EP.FILE + '?' + makeQuery(), {
method: 'POST',
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ pdir_fid: pdirFid, file_name: dirName, dir: true, dir_path: '' }),
signal: AbortSignal.timeout(10000),
});
const data = await resp.json();
if (data.status === 200 && data.data?.fid) {
return { fid: data.data.fid, file_name: dirName };
}
console.log('[Quark] quarkCreateDir failed: status=' + data.status + ' msg=' + data.message);
return null;
} catch (err) {
console.error('[Quark] quarkCreateDir error:', err.message);
return null;
}
}
/**
* 获取会员/容量信息 (快速接口)
*/
export async function getMemberInfo(cookie) {
try {
const params = new URLSearchParams({ pr: 'ucpro', fr: 'pc', uc_param_str: '', __t: String(Date.now()), __dt: '1000' });
const resp = await fetch(QUARK_PAN_HOST + EP.MEMBER + '?' + params.toString(), {
headers: getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return null;
const data = await resp.json();
if (data.status === 200 && data.data) {
return {
usedBytes: data.data.use_capacity ?? 0,
totalBytes: data.data.total_capacity ?? 0,
};
}
return null;
} catch {
return null;
}
}
/**
* 获取账号信息 (验证 cookie 有效性)
*/
export async function getAccountInfo(cookie) {
const url = QUARK_PAN_HOST + EP.ACCOUNT_INFO + '?fr=pc&platform=pc';
try {
const resp = await fetch(url, { headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) });
if (resp.ok) {
const data = await resp.json();
if (data.status === 200 && data.data) return data.data;
}
return null;
} catch {
return null;
}
return name;
}

View File

@@ -1,9 +1,10 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as q from './quark-api';
/**
* 认证模块 — Cookie 验证、账号信息获取、QR 登录状态检查。
* 所有方法以 cookie 字符串为参数,不持有驱动状态。
* 依赖 quark-api.ts 统一端点模块 — URL 不再硬编码。
*/
// ==================== Validate ====================
/**
@@ -13,12 +14,10 @@ export async function validate(cookie) {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
// Use account/info API (same as quark-auto-save project)
// Only needs __uid cookie, no mparam (kps/sign/vcode) required
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const url = q.QUARK_PAN_HOST + q.EP.ACCOUNT_INFO + '?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
...q.getHeaders(cookie),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
},
signal: AbortSignal.timeout(15000),
@@ -31,11 +30,11 @@ export async function validate(cookie) {
}
catch (err) {
if (attempt < MAX_RETRIES) {
console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`);
console.log('[Quark] validate attempt ' + (attempt + 1) + ' failed: ' + err.message + ', retrying...');
await new Promise(r => setTimeout(r, 2000));
continue;
}
console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`);
console.log('[Quark] validate all ' + (MAX_RETRIES + 1) + ' attempts failed: ' + err.message);
}
}
return false;
@@ -43,10 +42,10 @@ export async function validate(cookie) {
/** Fetch nickname from Quark account info (same API used by quark-auto-save) */
export async function fetchNickname(cookie) {
try {
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const url = q.QUARK_PAN_HOST + q.EP.ACCOUNT_INFO + '?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
...q.getHeaders(cookie),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
},
signal: AbortSignal.timeout(15000),

View File

@@ -0,0 +1,62 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
/**
* 认证模块 — Cookie 验证、账号信息获取、QR 登录状态检查。
* 所有方法以 cookie 字符串为参数,不持有驱动状态。
*/
// ==================== Validate ====================
/**
* Validate the cookie by fetching user info.
*/
export async function validate(cookie) {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
// Use account/info API (same as quark-auto-save project)
// Only needs __uid cookie, no mparam (kps/sign/vcode) required
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok)
return false;
const data = await response.json();
if (data?.data?.nickname)
return true;
}
catch (err) {
if (attempt < MAX_RETRIES) {
console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`);
await new Promise(r => setTimeout(r, 2000));
continue;
}
console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`);
}
}
return false;
}
/** Fetch nickname from Quark account info (same API used by quark-auto-save) */
export async function fetchNickname(cookie) {
try {
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
const response = await fetch(url, {
headers: {
...quark_api.getHeaders(cookie),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok)
return null;
const data = await response.json();
return data?.data?.nickname || null;
}
catch {
return null;
}
}

View File

@@ -1,58 +1,38 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
/**
* 夸克网盘 — 容量查询 & 空间清理
* 依赖 quark-api.ts 统一端点模块
*/
import * as q from './quark-api';
// ==================== 容量查询 ====================
/** 容量缓存 (3小时窗口) */
const storageCache = { bytes: 0, hourBlock: -1 };
/**
* 容量信息 & 空间清理模块。
*/
const BASE_URL = 'https://drive-pc.quark.cn';
// ==================== Storage Info ====================
/** Cached used space, keyed by hour block (3h window) */
const cachedUsedSpace = null;
// We use a function-scoped cache instead of instance field
const storageCache = { bytes: 0, hourBlock: -1 };
/**
* Get total capacity from /capacity/detail API.
* Also does a quick used-space estimate by summing root-level file sizes + subdir sizes
* (夸克目录的 size 字段 = 该目录内所有文件总大小,无需递归).
* If the API fails (e.g. missing sign params), falls back to fallbackTotal if provided.
*/
/**
* Get storage info from /member API — single fast call returns both used & total capacity.
* Falls back to capacity/detail + root file sum if member API fails.
* 快速获取容量信息 (优先 /member API回退到 capacity/detail + 根目录统计)
*/
export async function getStorageInfoQuick(cookie, fallbackTotal?) {
// 优先使用 /member 快速接口
const member = await q.getMemberInfo(cookie);
if (member && member.totalBytes > 0) {
const currentHourBlock = Math.floor(new Date().getHours() / 3);
storageCache.bytes = member.usedBytes;
storageCache.hourBlock = currentHourBlock;
return {
total: q.formatBytes(member.totalBytes),
totalBytes: member.totalBytes,
used: q.formatBytes(member.usedBytes),
usedBytes: member.usedBytes,
};
}
// 回退: capacity/detail + 根目录文件大小合计
try {
const memberParams = new URLSearchParams({ pr: 'ucpro', fr: 'pc', uc_param_str: '', __t: String(Date.now()), __dt: '1000' });
const resp = await fetch(`https://pan.quark.cn/1/clouddrive/member?${memberParams.toString()}`, {
headers: quark_api.getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (resp.ok) {
const data = await resp.json();
if (data.status === 200 && data.data) {
const usedBytes = data.data.use_capacity ?? 0;
const totalBytes = data.data.total_capacity ?? 0;
if (totalBytes > 0) {
// Cache for calculateUsedSpace compatibility
const currentHourBlock = Math.floor(new Date().getHours() / 3);
storageCache.bytes = usedBytes;
storageCache.hourBlock = currentHourBlock;
return {
total: quark_api.formatBytes(totalBytes),
totalBytes,
used: quark_api.formatBytes(usedBytes),
usedBytes,
};
}
}
}
} catch {}
// Fallback: capacity/detail for total + root file sum for used
try {
const params = new URLSearchParams(quark_api.getCommonParams());
const capResp = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
headers: quark_api.getHeaders(cookie),
const params = new URLSearchParams(q.getCommonParams());
const capResp = await fetch(q.QUARK_DRIVE_HOST + q.EP.CAPACITY_DETAIL + '?' + params.toString(), {
headers: q.getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
let totalBytes = 0;
@@ -62,46 +42,32 @@ export async function getStorageInfoQuick(cookie, fallbackTotal?) {
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
if (totalBytes === 0) {
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
totalBytes = memberships.reduce((max, m) => Math.max(max, m.capacity || 0), 0);
}
}
}
let usedBytes = 0;
try {
const rootFiles = await quark_api.listRootDir(cookie);
for (const f of rootFiles) usedBytes += f.size || 0;
} catch {}
try { const rootFiles = await q.listRootDir(cookie); for (const f of rootFiles) usedBytes += f.size || 0; } catch {}
if (totalBytes > 0) {
return { total: quark_api.formatBytes(totalBytes), totalBytes, used: quark_api.formatBytes(usedBytes), usedBytes };
return { total: q.formatBytes(totalBytes), totalBytes, used: q.formatBytes(usedBytes), usedBytes };
}
} catch {}
// Last resort: parse fallbackTotal string
// 兜底:从字符串解析
if (fallbackTotal) {
const match = String(fallbackTotal).match(/^([\d.]+)\s*([KMGT]B?)/i);
if (match) {
const num = parseFloat(match[1]);
const unit = match[2].toUpperCase();
const multipliers: Record<string,number> = { B:1, KB:1024, MB:1048576, GB:1073741824, TB:1099511627776 };
const multipliers = { B:1, KB:1024, MB:1048576, GB:1073741824, TB:1099511627776 };
const m = multipliers[unit] || multipliers[unit.replace('B','')+'B'] || 0;
if (m > 0) return { total: String(fallbackTotal), totalBytes: Math.round(num*m), used: '-', usedBytes: 0 };
}
}
return { total: '-', totalBytes: 0, used: '-', usedBytes: 0 };
}
/**
* Get storage info with used space calculation.
*/
/**
* Fast estimation (root-level files only) + background full traversal.
* First call returns quickly; full traversal runs async and updates DB later.
* `onBackgroundComplete` is called when traversal finishes.
*/
/**
* Get accurate storage info via /member API (fast, no file traversal needed).
* Returns { total, totalBytes, used, usedBytes } in ~1s.
* onBackgroundComplete is kept for backward compat (called synchronously).
*/
/** 获取容量信息 (兼容旧接口) */
export async function getStorageInfo(cookie, onBackgroundComplete?) {
const result = await getStorageInfoQuick(cookie);
if (onBackgroundComplete && result.used !== '-' && result.usedBytes > 0) {
@@ -109,10 +75,8 @@ export async function getStorageInfo(cookie, onBackgroundComplete?) {
}
return result;
}
/**
* Calculate total used space by recursively traversing all files
* and summing their sizes. Uses 3-hour time window cache.
*/
/** 递归遍历所有文件计算已用空间 (带 3 小时缓存) */
export async function calculateUsedSpace(cookie) {
const currentHourBlock = Math.floor(new Date().getHours() / 3);
if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) {
@@ -120,22 +84,16 @@ export async function calculateUsedSpace(cookie) {
}
let totalUsed = 0;
const stack = ['0'];
const visited = new Set();
const visited = new Set<string>();
while (stack.length > 0) {
const fid = stack.pop();
if (visited.has(fid))
continue;
const fid = stack.pop()!;
if (visited.has(fid)) continue;
visited.add(fid);
const files = await quark_api.listDirAllPages(cookie, fid);
if (!files.length)
continue;
const files = await q.listDirAllPages(cookie, fid);
if (!files.length) continue;
for (const f of files) {
if (f.dir) {
stack.push(f.fid);
}
else {
totalUsed += f.size || 0;
}
if (f.dir) stack.push(f.fid);
else totalUsed += f.size || 0;
}
await new Promise(r => setTimeout(r, 50));
}
@@ -143,119 +101,69 @@ export async function calculateUsedSpace(cookie) {
storageCache.hourBlock = currentHourBlock;
return totalUsed;
}
// ==================== Cleanup ====================
// ==================== 清理操作 ====================
/**
* Trash specified files/folders (move to recycle bin).
* 永久删除文件/文件夹 (之前是移入回收站, 现改为直接永久删除)
*/
export async function trashFiles(cookie, fids) {
if (!fids.length)
return true;
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/file/trash?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
action_type: 1, // 1 = move to trash
filelist: fids,
exclude_filelist: [],
}),
signal: AbortSignal.timeout(30000),
});
if (!response.ok)
return false;
const data = await response.json();
if (data.status === 200)
return true;
console.error(`[Quark] trashFiles failed: ${data.message || data.status}`);
return false;
}
catch (err) {
console.error(`[Quark] trashFiles error: ${err.message}`);
return false;
}
return q.permanentDelete(cookie, fids);
}
/**
* Empty the recycle bin — permanently delete all files in trash.
* 清空回收站 (现在删除是永久删除, 此函数基本不需要了, 保留兼容)
*/
export async function emptyTrash(cookie) {
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/file/trash/clear?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({}),
signal: AbortSignal.timeout(60000),
});
if (!response.ok)
return false;
const data = await response.json();
if (data.status === 200)
return true;
console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`);
return false;
}
catch (err) {
console.error(`[Quark] emptyTrash error: ${err.message}`);
return false;
}
return q.emptyTrashApi(cookie);
}
/**
* Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`.
* 清理 N 天前的日期文件夹 (YYYY-MM-DD 格式)
*/
export async function cleanupOldDateFolders(cookie, days, whitelistDirs) {
const errors = [];
export async function cleanupOldDateFolders(cookie, days, whitelistDirs?) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
try {
const rootItems = await quark_api.listRootDir(cookie);
const rootItems = await q.listRootDir(cookie);
const oldFolders = rootItems.filter(item => {
if (!item.dir)
return false;
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
return false;
if (whitelistDirs && whitelistDirs.includes(item.file_name))
return false;
if (!item.dir) return false;
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false;
if (whitelistDirs && whitelistDirs.includes(item.file_name)) return false;
return item.file_name < cutoffStr;
});
if (oldFolders.length === 0) {
return { trashed: 0, errors: [] };
}
if (oldFolders.length === 0) return { trashed: 0, errors: [] };
const fids = oldFolders.map(f => f.fid);
console.log(`[Quark] Trashing ${fids.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`);
console.log('[Quark] 永久删除 ' + fids.length + ' 个旧日期文件夹 (早于 ' + cutoffStr + '): ' + oldFolders.map(f => f.file_name).join(', '));
const ok = await trashFiles(cookie, fids);
if (ok) {
return { trashed: fids.length, errors: [] };
}
return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] };
}
catch (err) {
if (ok) return { trashed: fids.length, errors: [] };
return { trashed: 0, errors: ['删除 API 失败: ' + fids.length + ' 个文件夹'] };
} catch (err) {
return { trashed: 0, errors: [err.message] };
}
}
/**
* Cleanup: if used space exceeds thresholdPercent% of total,
* delete the oldest date folders until totalBytes * deletePercent/100
* of total capacity is freed.
* 根据空间阈值清理:已用空间超过 thresholdPercent% 时删除最老的日期文件夹
* 直到释放 deletePercent% 的总容量
*/
export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePercent, whitelistDirs) {
const errors = [];
export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePercent, whitelistDirs?) {
try {
const storage = await getStorageInfo(cookie);
if (storage.totalBytes <= 0)
return { trashed: 0, errors: [] };
if (storage.totalBytes <= 0) return { trashed: 0, errors: [] };
const usagePercent = (storage.usedBytes / storage.totalBytes) * 100;
if (usagePercent < thresholdPercent) {
console.log(`[Quark] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`);
console.log('[Quark] 使用率 ' + usagePercent.toFixed(1) + '% 低于阈值 ' + thresholdPercent + '%, 跳过');
return { trashed: 0, errors: [] };
}
const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100);
const rootItems = await quark_api.listRootDir(cookie);
const rootItems = await q.listRootDir(cookie);
const dateFolders = rootItems
.filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
.filter(item => !whitelistDirs || !whitelistDirs.includes(item.file_name))
.sort((a, b) => a.file.localeCompare(b.file_name));
if (dateFolders.length === 0)
return { trashed: 0, errors: [] };
.sort((a, b) => a.file_name.localeCompare(b.file_name));
if (dateFolders.length === 0) return { trashed: 0, errors: [] };
const hasSizes = dateFolders.some(f => f.size && f.size > 0);
let cumulativeSize = 0;
const foldersToTrash = [];
@@ -263,11 +171,9 @@ export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePe
for (const folder of dateFolders) {
foldersToTrash.push(folder);
cumulativeSize += folder.size || 0;
if (cumulativeSize >= targetBytesToFree)
break;
if (cumulativeSize >= targetBytesToFree) break;
}
}
else {
} else {
const avgSizePerFolder = storage.usedBytes / dateFolders.length;
const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder));
foldersToTrash.push(...dateFolders.slice(0, estCount));
@@ -276,15 +182,15 @@ export async function cleanupBySpaceThreshold(cookie, thresholdPercent, deletePe
const freedMB = (cumulativeSize / 1024 / 1024).toFixed(0);
const targetMB = (targetBytesToFree / 1024 / 1024).toFixed(0);
const fidsToTrash = foldersToTrash.map(f => f.fid);
console.log(`[Quark] Space threshold: trashing ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB) to free ${targetMB} MB (${deletePercent}% of ${(storage.totalBytes / 1024 / 1024 / 1024).toFixed(0)} GB total)`);
const gbTotal = (storage.totalBytes / 1024 / 1024 / 1024).toFixed(0);
console.log('[Quark] 空间阈值清理:删除 ' + foldersToTrash.length + '/' + dateFolders.length + ' 个最老文件夹 (~' + freedMB + ' MB),目标释放 ' + targetMB + ' MB (' + deletePercent + '% of ' + gbTotal + ' GB)');
const ok = await trashFiles(cookie, fidsToTrash);
if (ok) {
console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`);
console.log('[Quark] OK 空间阈值清理完成:已删除 ' + foldersToTrash.length + ' 个文件夹 (~' + freedMB + ' MB)');
return { trashed: foldersToTrash.length, errors: [] };
}
return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] };
}
catch (err) {
return { trashed: 0, errors: ['空间阈值清理失败: ' + foldersToTrash.length + ' 个文件夹'] };
} catch (err) {
return { trashed: 0, errors: [err.message] };
}
}

View File

@@ -1,10 +1,9 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as q from './quark-api';
/**
* 分享模块 — 分享链接解析、转存任务、创建分享链接。
*/
const BASE_URL = 'https://drive-pc.quark.cn';
// ==================== Acquire Stoken ====================
/**
* Acquire stoken for a share link (needed for detail/save).
@@ -12,10 +11,10 @@ const BASE_URL = 'https://drive-pc.quark.cn';
export async function acquireStoken(cookie, pwdId) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const params = new URLSearchParams(quark_api.getCommonParams());
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/token?${params.toString()}`, {
const params = new URLSearchParams(q.getCommonParams());
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PAGE_TOKEN?${params.toString()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
signal: AbortSignal.timeout(10000),
});
@@ -44,7 +43,7 @@ export async function acquireStoken(cookie, pwdId) {
*/
export async function getDetailAt(cookie, pwdId, stoken, pdirFid) {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
...q.getCommonParams(),
pwd_id: pwdId,
stoken,
pdir_fid: pdirFid,
@@ -58,7 +57,7 @@ export async function getDetailAt(cookie, pwdId, stoken, pdirFid) {
ver: '2',
fetch_share_full_path: '0',
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/detail?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(15000) });
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PAGE_DETAIL?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const data = await resp.json();
@@ -108,9 +107,9 @@ export async function getShareFiles(cookie, pwdId, stoken) {
*/
export async function saveFiles(cookie, pwdId, stoken, fids, fidTokens, toPdirFid) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/save?${quark_api.makeQuery()}`, {
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PAGE_SAVE?${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: fids,
fid_token_list: fidTokens,
@@ -148,14 +147,14 @@ export async function waitForTask(cookie, taskId, timeoutMs) {
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
...q.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.TASK?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.status === 200) {
if (data.data?.status === 2) {
@@ -180,9 +179,9 @@ export async function waitForTask(cookie, taskId, timeoutMs) {
*/
export async function renameFile(cookie, fid, newName) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/file/rename?${quark_api.makeQuery()}`, {
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.FILE_RENAME?${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ fid, file_name: newName }),
signal: AbortSignal.timeout(10000),
});
@@ -200,16 +199,16 @@ export async function renameFile(cookie, fid, newName) {
*/
export async function createShareLink(cookie, fileId) {
try {
const sharePwd = quark_api.randomSharePwd();
const sharePwd = q.randomSharePwd();
// Try different share_type values (1=7天, 0=无限制)
const shareTypes = ['1', '0'];
let lastError = '';
for (const st of shareTypes) {
await quark_api.humanDelay();
await q.humanDelay();
// Step 1: Create share task - get task_id
const response = await fetch(`${BASE_URL}/1/clouddrive/share?${quark_api.makeQuery()}`, {
const response = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE + "?"${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: [fileId],
share_type: st,
@@ -256,9 +255,9 @@ export async function createShareLink(cookie, fileId) {
*/
async function submitShare(cookie, shareId, sharePwd) {
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/share/password?${quark_api.makeQuery()}`, {
const response = await fetch(`q.QUARK_DRIVE_HOST + q.EP.SHARE_PASSWORD?${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
signal: AbortSignal.timeout(15000),
});
@@ -285,14 +284,14 @@ async function waitForShareTask(cookie, taskId, timeoutMs) {
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
...q.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.TASK?${params.toString()}`, { headers: q.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.data?.status === 2) {
// Task completed — try multiple extraction approaches

View File

@@ -0,0 +1,356 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
/**
* 分享模块 — 分享链接解析、转存任务、创建分享链接。
*/
const BASE_URL = 'https://drive-pc.quark.cn';
// ==================== Acquire Stoken ====================
/**
* Acquire stoken for a share link (needed for detail/save).
*/
export async function acquireStoken(cookie, pwdId) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const params = new URLSearchParams(quark_api.getCommonParams());
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/token?${params.toString()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
if (attempt < 2)
continue;
return null;
}
const data = await resp.json();
if (data.status === 200 && data.data?.stoken) {
return data.data.stoken;
}
return null;
}
catch {
if (attempt >= 2)
return null;
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
}
}
return null;
}
// ==================== Get Share Files ====================
/**
* Fetch detail at a given pdir_fid within a share.
*/
export async function getDetailAt(cookie, pwdId, stoken, pdirFid) {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
pwd_id: pwdId,
stoken,
pdir_fid: pdirFid,
force: '0',
_page: '1',
_size: '50',
_fetch_banner: '0',
_fetch_share: '1',
_fetch_total: '1',
_sort: 'file_type:asc,updated_at:desc',
ver: '2',
fetch_share_full_path: '0',
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/detail?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(15000) });
if (!resp.ok)
return [];
const data = await resp.json();
if (data.status !== 200)
return [];
return (data.data?.list || []).filter((f) => f.fid).map((f) => ({
fid: f.fid,
file_name: f.file_name,
share_fid_token: f.share_fid_token || '',
dir: f.dir || false,
size: f.size || 0,
}));
}
/**
* Recursively collect files from a share.
* If the share contains a single directory, drill into it to list contents
* but still save the directory itself.
*/
export async function getShareFiles(cookie, pwdId, stoken) {
try {
const topLevel = await getDetailAt(cookie, pwdId, stoken, '0');
if (!topLevel || topLevel.length === 0)
return null;
// If the share is a single directory, we save the directory itself
// and fetch its contents for renaming later
if (topLevel.length === 1 && topLevel[0].dir) {
const innerFiles = await getDetailAt(cookie, pwdId, stoken, topLevel[0].fid);
return {
files: topLevel,
topDir: true,
childFiles: innerFiles || [],
};
}
// Multiple top-level items: save them directly
return {
files: topLevel,
topDir: false,
};
}
catch {
return null;
}
}
// ==================== Save Files (share → cloud) ====================
/**
* Save shared files to the user's cloud directory.
*/
export async function saveFiles(cookie, pwdId, stoken, fids, fidTokens, toPdirFid) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/share/sharepage/save?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: fids,
fid_token_list: fidTokens,
to_pdir_fid: toPdirFid,
pwd_id: pwdId,
stoken,
pdir_fid: '0',
scene: 'link',
}),
signal: AbortSignal.timeout(30000),
});
const data = await resp.json();
if (data.status === 200 && data.data?.task_id) {
return { success: true, message: 'Save task created', taskId: data.data.task_id };
}
return {
success: false,
message: data.message === 'require login [guest]'
? '夸克网盘 Cookie 已过期,请在后台重新配置 Cookie'
: (data.message || `API 返回错误 (status=${data.status}, code=${data.code})`),
};
}
catch (err) {
return { success: false, message: err.message || 'Network error' };
}
}
// ==================== Wait for Save Task ====================
/**
* Poll task status until complete or timeout.
* Returns the saved file FIDs (save_as_top_fids).
*/
export async function waitForTask(cookie, taskId, timeoutMs) {
const start = Date.now();
let retryIndex = 0;
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.status === 200) {
if (data.data?.status === 2) {
// Task completed
const savedFids = data.data?.save_as?.save_as_top_fids || [];
return savedFids;
}
// Still in progress
retryIndex++;
}
}
catch {
// Network error, retry
}
await new Promise(r => setTimeout(r, 1000));
}
return null; // Timeout
}
// ==================== Rename File ====================
/**
* Rename a file by its FID.
*/
export async function renameFile(cookie, fid, newName) {
try {
const resp = await fetch(`${BASE_URL}/1/clouddrive/file/rename?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ fid, file_name: newName }),
signal: AbortSignal.timeout(10000),
});
const data = await resp.json();
return data.status === 200 || data.code === 0;
}
catch {
return false;
}
}
// ==================== Create Share Link ====================
/**
* Create a share link for a file/folder.
* Flow: create task → poll for share_id → submit to get short URL.
*/
export async function createShareLink(cookie, fileId) {
try {
const sharePwd = quark_api.randomSharePwd();
// Try different share_type values (1=7天, 0=无限制)
const shareTypes = ['1', '0'];
let lastError = '';
for (const st of shareTypes) {
await quark_api.humanDelay();
// Step 1: Create share task - get task_id
const response = await fetch(`${BASE_URL}/1/clouddrive/share?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
fid_list: [fileId],
share_type: st,
url_type: '1',
share_pwd: sharePwd,
}),
signal: AbortSignal.timeout(15000),
});
const data = await response.json();
const taskId = data.data?.task_id;
if (!taskId) {
lastError = data.message || `share_type=${st} 失败`;
console.error('[Quark] Create share task failed (type=%s):', st, data.message || JSON.stringify(data).slice(0, 200));
continue;
}
// Step 2: Poll task until complete
const result = await waitForShareTask(cookie, taskId, 20000);
if (!result?.shareId) {
lastError = result?.message || '任务超时';
console.error('[Quark] Wait for share task failed (type=%s):', st, result?.message || 'unknown');
continue;
}
// Step 3: Submit share via /password endpoint
const shareUrl = await submitShare(cookie, result.shareId, sharePwd);
if (shareUrl) {
return {
success: true,
shareUrl,
sharePwd,
message: `分享链接已生成(密码:${sharePwd}`,
};
}
lastError = '提交密码后未获取到短链接';
}
return { success: false, message: lastError || '🤷 各种姿势都试过了,就是分享不出来…' };
}
catch (err) {
console.error('[Quark] createShareLink error:', err.message);
return { success: false, message: err.message || '🌩️ 网络开小差了,再试试?' };
}
}
/**
* Submit share via /password endpoint to get the actual short URL.
*/
async function submitShare(cookie, shareId, sharePwd) {
try {
const response = await fetch(`${BASE_URL}/1/clouddrive/share/password?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
signal: AbortSignal.timeout(15000),
});
const data = await response.json();
if (data.status === 200 && data.data?.share_url) {
console.log('[Quark] Share short URL:', data.data.share_url);
return data.data.share_url;
}
console.log('[Quark] /password response:', JSON.stringify(data).slice(0, 300));
console.error('[Quark] /password FAIL status=%s msg=%s', data.status, data.message || '');
return null;
}
catch (err) {
console.log('[Quark] /password error:', err);
return null;
}
}
/**
* Poll share task until complete and extract share URL/shortcode.
*/
async function waitForShareTask(cookie, taskId, timeoutMs) {
const start = Date.now();
let retryIndex = 0;
while (Date.now() - start < timeoutMs) {
try {
const params = new URLSearchParams({
...quark_api.getCommonParams(),
uc_param_str: '',
task_id: taskId,
retry_index: String(retryIndex),
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
__t: String(Date.now() / 1000),
});
const resp = await fetch(`${BASE_URL}/1/clouddrive/task?${params.toString()}`, { headers: quark_api.getHeaders(cookie), signal: AbortSignal.timeout(10000) });
const data = await resp.json();
if (data.data?.status === 2) {
// Task completed — try multiple extraction approaches
// 1. Direct share_url field
if (data.data?.share_url) {
const match = data.data.share.match(/\/s\/([a-zA-Z0-9]+)/);
if (match)
return { shareId: match[1] };
}
// 2. Nested share object
if (data.data?.share?.url) {
const match = data.data.share.url.match(/\/s\/([a-zA-Z0-9]+)/);
if (match)
return { shareId: match[1] };
}
if (data.data?.share?.short_url) {
const match = data.data.share.short.match(/\/s\/([a-zA-Z0-9]+)/);
if (match)
return { shareId: match[1] };
}
// 3. share_id — validate it's a reasonable short code (8-20 chars, not UUID-like)
const shareId = data.data?.share_id;
if (shareId && shareId.length <= 20 && shareId.length >= 8) {
return { shareId };
}
// 4. Regex search through the full response for a URL pattern
const str = JSON.stringify(data);
const urlMatch = str.match(/https?:\/\/pan\.quark\.cn\/s\/([a-zA-Z0-9]{6,16})/);
if (urlMatch) {
return { shareId: urlMatch[1] };
}
// 5. Extract from any URL field in the response
const urlFields = ['url', 'link', 'share_url', 'short_url', 'share_link'];
for (const field of urlFields) {
const val = data.data?.[field] || data.data?.share?.[field];
if (typeof val === 'string' && val.includes('pan.quark.cn/s/')) {
const m = val.match(/\/s\/([a-zA-Z0-9]+)/);
if (m)
return { shareId: m[1] };
}
}
// 6. Log full share task response for debugging
console.log('[Quark] Full share task response:', JSON.stringify(data, null, 2).slice(0, 2000));
// 7. Even if shareId is UUID-like (32 hex chars), use it anyway as last resort
if (shareId) {
return { shareId };
}
return { message: 'Share task completed but no share URL found' };
}
if (data.data?.status === 3) {
return { message: data.message || 'Share task failed' };
}
retryIndex++;
}
catch {
// Retry
}
await new Promise(r => setTimeout(r, 1000));
}
return { message: 'Share task timed out' };
}

View File

@@ -1,6 +1,6 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as quark_share from "./quark-share";
import * as q from './quark-api';
import * as qs from './quark-share';
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
@@ -54,12 +54,12 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
return { success: false, message: 'Invalid share URL: could not extract share token' };
}
// Step 1: Acquire stoken
const stoken = await quark_share.acquireStoken(cookie, pwdId);
const stoken = await qs.acquireStoken(cookie, pwdId);
if (!stoken) {
return { success: false, message: '😅 Oops资源好像偷偷溜走了换个链接试试吧' };
}
// Step 2: Get share detail
const shareInfo = await quark_share.getShareFiles(cookie, pwdId, stoken);
const shareInfo = await qs.getShareFiles(cookie, pwdId, stoken);
if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) {
return { success: false, message: '🌚 空的!这个分享里啥都没有…' };
}
@@ -68,8 +68,8 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
const fids = topFiles.map(f => f.fid);
const fidTokens = topFiles.map(f => f.share_fid_token);
// 按日期创建/查找文件夹,每天的转存存入当天文件夹
await quark_api.humanDelay();
const saveDirName = quark_api.dailyFolderName();
await q.humanDelay();
const saveDirName = q.dailyFolderName();
console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`);
const saveDirFid = await findOrCreateDir(cookie, saveDirName);
let targetPdirFid = saveDirFid || '0';
@@ -90,18 +90,18 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`);
}
// Step 3: Save top-level item(s) to the target directory
const saveResult = await quark_share.saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean), targetPdirFid);
const saveResult = await qs.saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean), targetPdirFid);
if (!saveResult.success) {
return saveResult;
}
const taskId = saveResult.taskId;
// Step 4: Wait for save task to complete (poll up to 30s)
const savedFids = await quark_share.waitForTask(cookie, taskId, 30000);
const savedFids = await qs.waitForTask(cookie, taskId, 30000);
if (!savedFids || savedFids.length === 0) {
return { success: true, message: '文件已保存,但获取保存结果超时' };
}
// Step 5: Magic rename files — with random delay to avoid detection
await quark_api.humanDelay();
await q.humanDelay();
const renamed = [];
let shareFid = '';
let savedFolderName = '';
@@ -122,7 +122,7 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
console.log(`[Quark] Retry: sharing subfolder (fid: ${retrySubFolderFid}) instead of saved content`);
}
// Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared
await quark_api.humanDelay();
await q.humanDelay();
let shareUrlResult = '';
let sharePwdResult = '';
let shareMsg = '';
@@ -146,7 +146,7 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
// ── Single folder share ──
const savedDirFid = savedFids[0];
// List files inside the saved directory
const dirFiles = await quark_api.listDir(cookie, savedDirFid);
const dirFiles = await q.listDir(cookie, savedDirFid);
if (dirFiles && dirFiles.length > 0) {
for (const file of dirFiles) {
if (file.dir)
@@ -255,9 +255,9 @@ export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, ret
*/
export async function createDir(cookie, dirName, parentFid = '0') {
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file?${quark_api.makeQuery()}`, {
const resp = await fetch(`q.QUARK_DRIVE_HOST + q.EP.FILE + '?'${q.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
headers: { ...q.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
pdir_fid: parentFid,
file_name: dirName,
@@ -284,7 +284,7 @@ export async function createDir(cookie, dirName, parentFid = '0') {
*/
export async function findOrCreateDir(cookie, dirName, parentFid = '0') {
try {
const rootFiles = await quark_api.listDirAllPages(cookie, parentFid);
const rootFiles = await q.listDirAllPages(cookie, parentFid);
const existing = rootFiles.find(f => f.dir && f.file_name === dirName);
if (existing?.fid) {
console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`);
@@ -313,7 +313,7 @@ export async function countRecursive(cookie, pdirFid) {
if (visited.has(fid))
continue;
visited.add(fid);
const files = await quark_api.listDir(cookie, fid);
const files = await q.listDir(cookie, fid);
if (!files)
continue;
for (const f of files) {

View File

@@ -0,0 +1,330 @@
// @ts-nocheck
import * as quark_api from "./quark-api";
import * as quark_share from "./quark-share";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
/**
* 转存 & 存储管理模块。
* 处理分享链接解析 → 转存 → 查/创建目标文件夹 → 文件重命名 → 递归统计。
*/
// ==================== saveFromShare — 核心转存流水线 ====================
/**
* Save files from a share link → magic rename → create shared link.
*
* Flow: token → detail → save → wait_task → rename → share
*/
export async function saveFromShare(cookie, nickname, shareUrl, sourceTitle, retrySave = false) {
try {
// Parse share token from URL
const urlObj = new URL(shareUrl);
const pwdId = urlObj.pathname.split('/').filter(Boolean).pop();
if (!pwdId) {
return { success: false, message: 'Invalid share URL: could not extract share token' };
}
// Step 1: Acquire stoken
const stoken = await quark_share.acquireStoken(cookie, pwdId);
if (!stoken) {
return { success: false, message: '😅 Oops资源好像偷偷溜走了换个链接试试吧' };
}
// Step 2: Get share detail
const shareInfo = await quark_share.getShareFiles(cookie, pwdId, stoken);
if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) {
return { success: false, message: '🌚 空的!这个分享里啥都没有…' };
}
const { files: topFiles, topDir, childFiles } = shareInfo;
const originalFolderName = topFiles[0]?.file_name || '';
const fids = topFiles.map(f => f.fid);
const fidTokens = topFiles.map(f => f.share_fid_token);
// 按日期创建/查找文件夹,每天的转存存入当天文件夹
await quark_api.humanDelay();
const saveDirName = quark_api.dailyFolderName();
console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`);
const saveDirFid = await findOrCreateDir(cookie, saveDirName);
let targetPdirFid = saveDirFid || '0';
let retrySubFolderFid = '';
if (saveDirFid) {
console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`);
if (retrySave) {
const subName = 'retry_' + Math.random().toString(36).slice(2, 6);
console.log(`[Quark] Retry: creating subfolder "${subName}" inside "${saveDirName}"`);
retrySubFolderFid = await findOrCreateDir(cookie, subName, saveDirFid);
if (retrySubFolderFid) {
targetPdirFid = retrySubFolderFid;
console.log(`[Quark] Retry: saving to subfolder ${subName} (fid: ${retrySubFolderFid})`);
}
}
}
else {
console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`);
}
// Step 3: Save top-level item(s) to the target directory
const saveResult = await quark_share.saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean), targetPdirFid);
if (!saveResult.success) {
return saveResult;
}
const taskId = saveResult.taskId;
// Step 4: Wait for save task to complete (poll up to 30s)
const savedFids = await quark_share.waitForTask(cookie, taskId, 30000);
if (!savedFids || savedFids.length === 0) {
return { success: true, message: '文件已保存,但获取保存结果超时' };
}
// Step 5: Magic rename files — with random delay to avoid detection
await quark_api.humanDelay();
const renamed = [];
let shareFid = '';
let savedFolderName = '';
let newInnerDirName = '';
if (topDir && childFiles && childFiles.length > 0) {
// ── Single folder share ──
const savedDirFid = savedFids[0];
shareFid = savedDirFid;
savedFolderName = topFiles[0]?.file_name || '';
}
else {
// ── Multiple files at top level ──
shareFid = savedFids[0];
savedFolderName = topFiles[0]?.file_name || '';
}
if (retrySave && retrySubFolderFid) {
shareFid = retrySubFolderFid;
console.log(`[Quark] Retry: sharing subfolder (fid: ${retrySubFolderFid}) instead of saved content`);
}
// Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared
await quark_api.humanDelay();
let shareUrlResult = '';
let sharePwdResult = '';
let shareMsg = '';
let successCount = 0; // total items (files + folders) actually saved
const { createShareLink } = await import('./quark-share');
if (shareFid) {
const shareResult = await createShareLink(cookie, shareFid);
if (shareResult.success && shareResult.shareUrl) {
shareUrlResult = shareResult.shareUrl;
if (shareResult.sharePwd)
sharePwdResult = shareResult.sharePwd;
}
else {
shareMsg = `(分享失败:${shareResult.message}`;
}
}
const { magicRenameDir, magicRename } = await import('./quark-rename');
const { renameFile } = await import('./quark-share');
// Step 7: Rename files AFTER creating the share link (anti-harmony, won't affect the share)
if (topDir && childFiles && childFiles.length > 0) {
// ── Single folder share ──
const savedDirFid = savedFids[0];
// List files inside the saved directory
const dirFiles = await quark_api.listDir(cookie, savedDirFid);
if (dirFiles && dirFiles.length > 0) {
for (const file of dirFiles) {
if (file.dir)
continue;
const newName = magicRename(file.file_name);
const renameOk = await renameFile(cookie, file.fid, newName);
if (renameOk) {
renamed.push({ original: file.file_name, renamed: newName });
}
}
}
// Also rename the inner folder itself (the actual shared folder)
const innerDirOriginalName = sourceTitle || topFiles[0]?.file_name || '';
if (innerDirOriginalName) {
newInnerDirName = magicRenameDir(innerDirOriginalName);
const innerDirRenameOk = await renameFile(cookie, savedDirFid, newInnerDirName);
if (innerDirRenameOk) {
console.log(`[Quark] Renamed inner folder: ${innerDirOriginalName}${newInnerDirName}`);
}
}
}
else {
// ── Multiple files at top level ──
for (let i = 0; i < savedFids.length && i < topFiles.length; i++) {
const originalName = topFiles[i].file_name;
if (topFiles[i].dir)
continue;
const newName = magicRename(originalName);
const renameOk = await renameFile(cookie, savedFids[i], newName);
if (renameOk) {
renamed.push({ original: originalName, renamed: newName });
}
}
}
// Step 7.5: 广告关键词清理 + 创建警示文件夹
if (shareFid) {
try {
const { runAdCleanup } = await import('./quark-ad-cleanup');
const adResult = await runAdCleanup(cookie, shareFid);
if (adResult.adDeleted > 0) {
console.log(`[Quark] 广告清理完成: 删除了 ${adResult.adDeleted} 个广告文件/文件夹`);
}
if (adResult.warningDirs > 0) {
console.log(`[Quark] 已创建 ${adResult.warningDirs} 个警示文件夹`);
}
}
catch (err) {
console.log(`[Quark] 广告清理/警示文件夹创建失败(非致命): ${err.message}`);
}
}
// Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03")
// DO NOT rename the date folder — it serves as the organizational container.
savedFolderName = newInnerDirName ? `${saveDirName}/${newInnerDirName}` : saveDirName;
// Recursively count files and folders from saved cloud directory
let fileCount = 0;
let folderCount = 0;
if (shareFid) {
try {
const counts = await countRecursive(cookie, shareFid);
fileCount = counts.fileCount;
folderCount = counts.folderCount;
}
catch {
console.log('[Quark] Recursive count failed, using fallback');
}
}
// If recursive count returned nothing, try fallback
if (fileCount === 0 && folderCount === 0) {
if (topDir && childFiles) {
folderCount = 1 + childFiles.filter(f => f.dir).length;
fileCount = childFiles.filter(f => !f.dir).length;
}
else {
folderCount = topFiles.filter(f => f.dir).length;
fileCount = topFiles.filter(f => !f.dir).length;
}
}
// Calculate total file size
const allFiles = topDir && childFiles ? childFiles : topFiles;
const fileSize = allFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
const renameMsg = renamed.length > 0
? `,已重命名 ${renamed.length} 个文件`
: '';
const folderMsg = savedFolderName ? `到文件夹「${savedFolderName}` : '';
return {
success: true,
message: `已保存${folderMsg}${renameMsg}${shareMsg}`,
shareUrl: shareUrlResult || undefined,
sharePwd: sharePwdResult || undefined,
folderName: savedFolderName,
taskId,
renamed: renamed.map(r => `${r.original}${r.renamed}`),
fileCount,
folderCount,
fileSize,
originalFolderName,
};
}
catch (err) {
return { success: false, message: err.message || 'Network error' };
}
}
// ==================== Dir Management ====================
/**
* Create a new directory at root.
*/
export async function createDir(cookie, dirName, parentFid = '0') {
try {
const resp = await fetch(`https://drive-pc.quark.cn/1/clouddrive/file?${quark_api.makeQuery()}`, {
method: 'POST',
headers: { ...quark_api.getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
pdir_fid: parentFid,
file_name: dirName,
dir: true,
dir_path: '',
}),
signal: AbortSignal.timeout(10000),
});
const data = await resp.json();
if (data.status === 200 && data.data?.fid) {
console.log(`[Quark] Created dir "${dirName}" (fid: ${data.data.fid})`);
return data.data.fid;
}
console.log(`[Quark] createDir API returned non-200: status=${data.status} msg=${data.message}`);
return null;
}
catch (err) {
console.log(`[Quark] createDir error: ${err.message}`);
return null;
}
}
/**
* Find an existing directory by name, or create it if not found.
*/
export async function findOrCreateDir(cookie, dirName, parentFid = '0') {
try {
const rootFiles = await quark_api.listDirAllPages(cookie, parentFid);
const existing = rootFiles.find(f => f.dir && f.file_name === dirName);
if (existing?.fid) {
console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`);
return existing.fid;
}
console.log(`[Quark] Daily folder "${dirName}" not found, creating...`);
}
catch (err) {
console.log(`[Quark] findOrCreateDir list error: ${err.message}`);
}
const fid = await createDir(cookie, dirName, parentFid);
console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`);
return fid;
}
// ==================== Recursive Count ====================
/**
* Recursively count files and folders for a saved cloud directory.
*/
export async function countRecursive(cookie, pdirFid) {
let fileCount = 0;
let folderCount = 0;
const stack = [pdirFid];
const visited = new Set();
while (stack.length > 0) {
const fid = stack.pop();
if (visited.has(fid))
continue;
visited.add(fid);
const files = await quark_api.listDir(cookie, fid);
if (!files)
continue;
for (const f of files) {
if (f.dir) {
folderCount++;
stack.push(f.fid);
}
else {
fileCount++;
}
}
}
return { fileCount, folderCount };
}

View File

@@ -1,3 +1,4 @@
import { QUARK_PAN_HOST, QUARK_DRIVE_HOST, EP as QE } from './drivers/quark-api';
import { chromium, BrowserContext, Page } from 'playwright';
import jsQR from 'jsqr';
import { getDb } from '../database/database';
@@ -140,7 +141,7 @@ export async function startQrLogin(): Promise<{
try {
// Navigate to Quark login page (now the homepage itself has QR login)
await page.goto('https://pan.quark.cn/', {
await page.goto(QUARK_PAN_HOST + '/', {
waitUntil: 'commit',
timeout: 30000,
});
@@ -243,7 +244,7 @@ async function pollLoginStatus(session: QrSession) {
// Check URL change as alternative indicator
const url = session.page.url();
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) {
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== QUARK_PAN_HOST + '/' && url.length > 10) {
await checkAndCaptureCookies(session);
}
} catch (err: any) {
@@ -275,7 +276,7 @@ async function checkAndCaptureCookies(session: QrSession) {
session.cookieSnapshot = cookieStr;
try {
const resp = await session.page.evaluate(async () => {
const r = await fetch('https://pan.quark.cn/account/info', {
const r = await fetch(QUARK_PAN_HOST + QE.ACCOUNT_INFO, {
credentials: 'include',
});
return await r.text();
@@ -319,7 +320,7 @@ export async function getQrLoginStatus(sessionId: string): Promise<{
let nickname = '';
try {
const resp = await session.page.evaluate(async () => {
const r = await fetch('https://pan.quark.cn/account/info', {
const r = await fetch(QUARK_PAN_HOST + QE.ACCOUNT_INFO, {
credentials: 'include',
});
return await r.text();
@@ -334,7 +335,7 @@ export async function getQrLoginStatus(sessionId: string): Promise<{
try {
const capResp = await session.page.evaluate(async () => {
const r = await fetch(
'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc',
QUARK_PAN_HOST + QE.CAPACITY_DETAIL + '?pr=ucpro&fr=pc',
{ credentials: 'include' }
);
return await r.text();

View File

@@ -0,0 +1,402 @@
import { chromium, BrowserContext, Page } from 'playwright';
import jsQR from 'jsqr';
import { getDb } from '../database/database';
import { escapeLike } from '../utils/time';
interface QrSession {
id: string;
browserContext: BrowserContext;
page: Page;
createdAt: number;
cookieSnapshot: string;
lastPollAt: number;
qrUrl: string;
status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error';
error?: string;
}
const SESSIONS = new Map<string, QrSession>();
const SESSION_TTL = 5 * 60 * 1000; // 5 minutes
const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks
import config from '../config';
import { formatBytes } from './drivers/quark-api';
const CHROMIUM_PATH = config.chromiumPath;
// Clean up old sessions periodically
setInterval(() => {
const now = Date.now();
for (const [id, session] of SESSIONS.entries()) {
if (now - session.createdAt > SESSION_TTL) {
cleanupSession(id);
}
}
}, 60000);
function cleanupSession(id: string) {
const session = SESSIONS.get(id);
if (session) {
try {
session.browserContext.close().catch(() => {});
} catch {}
SESSIONS.delete(id);
}
}
/**
* Extract QR code URL from the login page canvas using jsQR.
* The actual login QR code is Canvas #0 (anonymous, 177x177), NOT #react-qrcode-logo.
*/
async function extractQrUrl(page: Page): Promise<string> {
// Run inside Playwright's browser context (as a string to avoid Node TS type errors)
const raw = await page.evaluate(`(() => {
const canvases = document.querySelectorAll('canvas');
var results = [];
for (var i = 0; i < canvases.length; i++) {
try {
var c = canvases[i];
var ctx = c.getContext('2d');
if (!ctx) continue;
var imageData = ctx.getImageData(0, 0, c.width, c.height);
results.push({
index: i,
w: c.width,
h: c.height,
data: Array.from(imageData.data)
});
} catch(e) {}
}
return results;
})()`) as unknown as { index: number; w: number; h: number; data: number[] }[];
if (!raw || raw.length === 0) {
throw new Error('页面没有可用的 canvas');
}
// Try to decode each canvas, preferring the one with su.quark.cn URL
let bestUrl = '';
let bestResult: { index: number; w: number; h: number; data: number[] } | null = null;
for (const canvas of raw) {
const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h);
if (code && code.data) {
// If this is the login QR code (has su.quark.cn), use it immediately
if (code.data.includes('su.quark.cn')) {
return code.data;
}
// Otherwise keep it as fallback
if (!bestUrl) {
bestUrl = code.data;
bestResult = canvas;
}
}
}
if (bestUrl) {
return bestUrl;
}
throw new Error('无法解析二维码内容');
}
/**
* Start a QR code login session.
* Launches headless Chromium, navigates to Quark login page, extracts QR code URL.
*/
export async function startQrLogin(): Promise<{
sessionId: string;
qrUrl: string;
expiresIn: number;
}> {
// Clean up any existing expired sessions
for (const [id, session] of SESSIONS.entries()) {
if (Date.now() - session.createdAt > SESSION_TTL) {
cleanupSession(id);
}
}
const browser = await chromium.launch({
executablePath: CHROMIUM_PATH,
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--no-first-run',
'--no-zygote',
],
});
const browserContext = await browser.newContext({
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1280, height: 800 },
locale: 'zh-CN',
});
const page = await browserContext.newPage();
const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
try {
// Navigate to Quark login page (now the homepage itself has QR login)
await page.goto('https://pan.quark.cn/', {
waitUntil: 'commit',
timeout: 30000,
});
// Wait for the QR code canvas to appear
await page.waitForSelector('canvas', { timeout: 15000 });
// Extra wait for the QR code to fully render
await page.waitForTimeout(2000);
// Extract the QR code URL from the canvas
const qrUrl = await extractQrUrl(page);
// Take initial cookie snapshot
const cookies = await browserContext.cookies();
const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; ');
const session: QrSession = {
id: sessionId,
browserContext,
page,
createdAt: Date.now(),
cookieSnapshot,
lastPollAt: Date.now(),
qrUrl,
status: 'pending',
};
SESSIONS.set(sessionId, session);
// Start background polling for login detection
pollLoginStatus(session);
// Handle page navigation (like redirect after login)
page.on('framenavigated', async (frame) => {
if (frame === page.mainFrame()) {
const url = frame.url();
if (url === 'about:blank') {
await checkAndCaptureCookies(session);
}
}
});
// Handle popups/dialogs
page.on('popup', async (popup) => {
try {
await popup.waitForLoadState('networkidle', { timeout: 10000 });
await checkAndCaptureCookies(session);
} catch {}
});
return {
sessionId,
qrUrl,
expiresIn: SESSION_TTL / 1000,
};
} catch (err: any) {
// Clean up on failure
try { await browserContext.close(); } catch {}
try { browser.close().catch(() => {}); } catch {}
SESSIONS.delete(sessionId);
throw new Error(`启动扫码登录失败: ${err.message}`);
}
}
/**
* Poll login status in background.
* Checks cookies every COOKIE_CHECK_INTERVAL ms for new session tokens.
*/
async function pollLoginStatus(session: QrSession) {
const checkInterval = setInterval(async () => {
try {
const now = Date.now();
// Check if expired
if (now - session.createdAt > SESSION_TTL) {
clearInterval(checkInterval);
session.status = 'expired';
cleanupSession(session.id);
return;
}
session.lastPollAt = now;
// Check cookies
const cookies = await session.browserContext.cookies();
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
// Check for session cookies indicating login
const hasSessionCookie = cookies.some(
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
);
if (hasSessionCookie) {
session.cookieSnapshot = cookieStr;
session.status = 'logged_in';
clearInterval(checkInterval);
return;
}
// Check URL change as alternative indicator
const url = session.page.url();
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) {
await checkAndCaptureCookies(session);
}
} catch (err: any) {
// Page might have been closed
clearInterval(checkInterval);
}
}, COOKIE_CHECK_INTERVAL);
}
/**
* Check cookies after navigation/redirect and capture them if login succeeded.
*/
async function checkAndCaptureCookies(session: QrSession) {
try {
const cookies = await session.browserContext.cookies();
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
const hasSessionCookie = cookies.some(
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
);
if (hasSessionCookie) {
session.cookieSnapshot = cookieStr;
session.status = 'logged_in';
} else if (cookies.length > 3) {
const newCookies = cookies.filter(
c => !['ctoken', 'b-user-id', '__wpkreporterwid_'].includes(c.name)
);
if (newCookies.length > 0) {
session.cookieSnapshot = cookieStr;
try {
const resp = await session.page.evaluate(async () => {
const r = await fetch('https://pan.quark.cn/account/info', {
credentials: 'include',
});
return await r.text();
});
const data = JSON.parse(resp);
if (data?.data?.nickname) {
session.status = 'logged_in';
}
} catch {}
}
}
} catch {}
}
/**
* Get the login status for a session.
*/
export async function getQrLoginStatus(sessionId: string): Promise<{
status: string;
cookie?: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
autoUpdated?: boolean;
updatedConfigId?: number;
}> {
const session = SESSIONS.get(sessionId);
if (!session) {
return { status: 'expired' };
}
// Check if expired
if (Date.now() - session.createdAt > SESSION_TTL) {
session.status = 'expired';
cleanupSession(sessionId);
return { status: 'expired' };
}
if (session.status === 'logged_in') {
// Try to get nickname too
let nickname = '';
try {
const resp = await session.page.evaluate(async () => {
const r = await fetch('https://pan.quark.cn/account/info', {
credentials: 'include',
});
return await r.text();
});
const data = JSON.parse(resp);
nickname = data?.data?.nickname || '';
} catch {}
// Fetch capacity info from within the browser context (has full JS signing)
let storageTotal = '';
let storageUsed = '';
try {
const capResp = await session.page.evaluate(async () => {
const r = await fetch(
'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc',
{ credentials: 'include' }
);
return await r.text();
});
const capData = JSON.parse(capResp);
if (capData.status === 200 && capData.data?.capacity_summary) {
const summary = capData.data.capacity_summary;
const total = summary.sum_capacity || 0;
storageTotal = formatBytes(total);
storageUsed = '0 B'; // capacity/detail doesn't return used_size
}
} catch {}
// Build full cookie string including httpOnly cookies
const cookies = await session.browserContext.cookies();
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
// Extract __uid from cookie for duplicate detection
const uidMatch = cookieStr.match(/(?<!\\w)__uid=([a-f0-9-]+)/);
let autoUpdated = false;
let updatedConfigId: number | undefined;
if (uidMatch) {
const uid = uidMatch[1];
try {
const db = getDb();
const existing = db.prepare(
`SELECT id, nickname FROM cloud_configs WHERE cloud_type = 'quark' AND cookie LIKE ?`
).get(`%${escapeLike(uid)}%`) as { id: number; nickname: string } | undefined;
if (existing) {
// Same account → auto-update cookie with capacity info too
const localTimestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
db.prepare(
`UPDATE cloud_configs SET cookie = ?, storage_used = ?, storage_total = ?, updated_at = ? WHERE id = ?`
).run(cookieStr, storageUsed || null, storageTotal || null, localTimestamp, existing.id);
autoUpdated = true;
updatedConfigId = existing.id;
}
} catch {}
}
// Clean up session after successful login
cleanupSession(sessionId);
return {
status: 'logged_in',
cookie: cookieStr,
nickname,
storage_used: storageUsed,
storage_total: storageTotal,
autoUpdated,
updatedConfigId,
};
}
return { status: session.status };
}
/**
* Cancel a QR login session.
*/
export async function cancelQrLogin(sessionId: string): Promise<void> {
cleanupSession(sessionId);
}

View File

@@ -1,3 +1,4 @@
import { buildImageUrl, buildWebUrl, buildSearchUrl, buildDetailUrl } from './tmdb-api';
// Native fetch available in Node 20+
import { getDb } from '../database/database';
import { localTimestamp } from '../utils/time';
@@ -95,7 +96,7 @@ async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise<Conten
let tvResults: any[] = [];
try {
const searchUrl = `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
const searchUrl = buildSearchUrl('movie', keyword);
const searchResp = await fetch(searchUrl, {
headers: { 'Authorization': `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
@@ -111,7 +112,7 @@ async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise<Conten
}
try {
const searchUrl = `https://api.themoviedb.org/3/search/tv?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
const searchUrl = buildSearchUrl('tv', keyword);
const searchResp = await fetch(searchUrl, {
headers: { 'Authorization': `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
@@ -212,7 +213,7 @@ async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise<Conten
let tmdbId = best.id;
try {
const detailUrl = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?language=zh-CN&append_to_response=credits`;
const detailUrl = buildDetailUrl(mediaType as 'movie' | 'tv', tmdbId);
const detailResp = await fetch(detailUrl, {
headers: { 'Authorization': `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
@@ -248,10 +249,10 @@ async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise<Conten
? (movie.runtime > 0 ? `${movie.runtime}分钟` : '')
: (movie.episode_run_time && movie.episode_run_time.length > 0 ? `每集${movie.episode_run_time[0]}分钟` : '');
const description = movie.overview ? movie.overview.substring(0, 200) : '';
const cover = movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : '';
const cover = movie.poster_path ? buildImageUrl(movie.poster_path) : '';
// TMDB detail page URL
const tmdbUrl = `https://www.themoviedb.org/${mediaType}/${tmdbId}`;
const tmdbUrl = buildWebUrl(mediaType as 'movie' | 'tv', tmdbId);
// Generate tags from keyword + title
const tags = genTags({ keyword, title });
@@ -322,4 +323,4 @@ function safeParseTags(tagsStr: string | null | undefined): string[] {
} catch {
return [];
}
}
}

View File

@@ -0,0 +1,325 @@
// Native fetch available in Node 20+
import { getDb } from '../database/database';
import { localTimestamp } from '../utils/time';
export interface ContentInfo {
keyword: string;
title: string;
description: string;
tags: string[];
cover: string;
source: string;
/** TMDB 详情页链接 */
tmdb_url?: string;
/** 评分 e.g. "7.3" */
rating?: string;
/** 评分人数 e.g. "12345" */
rating_count?: string;
/** 发布年份 e.g. "2025" */
year?: string;
/** 类型标签 e.g. ["动作", "科幻"] */
genres?: string[];
/** 导演 e.g. "克里斯托弗·诺兰" */
directors?: string;
/** 演员前5个 e.g. "基里安·墨菲 / 艾米莉·布朗特" */
actors?: string;
/** 制片国家/地区 e.g. "美国 / 英国" */
region?: string;
/** 片长 e.g. "180分钟" */
duration?: string;
}
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export async function getContentInfo(keyword: string): Promise<ContentInfo | null> {
if (!keyword || keyword.length < 1) return null;
const db = getDb();
const tmdbToken = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || '';
if (!tmdbToken) return null;
const cached = db.prepare('SELECT * FROM content_cache WHERE keyword = ?').get(keyword) as any;
if (cached) {
const age = Date.now() - new Date(cached.updated_at + 'Z').getTime();
if (age < CACHE_TTL_MS) {
return rowToContentInfo(cached);
}
}
try {
const info = await fetchFromTMDB(keyword, tmdbToken);
if (info) {
db.prepare(`
INSERT OR REPLACE INTO content_cache
(keyword, title, description, tags, cover, douban_url, source, updated_at,
rating, rating_count, year, genres, directors, actors, region, duration)
VALUES (?, ?, ?, ?, ?, ?, 'tmdb', ?,
?, ?, ?, ?, ?, ?, ?, ?)
`).run(
keyword, info.title, info.description, JSON.stringify(info.tags), info.cover, info.tmdb_url || '', localTimestamp(),
info.rating || '', info.rating_count || '', info.year || '',
JSON.stringify(info.genres || []), info.directors || '', info.actors || '',
info.region || '', info.duration || ''
);
return info;
}
} catch (err) {
console.error(`[Content] Failed to fetch for "${keyword}":`, err);
}
return null;
}
function rowToContentInfo(row: any): ContentInfo {
return {
keyword: row.keyword,
title: row.title || '',
description: row.description || '',
tags: safeParseTags(row.tags),
cover: row.cover || '',
source: row.source || (row.title ? 'tmdb' : 'cache'),
tmdb_url: row.douban_url || '',
rating: row.rating || '',
rating_count: row.rating_count || '',
year: row.year || '',
genres: safeParseTags(row.genres),
directors: row.directors || '',
actors: row.actors || '',
region: row.region || '',
duration: row.duration || '',
};
}
async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise<ContentInfo | null> {
// Step 1: TMDB search — search both movie and TV in parallel
let movieResults: any[] = [];
let tvResults: any[] = [];
try {
const searchUrl = `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
const searchResp = await fetch(searchUrl, {
headers: { 'Authorization': `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
});
if (searchResp.ok) {
const searchData = await searchResp.json() as any;
if (Array.isArray(searchData.results)) {
movieResults = searchData.results;
}
}
} catch {
console.warn(`[Content] TMDB movie search failed for "${keyword}"`);
}
try {
const searchUrl = `https://api.themoviedb.org/3/search/tv?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
const searchResp = await fetch(searchUrl, {
headers: { 'Authorization': `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
});
if (searchResp.ok) {
const searchData = await searchResp.json() as any;
if (Array.isArray(searchData.results)) {
tvResults = searchData.results;
}
}
} catch {
console.warn(`[Content] TMDB TV search failed for "${keyword}"`);
}
// Step 2: Score and rank all results
const isChineseKeyword = /[\u4e00-\u9fff]/.test(keyword);
const kwLower = keyword.toLowerCase();
// Score function: higher = better match
function scoreResult(item: any, type: 'tv' | 'movie'): number {
const name = (type === 'tv' ? (item.name || item.original_name || '') : (item.title || item.original_title || '')).toLowerCase();
// Exact match gets highest priority
if (name === kwLower) return 100;
// Name starts with keyword
if (name.startsWith(kwLower)) return 80;
// Name contains keyword as a standalone segment
if (name.includes(kwLower)) return 60;
// Keyword contains significant portion of name
const cleanName = name.replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
if (kwLower.includes(cleanName) && cleanName.length >= 2) return 40;
// Partial match
if (name.includes(kwLower) || kwLower.includes(cleanName)) return 20;
return 0;
}
// Score all TV results
const scoredTV = tvResults.map((r: any) => ({ item: r, score: scoreResult(r, 'tv') })).filter(r => r.score > 0);
// Score all movie results
const scoredMovie = movieResults.map((r: any) => ({ item: r, score: scoreResult(r, 'movie') })).filter(r => r.score > 0);
// Sort by score descending
scoredTV.sort((a, b) => b.score - a.score);
scoredMovie.sort((a, b) => b.score - a.score);
const tvBest = scoredTV[0]?.item || null;
const movieBest = scoredMovie[0]?.item || null;
const tvBestScore = scoredTV[0]?.score || 0;
const movieBestScore = scoredMovie[0]?.score || 0;
let best: any = null;
let mediaType: 'movie' | 'tv' = 'movie';
let movie: any = null;
if (tvBest && movieBest) {
// Both have matches — score-based comparison
// For Chinese keywords: TV gets +15 score bonus to prefer series over movies
const tvScore = tvBestScore + (isChineseKeyword ? 15 : 0);
const movieScore = movieBestScore;
if (tvScore > movieScore) {
best = tvBest;
mediaType = 'tv';
} else if (movieScore > tvScore) {
best = movieBest;
mediaType = 'movie';
} else {
// Tie — prefer TV for Chinese keywords, otherwise pick higher vote count
if (isChineseKeyword) {
best = tvBest;
mediaType = 'tv';
} else {
const tvVotes = tvBest.vote_count || 0;
const movieVotes = movieBest.vote_count || 0;
best = tvVotes >= movieVotes ? tvBest : movieBest;
mediaType = tvVotes >= movieVotes ? 'tv' : 'movie';
}
}
} else if (tvBest) {
best = tvBest;
mediaType = 'tv';
} else if (movieBest) {
best = movieBest;
mediaType = 'movie';
} else if (scoredTV.length > 0 && !scoredMovie.length) {
best = scoredTV[0].item;
mediaType = 'tv';
} else if (scoredMovie.length > 0) {
best = scoredMovie[0].item;
mediaType = 'movie';
} else if (tvResults.length > 0 && !movieResults.length) {
best = tvResults[0];
mediaType = 'tv';
} else if (movieResults.length > 0) {
best = movieResults[0];
mediaType = 'movie';
} else {
return null;
}
let tmdbId = best.id;
try {
const detailUrl = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?language=zh-CN&append_to_response=credits`;
const detailResp = await fetch(detailUrl, {
headers: { 'Authorization': `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
});
if (detailResp.ok) {
movie = await detailResp.json() as any;
}
} catch {
console.warn(`[Content] TMDB detail failed for ${mediaType} id ${tmdbId}`);
return null;
}
if (!movie) return null;
// Extract TMDB data (use title for movie, name for TV)
const title = movie.title || movie.name || keyword;
const rating = movie.vote_average > 0 ? String(Math.round(movie.vote_average * 10) / 10) : '';
const ratingCount = movie.vote_count ? String(movie.vote_count) : '';
// Use release_date for movie, first_air_date for TV
const year = movie.release_date ? movie.release_date.substring(0, 4) : (movie.first_air_date ? movie.first_air_date.substring(0, 4) : '');
const genres = Array.isArray(movie.genres) ? movie.genres.map((g: any) => g.name).filter(Boolean) : [];
// Directors: tv shows have limited crew data, fall back to "creator" for TV
const directors = Array.isArray(movie.credits?.crew)
? movie.credits.crew.filter((c: any) => c.job === 'Director').map((c: any) => c.name).filter(Boolean).join(' / ')
: '';
const actors = Array.isArray(movie.credits?.cast)
? movie.credits.cast.slice(0, 5).map((c: any) => c.name).filter(Boolean).join(' / ')
: '';
const region = Array.isArray(movie.production_countries)
? movie.production_countries.map((c: any) => c.name).filter(Boolean).join(' / ')
: (Array.isArray(movie.origin_country) ? movie.origin_country.join(' / ') : '');
const duration = mediaType === 'movie'
? (movie.runtime > 0 ? `${movie.runtime}分钟` : '')
: (movie.episode_run_time && movie.episode_run_time.length > 0 ? `每集${movie.episode_run_time[0]}分钟` : '');
const description = movie.overview ? movie.overview.substring(0, 200) : '';
const cover = movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : '';
// TMDB detail page URL
const tmdbUrl = `https://www.themoviedb.org/${mediaType}/${tmdbId}`;
// Generate tags from keyword + title
const tags = genTags({ keyword, title });
// Build description fallback
let desc = description;
if (!desc) {
const parts: string[] = [];
if (year) parts.push(`${year}`);
if (genres.length > 0) parts.push(genres.slice(0, 3).join(' / '));
if (duration) parts.push(duration);
desc = parts.length > 0 ? parts.join(' · ') : '';
}
return {
keyword,
title,
description: desc,
tags,
cover,
source: 'tmdb',
tmdb_url: tmdbUrl,
rating,
rating_count: ratingCount,
year,
genres,
directors,
actors,
region,
duration,
};
}
function genTags(opts: { keyword: string; title: string }): string[] {
const { keyword, title } = opts;
const tags: string[] = [];
if (keyword.length <= 8) tags.push(keyword);
const txt = (title + ' ' + keyword).toLowerCase();
const isDonghua = /动画|动漫/i.test(txt);
if (isDonghua) {
tags.push('动画'); tags.push('国漫');
} else {
tags.push('电影');
}
const genreMap: Record<string, string[]> = {
'动画': ['动画'], '动漫': ['动漫'], '国漫': ['国漫'],
'剧场版': ['剧场版'], '年番': ['年番'],
'动作': ['动作'], '奇幻': ['奇幻'], '玄幻': ['玄幻'],
'仙侠': ['仙侠'], '古装': ['古装'], '爱情': ['爱情'],
'科幻': ['科幻'], '喜剧': ['喜剧'], '悬疑': ['悬疑'],
'冒险': ['冒险'], '战争': ['战争'], '纪录': ['纪录片'], '真人': ['真人秀'],
};
for (const [key, vals] of Object.entries(genreMap)) {
if (txt.includes(key)) {
for (const v of vals) { if (!tags.includes(v)) tags.push(v); }
}
}
return tags;
}
function safeParseTags(tagsStr: string | null | undefined): string[] {
if (!tagsStr) return [];
try {
const parsed = JSON.parse(tagsStr);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}

View File

@@ -0,0 +1,59 @@
// @ts-nocheck
// ===================================================================
// TMDB (The Movie Database) API 统一模块
// 所有 TMDB API 域名、端点、图片 URL 集中管理
// 换 API 版本或切换到镜像只需改这个文件
// ===================================================================
// ==================== 域名 ====================
export const TMDB_API_HOST = 'https://api.themoviedb.org';
export const TMDB_IMAGE_HOST = 'https://image.tmdb.org';
export const TMDB_WEB_HOST = 'https://www.themoviedb.org';
// ==================== API 版本前缀 ====================
const V3 = '/3';
// ==================== API 端点路径 ====================
export const EP = {
// ── 搜索 ──
SEARCH_MOVIE: V3 + '/search/movie', // GET ?query=&language=zh-CN&page=
SEARCH_TV: V3 + '/search/tv', // GET ?query=&language=zh-CN&page=
// ── 详情 ──
MOVIE_DETAIL: V3 + '/movie', // GET /{id}?language=zh-CN&append_to_response=credits
TV_DETAIL: V3 + '/tv', // GET /{id}?language=zh-CN&append_to_response=credits
// ── 发现 ──
DISCOVER_MOVIE: V3 + '/discover/movie', // GET ?sort_by=&vote_count=
DISCOVER_TV: V3 + '/discover/tv', // GET ?sort_by=&vote_count=
// ── 配置 ──
CONFIGURATION: V3 + '/configuration', // GET 获取图片配置
// ── 图片 ──
POSTER_W500: '/t/p/w500', // 海报 500px 宽度
} as const;
// ==================== 工具函数 ====================
/** 构建图片完整 URL */
export function buildImageUrl(path: string | null, size: string = EP.POSTER_W500): string {
if (!path) return '';
return TMDB_IMAGE_HOST + size + path;
}
/** 构建 TMDB 网页 URL */
export function buildWebUrl(mediaType: 'movie' | 'tv', tmdbId: number): string {
return TMDB_WEB_HOST + '/' + mediaType + '/' + tmdbId;
}
/** 构建 API 搜索 URL */
export function buildSearchUrl(type: 'movie' | 'tv', keyword: string, page = 1): string {
const ep = type === 'movie' ? EP.SEARCH_MOVIE : EP.SEARCH_TV;
return TMDB_API_HOST + ep + '?query=' + encodeURIComponent(keyword) + '&language=zh-CN&page=' + page;
}
/** 构建 API 详情 URL */
export function buildDetailUrl(mediaType: 'movie' | 'tv', tmdbId: number): string {
const base = mediaType === 'movie' ? EP.MOVIE_DETAIL : EP.TV_DETAIL;
return TMDB_API_HOST + base + '/' + tmdbId + '?language=zh-CN&append_to_response=credits';
}

View File

@@ -1,3 +1,4 @@
import { TMDB_API_HOST, EP as TE } from '../content/tmdb-api';
import { Router, Request, Response } from 'express';
// Native fetch available in Node 20+
import fs from "fs";
@@ -512,7 +513,7 @@ router.post('/admin/test-external-service', async (req: Request, res: Response)
res.json({ ok: false, info: 'TMDB API Key not configured' });
return;
}
const response = await fetch('https://api.themoviedb.org/3/configuration', {
const response = await fetch(TMDB_API_HOST + TE.CONFIGURATION, {
headers: { Authorization: `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
});

View File

@@ -0,0 +1,867 @@
import { Router, Request, Response } from 'express';
// Native fetch available in Node 20+
import fs from "fs";
import { execSync } from 'child_process';
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
import { getSaveRecords } from '../cloud/cloud.service';
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service';
import { dailyCheckIn, skipCheckin, getCheckinSummary, getDrivesForCheckin } from '../cloud/checkin.service';
import { getAllCloudTypes } from '../cloud/cloud-types.service';
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
import { getAllPushUsers, upsertPushUser, updatePushUser, deletePushUser } from '../cloud/push-user.service';
import { getAllNotifierParams, testChannel, saveConfigNotifySettings, getConfigNotifySettingsJSON, getGlobalNotifyConfig } from '../cloud/notification.service';
import { getStats } from '../admin/stats.service';
import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service';
import { getDb } from '../database/database';
import config from '../config';
import { reconnectRedis, testRedisConnection } from '../middleware/cache';
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
import { testProxyConnection } from '../utils/proxy-agent';
const router = Router();
// ═══════════════════════════════════════
// Public routes (no auth required)
// ═══════════════════════════════════════
/**
* POST /api/admin/login
* Admin login
*/
router.post('/admin/login', loginLimiter, (req: Request, res: Response) => {
try {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
}
const token = login(username, password);
if (!token) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
res.json({ token });
} catch (err: any) {
console.error('[Login] Error:', err);
res.status(500).json({ error: err.message || 'Internal server error' });
}
});
/**
* GET /api/admin/cloud-types
* List all cloud types (public, read-only).
*/
router.get('/admin/cloud-types', (_req: Request, res: Response) => {
try {
const types = getAllCloudTypes();
res.json({ types });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Internal server error' });
}
});
// ═══════════════════════════════════════
// QR Login routes (no auth — user not logged in yet)
// MUST be before authMiddleware!
// ═══════════════════════════════════════
// ===== 夸克扫码登录 =====
router.post('/admin/quark/qr-login/start', async (_req: Request, res: Response) => {
try {
const result = await startQrLogin();
res.json({ ok: true, ...result });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
router.get('/admin/quark/qr-login/:sessionId/status', async (req: Request, res: Response) => {
try {
const sessionId = req.params.sessionId as string;
const result = await getQrLoginStatus(sessionId);
res.json({ ok: true, ...result });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
router.post('/admin/quark/qr-login/:sessionId/cancel', async (req: Request, res: Response) => {
try {
const sessionId = req.params.sessionId as string;
await cancelQrLogin(sessionId);
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// ===== 百度扫码登录 =====
router.post("/admin/baidu/qr-login/start", async (_req: Request, res: Response) => {
try {
const result = await BaiduDriver.startQrLogin();
res.json({ ok: true, ...result });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
router.get("/admin/baidu/qr-login/:sessionId/status", async (req: Request, res: Response) => {
try {
const sessionId = req.params.sessionId as string;
const result: any = await BaiduDriver.getQrLoginStatus(sessionId);
// Map to frontend-expected format (frontend reads data.cookie)
res.json({
ok: true,
status: result.status,
cookie: result.cookie || result.access_token || "",
nickname: result.nickname || "",
storage_used: result.storage_used || "",
storage_total: result.storage_total || "",
});
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: Response) => {
try {
BaiduDriver.cancelQrLogin(req.params.sessionId as string);
} catch {}
res.json({ ok: true });
});
// ═══════════════════════════════════════
// Auth wall — all routes below require JWT
// ═══════════════════════════════════════
router.use('/admin', authMiddleware);
// ═══════════════════════════════════════
// Cloud Configs CRUD
// ═══════════════════════════════════════
/** GET /api/admin/cloud-configs — list all cloud configs */
router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
try {
const configs = getCloudConfigs();
res.json(configs);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' });
}
});
/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */
router.post('/admin/cloud-configs', (req: Request, res: Response) => {
try {
const data = req.body;
if (!data.cloud_type) {
res.status(400).json({ error: 'cloud_type is required' });
return;
}
// Normalize is_active: frontend sends boolean, SQLite needs 0/1
if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0;
const saved = saveCloudConfig(data);
res.json(saved);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save cloud config' });
}
});
/** PUT /api/admin/cloud-configs/:id — update an existing cloud config */
router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const existing = getCloudConfigById(id);
if (!existing) {
res.status(404).json({ error: 'Cloud config not found' });
return;
}
const saved = saveCloudConfig({ ...req.body, id });
res.json(saved);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update cloud config' });
}
});
/** DELETE /api/admin/cloud-configs/:id */
router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const ok = deleteCloudConfig(id);
if (!ok) {
res.status(404).json({ error: 'Cloud config not found' });
return;
}
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to delete cloud config' });
}
});
/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */
router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => {
try {
const type = req.params.type as string;
const { cookie, id } = req.body;
// If cookie is provided directly, test with it (for new configs not yet saved)
if (cookie) {
const result = await testCloudConnectionWithCookie(type, cookie);
res.json(result);
return;
}
// Otherwise test by config id
if (id) {
const result = await testCloudConnection(parseInt(id));
res.json(result);
return;
}
res.status(400).json({ success: false, message: 'Provide either cookie or id' });
} catch (err: any) {
res.status(500).json({ success: false, message: err.message || 'Connection test failed' });
}
});
// ═══════════════════════════════════════
// Daily Check-in
// ═══════════════════════════════════════
/** POST /api/admin/cloud-configs/:id/checkin */
router.post('/admin/cloud-configs/:id/checkin', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const result = await dailyCheckIn(id);
res.json(result);
} catch (err: any) {
res.status(500).json({ success: false, message: err.message || 'Check-in failed' });
}
});
/** POST /api/admin/cloud-configs/:id/skip-checkin */
router.post('/admin/cloud-configs/:id/skip-checkin', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const ok = skipCheckin(id);
res.json({ success: ok });
} catch (err: any) {
res.status(500).json({ success: false, message: err.message || 'Skip check-in failed' });
}
});
/** POST /api/admin/cloud-configs/checkin-all */
router.post('/admin/cloud-configs/checkin-all', async (_req: Request, res: Response) => {
try {
const drives = getDrivesForCheckin();
const results: { id: number; nickname: string; success: boolean; message: string }[] = [];
let total = 0;
for (const drive of drives) {
total++;
try {
const result = await dailyCheckIn(drive.id);
results.push({ id: drive.id, nickname: drive.nickname || '', success: result.success, message: result.message });
} catch (err: any) {
results.push({ id: drive.id, nickname: drive.nickname || '', success: false, message: err.message || 'Check-in failed' });
}
}
res.json({ total, results });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Check-in all failed' });
}
});
/** GET /api/admin/cloud-configs/checkin-summary */
router.get('/admin/cloud-configs/checkin-summary', (_req: Request, res: Response) => {
try {
const summary = getCheckinSummary();
res.json(summary);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get check-in summary' });
}
});
// ═══════════════════════════════════════
// Stats
// ═══════════════════════════════════════
/** GET /api/admin/stats */
router.get('/admin/stats', (req: Request, res: Response) => {
try {
const days = req.query.days ? parseInt(req.query.days as string) : 7;
const stats = getStats(days);
res.json(stats);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get stats' });
}
});
// ═══════════════════════════════════════
// Save Records (转存日志)
// ═══════════════════════════════════════
/** GET /api/admin/save-records */
router.get('/admin/save-records', (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
const startDate = req.query.startDate as string | undefined;
const endDate = req.query.endDate as string | undefined;
const status = req.query.status as string | undefined;
const sourceType = req.query.sourceType as string | undefined;
const keyword = req.query.keyword as string | undefined;
const result = getSaveRecords(page, pageSize, startDate, endDate, status, sourceType, keyword);
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get save records' });
}
});
// ═══════════════════════════════════════
// System Configs
// ═══════════════════════════════════════
/** GET /api/admin/system-configs */
router.get('/admin/system-configs', (_req: Request, res: Response) => {
try {
const configs = getAllSystemConfigs();
res.json(configs);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get system configs' });
}
});
/** PUT /api/admin/system-configs — batch update */
router.put('/admin/system-configs', (req: Request, res: Response) => {
try {
const { entries } = req.body;
if (!entries || !Array.isArray(entries)) {
res.status(400).json({ error: 'entries array is required' });
return;
}
updateSystemConfigs(entries);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update system configs' });
}
});
// ═══════════════════════════════════════
// Cloud Types Toggle
// ═══════════════════════════════════════
/** PUT /api/admin/cloud-types — toggle cloud type enabled/disabled */
router.put('/admin/cloud-types', (req: Request, res: Response) => {
try {
const { type, enabled } = req.body;
if (!type) {
res.status(400).json({ error: 'type is required' });
return;
}
const db = getDb();
db.prepare(
`INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
).run(`cloud_type_${type}_enabled`, enabled ? '1' : '0', `Enable/disable ${type} cloud drive`);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to toggle cloud type' });
}
});
// ═══════════════════════════════════════
// Change Password
// ═══════════════════════════════════════
/** POST /api/admin/change-password */
router.post('/admin/change-password', (req: Request, res: Response) => {
try {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword) {
res.status(400).json({ error: 'Both old and new passwords are required' });
return;
}
// Get username from JWT
const authHeader = req.headers.authorization || '';
const token = authHeader.replace('Bearer ', '');
const payload = verifyToken(token);
if (!payload) {
res.status(401).json({ error: 'Invalid token' });
return;
}
const result = changePassword(payload.username, oldPassword, newPassword);
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to change password' });
}
});
// ═══════════════════════════════════════
// DB Status
// ═══════════════════════════════════════
/** GET /api/admin/db-status */
router.get('/admin/db-status', async (_req: Request, res: Response) => {
try {
const dbFile = getSystemConfig('db_path') || config.dbPath || '';
let dbSize = 'N/A';
if (dbFile) {
try {
const stats = fs.statSync(dbFile);
dbSize = (stats.size / 1024 / 1024).toFixed(2) + ' MB';
} catch {}
}
const db = getDb();
const counts = {
save_records: (db.prepare('SELECT COUNT(*) as c FROM save_records').get() as any)?.c || 0,
search_stats: (db.prepare('SELECT COUNT(*) as c FROM search_stats').get() as any)?.c || 0,
system_configs: (db.prepare('SELECT COUNT(*) as c FROM system_configs').get() as any)?.c || 0,
cloud_configs: (db.prepare('SELECT COUNT(*) as c FROM cloud_configs').get() as any)?.c || 0,
content_cache: (db.prepare('SELECT COUNT(*) as c FROM content_cache').get() as any)?.c || 0,
};
// Redis status
let redis_status = 'disconnected';
let redis_url = getSystemConfig('redis_url') || '';
try {
const testResult = await testRedisConnection(redis_url);
redis_status = testResult.ok ? '已连接' : '未连接';
} catch {
redis_status = '错误';
}
res.json({
db_size: dbSize,
db_path: dbFile,
...counts,
redis_status,
redis_url,
});
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get DB status' });
}
});
// ═══════════════════════════════════════
// Test Redis Connection
// ═══════════════════════════════════════
/** POST /api/admin/test-redis */
router.post('/admin/test-redis', async (req: Request, res: Response) => {
try {
const { url } = req.body;
if (!url) {
res.status(400).json({ ok: false, info: 'Redis URL is required' });
return;
}
const result = await testRedisConnection(url);
res.json(result);
} catch (err: any) {
res.status(500).json({ ok: false, info: err.message || 'Redis test failed' });
}
});
// ═══════════════════════════════════════
// Test External Service
// ═══════════════════════════════════════
/** POST /api/admin/test-external-service */
router.post('/admin/test-external-service', async (req: Request, res: Response) => {
try {
const { type, url, token } = req.body;
const start = Date.now();
switch (type) {
case 'pansou': {
const pansouUrl = url || getSystemConfig('pansou_url') || '';
if (!pansouUrl) {
res.json({ ok: false, info: 'PanSou URL not configured' });
return;
}
const response = await fetch(pansouUrl + '/api/health', { signal: AbortSignal.timeout(8000) });
const data: any = await response.json();
const latency = Date.now() - start;
res.json({
ok: response.ok && data?.status === 'ok',
latency,
info: response.ok ? `连接成功 (${data?.channels_count || 0} 频道, ${data?.plugin_count || 0} 插件)` : '连接失败',
});
break;
}
case 'video_parser': {
const parserUrl = url || getSystemConfig('video_parser_url') || '';
if (!parserUrl) {
res.json({ ok: false, info: 'Video Parser URL not configured' });
return;
}
const response = await fetch(parserUrl + '/health', { signal: AbortSignal.timeout(8000) });
const latency = Date.now() - start;
res.json({
ok: response.ok,
latency,
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
});
break;
}
case 'tmdb': {
const tmdbToken = token || getSystemConfig('tmdb_api_key') || '';
if (!tmdbToken) {
res.json({ ok: false, info: 'TMDB API Key not configured' });
return;
}
const response = await fetch('https://api.themoviedb.org/3/configuration', {
headers: { Authorization: `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
});
const latency = Date.now() - start;
res.json({
ok: response.ok,
latency,
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
});
break;
}
case 'proxy': {
const proxyUrl = url || getSystemConfig('proxy_url') || '';
if (!proxyUrl) {
res.json({ ok: false, info: 'Proxy URL not configured' });
return;
}
const result = await testProxyConnection(proxyUrl);
res.json(result);
break;
}
case 'ip_geo': {
const apiId = url || getSystemConfig('ip_geo_api_id') || '';
const apiKey = getSystemConfig('ip_geo_api_key') || '';
if (!apiId || !apiKey) {
res.json({ ok: false, info: '请先配置 IP 归属地 API ID 和 Key' });
return;
}
const testUrl = `https://cn.apihz.cn/api/ip/chaapi.php?id=${encodeURIComponent(apiId)}&key=${encodeURIComponent(apiKey)}&ip=8.8.8.8&td=0`;
const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) });
const data: any = await response.json();
const latency = Date.now() - start;
const valid = data?.code === 200;
res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' });
break;
}
default:
res.json({ ok: false, info: `Unknown service type: ${type}` });
}
} catch (err: any) {
res.status(500).json({ ok: false, info: err.message || 'External service test failed' });
}
});
// ═══════════════════════════════════════
// Pansou Info & Update
// ═══════════════════════════════════════
/** GET /api/admin/pansou-info — pansou health + version + update check */
router.get('/admin/pansou-info', async (_req: Request, res: Response) => {
try {
const baseUrl = getSystemConfig('pansou_url') || '';
if (!baseUrl) {
res.json({ status: 'disconnected', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '' });
return;
}
// Fetch PanSou health
const healthUrl = baseUrl + '/api/health';
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(8000) });
const healthData: any = await response.json();
const channelCount = healthData.channels_count || 0;
const pluginCount = healthData.plugin_count || 0;
// Derive disk count from channel names
const driveKeywords = ['aliyun', 'baidu', 'quark', '115', 'pikpak', 'xunlei', 'uc', '123', '139', '189', 'tianyi', 'netease'];
const drives = new Set<string>();
for (const ch of (healthData.channels || [])) {
for (const kw of driveKeywords) {
if (ch.toLowerCase().includes(kw)) { drives.add(kw); break; }
}
}
const diskCount = drives.size || 5;
// Get local version from docker label
let version = '';
let hasUpdate = false;
let latestVersion = '';
try {
const created = execSync(
`docker inspect CloudSearch_PanSou --format '{{index .Config.Labels "org.opencontainers.image.created"}}'`,
{ timeout: 5000, encoding: 'utf8' }
).trim();
version = created ? created.slice(0, 10) : '';
// Check update cache
const cacheFile = '/tmp/pansou-update-cache.json';
let cache: any = null;
try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8') || 'null'); } catch {}
const threeDays = 3 * 24 * 3600 * 1000;
if (!cache || (Date.now() - cache.checkedAt) > threeDays) {
// Check GHCR for latest version
try {
const tokenRes = await fetch(
'https://ghcr.io/token?scope=repository:fish2018/pansou-web:pull&service=ghcr.io'
);
const ghcrToken = (await tokenRes.json() as any).token;
const manifestRes = await fetch(
'https://ghcr.io/v2/fish2018/pansou-web/manifests/latest',
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' } }
);
const manifestList: any = await manifestRes.json();
const amd64 = manifestList.manifests?.find((m: any) => m.platform?.architecture === 'amd64' && m.platform?.os === 'linux');
if (amd64) {
const blobRes = await fetch(
`https://ghcr.io/v2/fish2018/pansou-web/manifests/${amd64.digest}`,
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json' } }
);
const blobData: any = await blobRes.json();
const cfgDigest = blobData.config?.digest;
if (cfgDigest) {
const cfgRes = await fetch(
`https://ghcr.io/v2/fish2018/pansou-web/blobs/${cfgDigest}`,
{ headers: { Authorization: `Bearer ${ghcrToken}` } }
);
const cfgData: any = await cfgRes.json();
const remoteCreated = cfgData.config?.Labels?.['org.opencontainers.image.created'];
if (remoteCreated) {
latestVersion = remoteCreated.slice(0, 10);
if (version && latestVersion !== version) hasUpdate = true;
}
}
}
} catch {}
fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), hasUpdate, latestVersion }));
} else {
hasUpdate = cache.hasUpdate;
latestVersion = cache.latestVersion;
}
} catch {}
res.json({
status: response.ok ? 'connected' : 'disconnected',
channelCount,
pluginCount,
diskCount,
version,
hasUpdate,
latestVersion,
});
} catch (err: any) {
res.json({ status: 'error', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '', error: err.message });
}
});
/** POST /api/admin/update-pansou — pull latest pansou image + recreate container */
router.post('/admin/update-pansou', async (_req: Request, res: Response) => {
try {
execSync('docker pull ghcr.io/fish2018/pansou-web:latest', { timeout: 120000 });
execSync('docker compose -p cloudsearch -f /app/docker-compose.yml up -d pansou', { timeout: 60000 });
try { fs.unlinkSync('/tmp/pansou-update-cache.json'); } catch {}
res.json({ success: true, message: 'PanSou 更新成功' });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message || 'PanSou 更新失败' });
}
});
// ======================== Notification / Push Users ========================
/** GET /api/admin/cloud-configs/:id/notify */
router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const settings = getConfigNotifySettingsJSON(id);
res.json(settings);
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to get notification settings' });
}
});
/** PUT /api/admin/cloud-configs/:id/notify */
router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const settings = req.body;
saveConfigNotifySettings(id, settings);
res.json({ success: true, message: 'Push config saved' });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to save notification settings' });
}
});
/** POST /api/admin/notify/test */
router.post('/admin/notify/test', async (req: Request, res: Response) => {
try {
const { channelType, account, configId, params } = req.body;
const ctx = account || (configId ? String(configId) : undefined);
const result = await testChannel(channelType, ctx, params);
res.json(result);
} catch (err: any) {
res.json({ success: false, message: err.message || 'Test send failed' });
}
});
/** GET /api/admin/notify/providers */
router.get('/admin/notify/providers', (_req: Request, res: Response) => {
try {
const providers = getAllNotifierParams();
res.json(providers);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get providers' });
}
});
/** GET /api/admin/notify/global-config */
router.get('/admin/notify/global-config', (_req, res) => {
try {
const cfg = getGlobalNotifyConfig();
res.json(cfg);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get global config' });
}
});
/** PUT /api/admin/notify/global-config */
router.put('/admin/notify/global-config', (req, res) => {
try {
const cfg = req.body;
if (!cfg || typeof cfg !== 'object') {
res.status(400).json({ error: 'Invalid config object' });
return;
}
updateSystemConfig('global_notify_config', JSON.stringify(cfg));
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save global config' });
}
});
/** GET /api/admin/push-users */
router.get('/admin/push-users', (_req: Request, res: Response) => {
try {
const users = getAllPushUsers();
const parsed = users.map(u => ({
...u,
notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(),
}));
res.json(parsed);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to list push users' });
}
});
/** POST /api/admin/push-users */
router.post('/admin/push-users', (req: Request, res: Response) => {
try {
const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' });
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {});
const user = upsertPushUser(account, configStr);
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to save push user' });
}
});
/** PUT /api/admin/push-users/:id */
router.put('/admin/push-users/:id', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' });
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {});
const user = updatePushUser(id, account, configStr);
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to update push user' });
}
});
/** DELETE /api/admin/push-users/:id */
router.delete('/admin/push-users/:id', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const ok = deletePushUser(id);
if (ok) res.json({ success: true });
else res.status(404).json({ error: 'Push user not found' });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to delete push user' });
}
});
// ═══════════════════════════════════════════════
// Daily Report
// ═══════════════════════════════════════════════
/** GET /api/admin/daily-report/config */
router.get('/admin/daily-report/config', (_req, res) => {
try {
const { getDailyReportConfig } = require('../services/daily-report.service');
const cfg = getDailyReportConfig();
res.json(cfg);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get daily report config' });
}
});
/** PUT /api/admin/daily-report/config */
router.put('/admin/daily-report/config', (req, res) => {
try {
const { saveDailyReportConfig } = require('../services/daily-report.service');
saveDailyReportConfig(req.body);
const { getDailyReportConfig } = require('../services/daily-report.service');
res.json(getDailyReportConfig());
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save daily report config' });
}
});
/** GET /api/admin/daily-report/preview */
router.get('/admin/daily-report/preview', (req, res) => {
try {
const { previewDailyReport, generateDailyReport } = require('../services/daily-report.service');
const date = req.query.date as string || undefined;
const content = previewDailyReport(date);
const report = generateDailyReport(date);
res.json({ content, report });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to preview daily report' });
}
});
/** POST /api/admin/daily-report/test — send a test report immediately */
router.post('/admin/daily-report/test', async (_req, res) => {
try {
const { sendTestDailyReport } = require('../services/daily-report.service');
const result = await sendTestDailyReport();
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to send test report' });
}
});
/** GET /api/admin/daily-report/last-run */
router.get('/admin/daily-report/last-run', (_req, res) => {
try {
const { getSystemConfig } = require('../admin/system-config.service');
const raw = getSystemConfig('daily_report_last_run') || '{}';
let data: any = {};
try { data = JSON.parse(raw); } catch {}
res.json(data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@@ -3,6 +3,7 @@ import multer from 'multer';
import sharp from 'sharp';
import path from 'path';
import fs from 'fs';
import config from '../config';
import { authMiddleware } from '../admin/auth.service';
import { updateSystemConfig } from '../admin/system-config.service';
@@ -15,7 +16,7 @@ const router = Router();
* Upload a fallback cover image for search results without covers.
* Recommended: 320×180 JPEG/PNG (16:9), max 2MB.
*/
const uploadDir = path.resolve('/app/uploads/fallback');
const uploadDir = path.resolve(config.uploadDir, 'fallback');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
@@ -70,7 +71,7 @@ router.post('/admin/upload-fallback-image', authMiddleware, upload.single('image
* Upload a site logo image displayed on search page (home link) and homepage.
* Recommended: 320×60 or similar wide/banner ratio, JPEG/PNG/WebP, max 2MB.
*/
const logoUploadDir = path.resolve('/app/uploads/logo');
const logoUploadDir = path.resolve(config.uploadDir, 'logo');
if (!fs.existsSync(logoUploadDir)) {
fs.mkdirSync(logoUploadDir, { recursive: true });
}

View File

@@ -1,6 +1,262 @@
// Native fetch available in Node 20+
// @ts-nocheck
/**
* 搜索脉搏 — 纯本站搜索数据驱动的实时热榜
* 不再依赖任何外部 API (Bilibili/Baidu/TMDB 已移除)
* 数据来源: 本站 hot_keywords 表,真实用户搜索行为
*/
import { getDb } from '../database/database';
import { getTimezone, formatLocalDateTime } from '../utils/time';
import { formatLocalDateTime } from '../utils/time';
// ==================== 类型定义 ====================
export interface HotKeyword {
keyword: string;
count: number;
updatedAt: string;
genre: string; // 自动检测的分类
genreEmoji: string; // 对应 emoji
heatLevel: number; // 0-5 热度等级 (用于火焰效果)
isRising: boolean; // 是否在上升中
}
export interface SearchPulse {
generatedAt: string;
totalSearches: number; // 总搜索次数
uniqueKeywords: number; // 不同关键词数
hottestHour: string; // 搜索最活跃的小时
hotList: HotKeyword[]; // 热榜 Top 20
recentList: HotKeyword[]; // 最新搜索 Top 15
categories: CategoryGroup[]; // 按分类聚合
}
export interface CategoryGroup {
name: string;
emoji: string;
keywords: HotKeyword[];
}
// ==================== 自动分类检测 ====================
/** 根据关键词内容自动推断分类 */
const GENRE_RULES: { emoji: string; name: string; patterns: RegExp[] }[] = [
{
emoji: '🎬', name: '影视',
patterns: [/电影/, /影院/, /上映/, /票房/, /好莱坞/, /导演/, /演员/, /奥斯卡/, /戛纳/,
/电视剧/, /剧集/, /韩剧/, /美剧/, /日剧/, /英剧/, /国产剧/, /网剧/,
/4[kK]/, /1080[pP]/, /720[pP]/, /[BbDd]lu[Rr]ay/, /[Hh][Dd]/,
/[Mm][Pp]4/, /[Mm][Kk][Vv]/, /[Rr][Mm][Vv][Bb]/, /[Aa][Vv][Ii]/]
},
{
emoji: '🎮', name: '动漫',
patterns: [/动漫/, /动画/, /番剧/, /新番/, /日漫/, /国漫/, /漫画/, /二次元/,
/bilibili/i, /鬼灭/, /海贼/, /火影/, /咒术/, /间谍过家家/,
/柯南/, /哆啦[Aa]梦/, /蜡笔小新/, /龙珠/, /进击的巨人/]
},
{
emoji: '🎵', name: '音乐',
patterns: [/音乐/, /歌曲/, /专辑/, /无损/, /[Ff][Ll][Aa][Cc]/, /[Aa][Pp][Ee]/,
/[Mm][Pp]3/, /演唱会/, /[Mm][Vv]/, /歌手/, /乐队/, /DJ/,
/周杰伦/, /Taylor/, /BTS/]
},
{
emoji: '📚', name: '书籍',
patterns: [/书/, /小说/, /电子书/, /[Pp][Dd][Ff]/, /[Ee][Pp][Uu][Bb]/, /[Mm][Oo][Bb][Ii]/,
/txt/, /漫画书/, /杂志/, /文献/, /论文/, /考试/, /教材/]
},
{
emoji: '💻', name: '软件',
patterns: [/软件/, /破解/, /注册机/, /激活/, /[Cc]rack/, /[Pp]atch/,
/[Ww]indows/, /[Mm]ac[Oo][Ss]/, /[Ll]inux/, /[Aa]ndroid/,
/[Ee]xe/, /[Dd][Mm][Gg]/, /安装包/, /[Aa][Pp][Kk]/]
},
{
emoji: '🎮', name: '游戏',
patterns: [/游戏/, /[Ss]team/, /[Ee]pic/, /[Ss]witch/, /[Pp][Ss]5/, /[Xx]box/,
/手游/, /端游/, /模拟器/, /[Rr][Oo][Mm]/, /[Ii][Ss][Oo]/,
/原神/, /王者/, /吃鸡/, /[Mm]inecraft/]
},
];
function detectGenre(keyword: string): { emoji: string; name: string } {
for (const rule of GENRE_RULES) {
for (const p of rule.patterns) {
if (p.test(keyword)) {
return { emoji: rule.emoji, name: rule.name };
}
}
}
return { emoji: '🔍', name: '其他' };
}
/** 根据搜索次数计算热度等级 0-5 */
function calcHeatLevel(count: number, maxCount: number): number {
if (maxCount === 0) return 0;
const ratio = count / maxCount;
if (ratio >= 0.8) return 5;
if (ratio >= 0.6) return 4;
if (ratio >= 0.4) return 3;
if (ratio >= 0.2) return 2;
if (ratio >= 0.05) return 1;
return 0;
}
/** 简单的"是否在上升"判断:最近更新过且搜索次数在中上水平 */
function isRising(updatedAt: string, count: number, maxCount: number): boolean {
const updated = new Date(updatedAt);
const now = new Date();
const hoursAgo = (now.getTime() - updated.getTime()) / 3600000;
return hoursAgo < 24 && count >= maxCount * 0.1;
}
// ==================== 缓存 ====================
let cache: { data: SearchPulse; time: number } | null = null;
const CACHE_TTL = 30 * 60 * 1000; // 30分钟缓存
function isCacheValid(): boolean {
return cache !== null && (Date.now() - cache.time) < CACHE_TTL;
}
// ==================== 主查询 ====================
async function generateSearchPulse(): Promise<SearchPulse> {
const db = getDb();
const now = new Date();
const localNow = formatLocalDateTime();
// 1. 统计概览
const stats = db.prepare(
'SELECT COUNT(*) as total, SUM(search_count) as totalSearches FROM hot_keywords'
).get() as any;
const uniqueKeywords = stats?.total || 0;
const totalSearches = stats?.totalSearches || 0;
// 2. 热榜 Top 20
const hotRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
).all() as any[];
const maxHotCount = hotRows.length > 0 ? hotRows[0].count : 1;
const hotList: HotKeyword[] = hotRows.map((row: any) => {
const genre = detectGenre(row.keyword);
return {
keyword: row.keyword,
count: row.count,
updatedAt: row.updatedAt,
genre: genre.name,
genreEmoji: genre.emoji,
heatLevel: calcHeatLevel(row.count, maxHotCount),
isRising: isRising(row.updatedAt, row.count, maxHotCount),
};
});
// 3. 最新搜索 Top 15
const recentRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 15'
).all() as any[];
const recentList: HotKeyword[] = recentRows.map((row: any) => {
const genre = detectGenre(row.keyword);
return {
keyword: row.keyword,
count: row.count,
updatedAt: row.updatedAt,
genre: genre.name,
genreEmoji: genre.emoji,
heatLevel: 1,
isRising: true, // 最新搜索都算上升
};
});
// 4. 按分类聚合
const allKeywords = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords WHERE search_count >= 3 ORDER BY search_count DESC'
).all() as any[];
const categoryMap = new Map<string, HotKeyword[]>();
for (const row of allKeywords) {
const genre = detectGenre(row.keyword);
if (!categoryMap.has(genre.name)) {
categoryMap.set(genre.name, []);
}
const group = categoryMap.get(genre.name)!;
if (group.length < 8) {
group.push({
keyword: row.keyword,
count: row.count,
updatedAt: row.updatedAt,
genre: genre.name,
genreEmoji: genre.emoji,
heatLevel: 0,
isRising: false,
});
}
}
const categories: CategoryGroup[] = Array.from(categoryMap.entries())
.sort((a, b) => b[1].length - a[1].length)
.slice(0, 6)
.map(([name, keywords]) => ({
name,
emoji: keywords[0]?.genreEmoji || '📋',
keywords,
}));
// 5. 搜索最活跃的小时
const hourRow = db.prepare(
"SELECT updated_at FROM hot_keywords ORDER BY updated_at DESC LIMIT 100"
).all() as any[];
const hours = hourRow.map((r: any) => new Date(r.updated_at).getHours());
const hourCount = new Map<number, number>();
for (const h of hours) {
hourCount.set(h, (hourCount.get(h) || 0) + 1);
}
let maxH = 19;
let maxC = 0;
for (const [h, c] of hourCount) {
if (c > maxC) { maxC = c; maxH = h; }
}
const hottestHour = `${String(maxH).padStart(2, '0')}:00`;
return {
generatedAt: localNow,
totalSearches,
uniqueKeywords,
hottestHour,
hotList,
recentList,
categories,
};
}
// ==================== 导出接口 ====================
/** 获取搜索脉搏数据 (30分钟缓存) */
export async function getSearchPulse(): Promise<SearchPulse> {
if (isCacheValid()) {
return cache!.data;
}
try {
const data = await generateSearchPulse();
cache = { data, time: Date.now() };
return data;
} catch (err) {
console.error('[SearchPulse] Error:', err);
if (cache) return cache.data;
return {
generatedAt: formatLocalDateTime(),
totalSearches: 0,
uniqueKeywords: 0,
hottestHour: '--',
hotList: [],
recentList: [],
categories: [],
};
}
}
// ==================== 兼容旧接口 ====================
export interface RankingItem {
keyword: string;
@@ -21,331 +277,64 @@ export interface CategorizedResponse {
categories: CategorizedRanking[];
}
// ===== Bilibili PGC 排行榜配置 =====
interface BiliPgcDef {
category: string;
label: string;
season_type: number; // 1=番剧, 2=电影, 3=纪录片, 4=国创, 5=电视剧, 7=综艺
}
const BILI_PGC_CATEGORIES: BiliPgcDef[] = [
// 国创:凡人修仙传、灵笼、斗破苍穹等官方国产动画
{ category: 'donghua', label: '国产动漫', season_type: 4 },
// 番剧:日漫等全球动画
{ category: 'global_anime', label: '热门动漫', season_type: 1 },
];
// ===== 百度热搜榜配置 =====
interface BaiduBoardDef {
category: string;
label: string;
tab: string; // movie=电影热搜, teleplay=电视剧热搜
}
const BAIDU_BOARDS: BaiduBoardDef[] = [
// 百度电影热搜:实时反映国内电影热度
{ category: 'movie', label: '国内电影', tab: 'movie' },
// 百度电视剧热搜:国内剧集热度
{ category: 'tv', label: '热门剧集', tab: 'teleplay' },
];
// ===== TMDB 分类配置(保留欧美和冷门内容)=====
interface TmdbCategoryDef {
category: string;
label: string;
hotUrl: string;
newestUrl: string;
}
const TMDB_CATEGORIES: TmdbCategoryDef[] = [
{
category: 'western_movie', label: '欧美电影',
hotUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10',
newestUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=release_date.desc&vote_count.gte=1',
},
{
category: 'western_tv', label: '欧美剧集',
hotUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10',
newestUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=first_air_date.desc&vote_count.gte=10',
},
{
category: 'niche', label: '冷门佳片',
hotUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=vote_average.desc&vote_count.gte=10&vote_count.lte=500',
newestUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=release_date.desc&vote_count.gte=1&vote_count.lte=500',
},
];
// ===== 显示顺序 =====
const CATEGORY_ORDER: Record<string, number> = {
donghua: 1,
movie: 2,
tv: 3,
global_anime: 4,
western_movie: 5,
western_tv: 6,
niche: 7,
hotsite: 8,
};
// ===== 12小时缓存 =====
let cache: { data: CategorizedResponse; time: number } | null = null;
const CACHE_TTL = 12 * 60 * 60 * 1000;
function isCacheValid(): boolean {
return cache !== null && (Date.now() - cache.time) < CACHE_TTL;
}
// ===== Bilibili PGC API =====
/**
* 抓取 Bilibili PGC 排行榜(番剧/国创)
*/
async function fetchFromBiliPgc(season_type: number): Promise<RankingItem[]> {
try {
const url = `https://api.bilibili.com/pgc/web/rank/list?season_type=${season_type}&day=7`;
const resp = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.bilibili.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
signal: AbortSignal.timeout(8000),
});
if (!resp.ok) {
console.error(`[BiliPGC] HTTP ${resp.status} for season_type=${season_type}`);
return [];
}
const json = await resp.json() as any;
if (json.code !== 0 || !json.result?.list) {
console.error(`[BiliPGC] API error code=${json.code} for season_type=${season_type}`);
return [];
}
return json.result.list.slice(0, 20).map((item: any) => {
const stat = item.stat || {};
const viewCount = stat.view || 0;
const followCount = stat.follow || 0;
const searchCount = viewCount > 0 ? viewCount : followCount;
let rating = 0;
if (item.rating) {
const m = String(item.rating).match(/([\d.]+)/);
if (m) rating = parseFloat(m[1]);
}
return {
keyword: item.title || '',
searchCount,
updatedAt: item.new_ep?.index_show || item.new_ep?.cover || '',
rating,
};
});
} catch (err) {
console.error(`[BiliPGC] Fetch error for season_type=${season_type}:`, (err as Error).message);
return [];
}
}
// ===== 百度热搜榜 API =====
/**
* 抓取百度热搜榜
* tab: movie=电影, teleplay=电视剧
*/
async function fetchFromBaidu(tab: string): Promise<RankingItem[]> {
try {
const url = `https://top.baidu.com/api/board?tab=${tab}`;
const resp = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://top.baidu.com/board',
},
signal: AbortSignal.timeout(8000),
});
if (!resp.ok) {
console.error(`[Baidu] HTTP ${resp.status} for tab=${tab}`);
return [];
}
const json = await resp.json() as any;
if (!json.success || !json.data?.cards) {
console.error(`[Baidu] API error for tab=${tab}`);
return [];
}
const results: RankingItem[] = [];
for (const card of json.data.cards) {
for (const item of (card.content || [])) {
results.push({
keyword: item.word || '',
// hotScore can be like "96438", parse as number
searchCount: parseInt(item.hotScore || '0', 10) || 0,
updatedAt: item.desc || '',
rating: 0,
});
}
}
return results.slice(0, 20);
} catch (err) {
console.error(`[Baidu] Fetch error for tab=${tab}:`, (err as Error).message);
return [];
}
}
// ===== TMDB =====
function getTmdbToken(): string {
const db = getDb();
return (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || '';
}
function tmdbResultToRanking(item: any): RankingItem {
const title = item.title || item.name || '';
const date = item.release_date || item.first_air_date || '';
const rating = item.vote_average ? Math.round(item.vote_average * 10) / 10 : 0;
return {
keyword: title,
searchCount: item.vote_count || 0,
updatedAt: date,
rating,
};
}
async function tmdbFetch(url: string, token: string): Promise<any[]> {
const fullUrl = `${url}${url.includes('?') ? '&' : '?'}language=zh-CN`;
try {
const resp = await fetch(fullUrl, {
headers: { 'Authorization': `Bearer ${token}` },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
console.error(`[TMDB] HTTP ${resp.status} for ${url}`);
return [];
}
const data = await resp.json() as any;
return (data.results || []).slice(0, 20);
} catch (err) {
console.error(`[TMDB] Fetch error for ${url}:`, err);
return [];
}
}
// ===== 主流程 =====
async function fetchRankings(): Promise<CategorizedResponse> {
const fetchedAt = formatLocalDateTime();
// 1. 并行抓取 Bilibili PGC 数据(国漫、番剧)
const biliPromises = BILI_PGC_CATEGORIES.map(async (cat) => {
const results = await fetchFromBiliPgc(cat.season_type);
const mid = Math.ceil(results.length / 2);
return {
category: cat.category,
label: cat.label,
hot: results.slice(0, mid),
newest: results.slice(mid),
};
});
// 2. 并行抓取百度热搜数据(电影、电视剧)
// 百度只有热榜没有最新榜,全部放 hot
const baiduPromises = BAIDU_BOARDS.map(async (board) => {
const results = await fetchFromBaidu(board.tab);
return {
category: board.category,
label: board.label,
hot: results,
newest: [],
};
});
// 3. 并行抓取 TMDB 数据(欧美观影、剧集、冷门)
const token = getTmdbToken();
let tmdbResults: CategorizedRanking[] = [];
if (token) {
const tmdbPromises = TMDB_CATEGORIES.map(async (cat) => {
const [hotResults, newestResults] = await Promise.all([
tmdbFetch(cat.hotUrl, token),
tmdbFetch(cat.newestUrl, token),
]);
return {
category: cat.category,
label: cat.label,
hot: hotResults.map(tmdbResultToRanking),
newest: newestResults.map(tmdbResultToRanking),
};
});
tmdbResults = await Promise.all(tmdbPromises);
}
// 4. 本站热搜
const db = getDb();
const rows = db.prepare(
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
).all() as RankingItem[];
const newestRows = db.prepare(
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 20'
).all() as RankingItem[];
const hotsiteCategory: CategorizedRanking = {
category: 'hotsite',
label: '本站热搜',
hot: rows,
newest: newestRows,
};
// 5. 合并所有结果
const [biliResults, baiduResults] = await Promise.all([
Promise.all(biliPromises),
Promise.all(baiduPromises),
]);
const allCategories = [...biliResults, ...baiduResults, ...tmdbResults, hotsiteCategory];
// 按 CATEGORY_ORDER 排序
allCategories.sort((a, b) => (CATEGORY_ORDER[a.category] || 99) - (CATEGORY_ORDER[b.category] || 99));
return { fetchedAt, categories: allCategories };
}
/** 兼容旧接口,返回搜索脉搏数据 */
export async function getCategorizedRankings(): Promise<CategorizedResponse> {
if (isCacheValid()) {
return cache!.data;
}
const pulse = await getSearchPulse();
const fetchedAt = pulse.generatedAt;
try {
const data = await fetchRankings();
cache = { data, time: Date.now() };
return data;
} catch (err) {
console.error('[Rankings] Fetch error:', err);
if (cache) return cache.data;
const db = getDb();
const rows = db.prepare(
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
).all() as RankingItem[];
return {
fetchedAt: formatLocalDateTime(),
categories: [{
category: 'hotsite', label: '本站热搜',
hot: rows,
newest: [...rows].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 20),
}],
};
}
const categories: CategorizedRanking[] = [
{
category: 'hot',
label: '搜索热榜',
hot: pulse.hotList.map(item => ({
keyword: item.keyword,
searchCount: item.count,
updatedAt: item.updatedAt,
heatLevel: item.heatLevel,
isRising: item.isRising,
genreEmoji: item.genreEmoji,
genre: item.genre,
})),
newest: pulse.recentList.map(item => ({
keyword: item.keyword,
searchCount: item.count,
updatedAt: item.updatedAt,
heatLevel: item.heatLevel,
isRising: item.isRising,
genreEmoji: item.genreEmoji,
genre: item.genre,
})),
},
// 按分类展示
...pulse.categories.map(cat => ({
category: 'genre_' + cat.name,
label: cat.emoji + ' ' + cat.name,
hot: cat.keywords.map(item => ({
keyword: item.keyword,
searchCount: item.count,
updatedAt: item.updatedAt,
heatLevel: item.heatLevel,
isRising: item.isRising,
genreEmoji: item.genreEmoji,
genre: item.genre,
})),
newest: [],
})),
];
return { fetchedAt, categories };
}
export async function getRankings(): Promise<RankingItem[]> {
const db = getDb();
const rows = db.prepare(
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
).all() as RankingItem[];
return rows;
const pulse = await getSearchPulse();
return pulse.hotList.map(item => ({
keyword: item.keyword,
searchCount: item.count,
updatedAt: item.updatedAt,
}));
}
export async function getHotKeywords(): Promise<string[]> {
const db = getDb();
const rows = db.prepare(
'SELECT keyword FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
).all() as { keyword: string }[];
return rows.map(r => r.keyword);
}
const pulse = await getSearchPulse();
return pulse.hotList.map(item => item.keyword);
}

View File

@@ -0,0 +1,351 @@
// Native fetch available in Node 20+
import { getDb } from '../database/database';
import { getTimezone, formatLocalDateTime } from '../utils/time';
export interface RankingItem {
keyword: string;
searchCount: number;
updatedAt: string;
rating?: number;
}
export interface CategorizedRanking {
category: string;
label: string;
hot: RankingItem[];
newest: RankingItem[];
}
export interface CategorizedResponse {
fetchedAt: string;
categories: CategorizedRanking[];
}
// ===== Bilibili PGC 排行榜配置 =====
interface BiliPgcDef {
category: string;
label: string;
season_type: number; // 1=番剧, 2=电影, 3=纪录片, 4=国创, 5=电视剧, 7=综艺
}
const BILI_PGC_CATEGORIES: BiliPgcDef[] = [
// 国创:凡人修仙传、灵笼、斗破苍穹等官方国产动画
{ category: 'donghua', label: '国产动漫', season_type: 4 },
// 番剧:日漫等全球动画
{ category: 'global_anime', label: '热门动漫', season_type: 1 },
];
// ===== 百度热搜榜配置 =====
interface BaiduBoardDef {
category: string;
label: string;
tab: string; // movie=电影热搜, teleplay=电视剧热搜
}
const BAIDU_BOARDS: BaiduBoardDef[] = [
// 百度电影热搜:实时反映国内电影热度
{ category: 'movie', label: '国内电影', tab: 'movie' },
// 百度电视剧热搜:国内剧集热度
{ category: 'tv', label: '热门剧集', tab: 'teleplay' },
];
// ===== TMDB 分类配置(保留欧美和冷门内容)=====
interface TmdbCategoryDef {
category: string;
label: string;
hotUrl: string;
newestUrl: string;
}
const TMDB_CATEGORIES: TmdbCategoryDef[] = [
{
category: 'western_movie', label: '欧美电影',
hotUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10',
newestUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=release_date.desc&vote_count.gte=1',
},
{
category: 'western_tv', label: '欧美剧集',
hotUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10',
newestUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=first_air_date.desc&vote_count.gte=10',
},
{
category: 'niche', label: '冷门佳片',
hotUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=vote_average.desc&vote_count.gte=10&vote_count.lte=500',
newestUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=release_date.desc&vote_count.gte=1&vote_count.lte=500',
},
];
// ===== 显示顺序 =====
const CATEGORY_ORDER: Record<string, number> = {
donghua: 1,
movie: 2,
tv: 3,
global_anime: 4,
western_movie: 5,
western_tv: 6,
niche: 7,
hotsite: 8,
};
// ===== 12小时缓存 =====
let cache: { data: CategorizedResponse; time: number } | null = null;
const CACHE_TTL = 12 * 60 * 60 * 1000;
function isCacheValid(): boolean {
return cache !== null && (Date.now() - cache.time) < CACHE_TTL;
}
// ===== Bilibili PGC API =====
/**
* 抓取 Bilibili PGC 排行榜(番剧/国创)
*/
async function fetchFromBiliPgc(season_type: number): Promise<RankingItem[]> {
try {
const url = `https://api.bilibili.com/pgc/web/rank/list?season_type=${season_type}&day=7`;
const resp = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.bilibili.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
signal: AbortSignal.timeout(8000),
});
if (!resp.ok) {
console.error(`[BiliPGC] HTTP ${resp.status} for season_type=${season_type}`);
return [];
}
const json = await resp.json() as any;
if (json.code !== 0 || !json.result?.list) {
console.error(`[BiliPGC] API error code=${json.code} for season_type=${season_type}`);
return [];
}
return json.result.list.slice(0, 20).map((item: any) => {
const stat = item.stat || {};
const viewCount = stat.view || 0;
const followCount = stat.follow || 0;
const searchCount = viewCount > 0 ? viewCount : followCount;
let rating = 0;
if (item.rating) {
const m = String(item.rating).match(/([\d.]+)/);
if (m) rating = parseFloat(m[1]);
}
return {
keyword: item.title || '',
searchCount,
updatedAt: item.new_ep?.index_show || item.new_ep?.cover || '',
rating,
};
});
} catch (err) {
console.error(`[BiliPGC] Fetch error for season_type=${season_type}:`, (err as Error).message);
return [];
}
}
// ===== 百度热搜榜 API =====
/**
* 抓取百度热搜榜
* tab: movie=电影, teleplay=电视剧
*/
async function fetchFromBaidu(tab: string): Promise<RankingItem[]> {
try {
const url = `https://top.baidu.com/api/board?tab=${tab}`;
const resp = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://top.baidu.com/board',
},
signal: AbortSignal.timeout(8000),
});
if (!resp.ok) {
console.error(`[Baidu] HTTP ${resp.status} for tab=${tab}`);
return [];
}
const json = await resp.json() as any;
if (!json.success || !json.data?.cards) {
console.error(`[Baidu] API error for tab=${tab}`);
return [];
}
const results: RankingItem[] = [];
for (const card of json.data.cards) {
for (const item of (card.content || [])) {
results.push({
keyword: item.word || '',
// hotScore can be like "96438", parse as number
searchCount: parseInt(item.hotScore || '0', 10) || 0,
updatedAt: item.desc || '',
rating: 0,
});
}
}
return results.slice(0, 20);
} catch (err) {
console.error(`[Baidu] Fetch error for tab=${tab}:`, (err as Error).message);
return [];
}
}
// ===== TMDB =====
function getTmdbToken(): string {
const db = getDb();
return (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || '';
}
function tmdbResultToRanking(item: any): RankingItem {
const title = item.title || item.name || '';
const date = item.release_date || item.first_air_date || '';
const rating = item.vote_average ? Math.round(item.vote_average * 10) / 10 : 0;
return {
keyword: title,
searchCount: item.vote_count || 0,
updatedAt: date,
rating,
};
}
async function tmdbFetch(url: string, token: string): Promise<any[]> {
const fullUrl = `${url}${url.includes('?') ? '&' : '?'}language=zh-CN`;
try {
const resp = await fetch(fullUrl, {
headers: { 'Authorization': `Bearer ${token}` },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
console.error(`[TMDB] HTTP ${resp.status} for ${url}`);
return [];
}
const data = await resp.json() as any;
return (data.results || []).slice(0, 20);
} catch (err) {
console.error(`[TMDB] Fetch error for ${url}:`, err);
return [];
}
}
// ===== 主流程 =====
async function fetchRankings(): Promise<CategorizedResponse> {
const fetchedAt = formatLocalDateTime();
// 1. 并行抓取 Bilibili PGC 数据(国漫、番剧)
const biliPromises = BILI_PGC_CATEGORIES.map(async (cat) => {
const results = await fetchFromBiliPgc(cat.season_type);
const mid = Math.ceil(results.length / 2);
return {
category: cat.category,
label: cat.label,
hot: results.slice(0, mid),
newest: results.slice(mid),
};
});
// 2. 并行抓取百度热搜数据(电影、电视剧)
// 百度只有热榜没有最新榜,全部放 hot
const baiduPromises = BAIDU_BOARDS.map(async (board) => {
const results = await fetchFromBaidu(board.tab);
return {
category: board.category,
label: board.label,
hot: results,
newest: [],
};
});
// 3. 并行抓取 TMDB 数据(欧美观影、剧集、冷门)
const token = getTmdbToken();
let tmdbResults: CategorizedRanking[] = [];
if (token) {
const tmdbPromises = TMDB_CATEGORIES.map(async (cat) => {
const [hotResults, newestResults] = await Promise.all([
tmdbFetch(cat.hotUrl, token),
tmdbFetch(cat.newestUrl, token),
]);
return {
category: cat.category,
label: cat.label,
hot: hotResults.map(tmdbResultToRanking),
newest: newestResults.map(tmdbResultToRanking),
};
});
tmdbResults = await Promise.all(tmdbPromises);
}
// 4. 本站热搜
const db = getDb();
const rows = db.prepare(
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
).all() as RankingItem[];
const newestRows = db.prepare(
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 20'
).all() as RankingItem[];
const hotsiteCategory: CategorizedRanking = {
category: 'hotsite',
label: '本站热搜',
hot: rows,
newest: newestRows,
};
// 5. 合并所有结果
const [biliResults, baiduResults] = await Promise.all([
Promise.all(biliPromises),
Promise.all(baiduPromises),
]);
const allCategories = [...biliResults, ...baiduResults, ...tmdbResults, hotsiteCategory];
// 按 CATEGORY_ORDER 排序
allCategories.sort((a, b) => (CATEGORY_ORDER[a.category] || 99) - (CATEGORY_ORDER[b.category] || 99));
return { fetchedAt, categories: allCategories };
}
export async function getCategorizedRankings(): Promise<CategorizedResponse> {
if (isCacheValid()) {
return cache!.data;
}
try {
const data = await fetchRankings();
cache = { data, time: Date.now() };
return data;
} catch (err) {
console.error('[Rankings] Fetch error:', err);
if (cache) return cache.data;
const db = getDb();
const rows = db.prepare(
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
).all() as RankingItem[];
return {
fetchedAt: formatLocalDateTime(),
categories: [{
category: 'hotsite', label: '本站热搜',
hot: rows,
newest: [...rows].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 20),
}],
};
}
}
export async function getRankings(): Promise<RankingItem[]> {
const db = getDb();
const rows = db.prepare(
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
).all() as RankingItem[];
return rows;
}
export async function getHotKeywords(): Promise<string[]> {
const db = getDb();
const rows = db.prepare(
'SELECT keyword FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
).all() as { keyword: string }[];
return rows.map(r => r.keyword);
}

View File

@@ -1,3 +1,4 @@
import { QUARK_DRIVE_HOST, QUARK_PAN_HOST, EP as QE } from '../cloud/drivers/quark-api';
// Native fetch available in Node 20+
import config from '../config';
import { RedisClient } from '../middleware/cache';
@@ -190,15 +191,15 @@ export class LinkValidator {
return { url, status: 'unknown', cloudType: 'quark', checkedAt, message: '无法解析分享链接 token' };
}
const tokenUrl = 'https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc';
const tokenUrl = QUARK_DRIVE_HOST + QE.SHARE_PAGE_TOKEN + '?pr=ucpro&fr=pc';
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Content-Type': 'application/json',
'Accept': 'application/json',
'Origin': 'https://pan.quark.cn',
'Referer': 'https://pan.quark.cn/',
'Origin': QUARK_PAN_HOST,
'Referer': QUARK_PAN_HOST + '/',
},
body: JSON.stringify({ pwd_id: shareToken, passcode: '' }),
signal: AbortSignal.timeout(15000),

View File

@@ -0,0 +1,369 @@
// Native fetch available in Node 20+
import config from '../config';
import { RedisClient } from '../middleware/cache';
import { BoundedPool } from './bounded-pool';
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
import { AliyunDriver } from '../cloud/drivers/aliyun.driver';
import { getSystemConfig } from '../admin/system-config.service';
export type LinkStatus = 'valid' | 'invalid' | 'unknown';
export interface ValidationResult {
url: string;
status: LinkStatus;
cloudType: string;
checkedAt: string;
message?: string;
}
/**
* 从系统配置加载自定义关键词列表(一行一条)
*/
function loadCustomKeywords(configKey: string): string[] {
try {
const rules = getSystemConfig(configKey);
if (rules) {
return rules.split('\n').map(k => k.trim()).filter(k => k.length > 0);
}
} catch {
// ignore
}
return [];
}
export class LinkValidator {
private cache: RedisClient;
private pool: BoundedPool;
constructor(concurrency?: number) {
this.cache = new RedisClient();
this.pool = new BoundedPool(concurrency || config.validation.concurrency);
}
/**
* Validate a single share link — PanSou only, no local fallback.
*/
async validate(url: string, cloudType: string): Promise<ValidationResult> {
// Check cache first
const cacheKey = `link:valid:${cloudType}:${Buffer.from(url).toString('base64').slice(0, 64)}`;
try {
const cached = await this.cache.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
return parsed as ValidationResult;
}
} catch {
// ignore cache errors
}
// Try PanSou's /api/check/links
const pansouResult = await this.validateViaPansou(url, cloudType);
if (pansouResult) {
if (pansouResult.status === 'valid' || pansouResult.status === 'invalid') {
// Cache definitive result
const ttl = pansouResult.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid;
try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(pansouResult)); } catch {}
return pansouResult;
}
// PanSou returned locked/unsupported/uncertain → return unknown, no local fallback
return pansouResult;
}
// PanSou unreachable → return unknown
return { url, status: 'unknown' as LinkStatus, cloudType, checkedAt: new Date().toISOString(), message: '盘搜不可达' };
}
/**
* Full validation with local fallback when PanSou can't determine.
*/
async validateWithLocalFallback(url: string, cloudType: string): Promise<ValidationResult> {
// Check cache first
const cacheKey = `link:valid:${cloudType}:${Buffer.from(url).toString('base64').slice(0, 64)}`;
try {
const cached = await this.cache.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
return parsed as ValidationResult;
}
} catch {
// ignore cache errors
}
// Try PanSou
const pansouResult = await this.validateViaPansou(url, cloudType);
if (pansouResult) {
if (pansouResult.status === 'valid' || pansouResult.status === 'invalid') {
const ttl = pansouResult.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid;
try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(pansouResult)); } catch {}
return pansouResult;
}
// PanSou uncertain → fall through to local validation
}
// Fall back to own validation
let result: ValidationResult;
switch (cloudType) {
case 'quark':
result = await this.validateQuark(url);
break;
case 'baidu':
result = await this.validateBaidu(url);
break;
case 'aliyun':
result = await this.validateAliyun(url);
break;
default:
result = await this.validateByHtml(url, cloudType);
}
const ttl = result.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid;
try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(result)); } catch {}
return result;
}
/**
* Try PanSou's /api/check/links for validation.
* Returns null if PanSou is unreachable.
*
* Judgment order:
* 1. summary "链接有效" → valid (PanSou's own OK signal)
* 2. summary 含自定义确认关键词 → valid (from DB link_valid_keywords)
* 3. summary 含自定义失效关键词 → invalid (from DB link_invalid_keywords)
* 4. 其他 → unknown
*/
private async validateViaPansou(url: string, cloudType: string): Promise<ValidationResult | null> {
const checkedAt = new Date().toISOString();
try {
const pansouApiUrl = `${config.pansouUrl}/api/check/links`;
const response = await fetch(pansouApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{ disk_type: cloudType, url }],
}),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) return null;
const data = await response.json() as any;
const pansouResult = data.results?.[0];
if (!pansouResult) return null;
const summary = pansouResult.summary || '';
// 1. PanSou 明确返回"链接有效"
if (summary.includes('链接有效')) {
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. 其余全部返回 valid无失效关键词命中则有效
return { url, status: 'valid', cloudType, checkedAt, message: summary || '盘搜验证通过' };
} catch {
return null;
}
}
/**
* Validate a Quark share link using the public share token API.
*/
private async validateQuark(url: string): Promise<ValidationResult> {
const checkedAt = new Date().toISOString();
try {
const cleanUrl = url.split('#')[0];
const urlObj = new URL(cleanUrl);
const pathParts = urlObj.pathname.split('/');
const shareToken = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
if (!shareToken) {
return { url, status: 'unknown', cloudType: 'quark', checkedAt, message: '无法解析分享链接 token' };
}
const tokenUrl = 'https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc';
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Content-Type': 'application/json',
'Accept': 'application/json',
'Origin': 'https://pan.quark.cn',
'Referer': 'https://pan.quark.cn/',
},
body: JSON.stringify({ pwd_id: shareToken, passcode: '' }),
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
const msg = response.status === 403 ? '分享已过期或需要密码' : `HTTP ${response.status}`;
return { url, status: 'invalid', cloudType: 'quark', checkedAt, message: msg };
}
const data = await response.json() as any;
if (data.status === 200 && data.data?.stoken) {
const title = data.data?.title || '';
const author = data.data?.author?.nick_name || '';
const expiredAt = data.data?.expired_at || 0;
const expireDate = expiredAt > 0 ? new Date(expiredAt).toISOString().slice(0, 10) : '';
return {
url,
status: 'valid',
cloudType: 'quark',
checkedAt,
message: expireDate ? `有效链接,过期时间: ${expireDate}` : '有效链接',
};
}
// API 返回了 200 但无 stoken — 可能是临时异常,保守判 unknown
return { url, status: 'unknown', cloudType: 'quark', checkedAt, message: 'API 返回异常(无 stoken不做失效判定' };
} catch (err: any) {
return {
url,
status: 'unknown',
cloudType: 'quark',
checkedAt,
message: `校验异常: ${err.message?.slice(0, 50) || '未知错误'}`,
};
}
}
private async validateBaidu(url: string): Promise<ValidationResult> {
const checkedAt = new Date().toISOString();
try {
const driver = new BaiduDriver();
const result = await driver.validateShareLink(url);
return {
url,
status: result.valid ? 'valid' : 'invalid',
cloudType: 'baidu',
checkedAt,
message: result.message,
};
} catch (err: any) {
return {
url,
status: 'unknown',
cloudType: 'baidu',
checkedAt,
message: `校验失败: ${err.message || err}`,
};
}
}
private async validateAliyun(url: string): Promise<ValidationResult> {
const checkedAt = new Date().toISOString();
try {
const driver = new AliyunDriver();
const result = await driver.validateShareLink(url);
return {
url,
status: result.valid ? 'valid' : 'invalid',
cloudType: 'aliyun',
checkedAt,
message: result.message,
};
} catch (err: any) {
return {
url,
status: 'unknown',
cloudType: 'aliyun',
checkedAt,
message: `校验失败: ${err.message || err}`,
};
}
}
/**
* Fallback: validate by fetching the share page as HTML and checking for
* custom failure keywords from DB config. Used for providers without a
* dedicated API (115, tianyi, 123pan, etc.).
*/
private async validateByHtml(url: string, cloudType: string): Promise<ValidationResult> {
let status: LinkStatus = 'valid';
const checkedAt = new Date().toISOString();
let message = '';
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.validation.timeout);
const response = await fetch(url, {
signal: controller.signal as any,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
},
redirect: 'follow',
});
clearTimeout(timeoutId);
const text = await response.text();
const keywords = loadCustomKeywords('link_invalid_keywords');
const isHttpError = response.status >= 400;
if (isHttpError) {
status = 'invalid';
message = `HTTP ${response.status} ${response.statusText}`;
} else {
const matched = keywords.find(kw => text.includes(kw));
if (matched) {
status = 'invalid';
message = `页面包含自定义失效关键词: "${matched}"`;
} else {
message = 'HTML 页面可访问,未检测到失效关键词';
}
}
} catch (err: any) {
// On timeout or network error, conservatively mark as valid
status = 'valid';
message = `网络校验超时,保守标记为有效`;
}
return { url, status, cloudType, checkedAt, message };
}
/**
* Batch validate multiple links with bounded concurrency.
*/
async validateBatch(urls: Array<{ url: string; cloudType: string }>): Promise<ValidationResult[]> {
const tasks = urls.map(item => () => this.validate(item.url, item.cloudType));
const results: ValidationResult[] = [];
for (const task of tasks) {
try {
const result = await this.pool.run(task);
results.push(result);
} catch (err) {
results.push({
url: '',
status: 'unknown',
cloudType: '',
checkedAt: new Date().toISOString(),
message: '校验执行异常',
});
}
}
return results;
}
async validateBatchWithPool(urls: Array<{ url: string; cloudType: string }>): Promise<ValidationResult[]> {
return this.validateBatch(urls);
}
}