release: v0.4.0
This commit is contained in:
@@ -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),
|
||||
});
|
||||
|
||||
401
source_clean/src/cloud/credential.service.ts.bak
Normal file
401
source_clean/src/cloud/credential.service.ts.bak
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
26
source_clean/src/cloud/drivers/aliyun-api.ts
Normal file
26
source_clean/src/cloud/drivers/aliyun-api.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
57
source_clean/src/cloud/drivers/baidu-api.ts
Normal file
57
source_clean/src/cloud/drivers/baidu-api.ts
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
1190
source_clean/src/cloud/drivers/baidu.driver.ts.bak
Normal file
1190
source_clean/src/cloud/drivers/baidu.driver.ts.bak
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
62
source_clean/src/cloud/drivers/quark-auth.ts.bak
Normal file
62
source_clean/src/cloud/drivers/quark-auth.ts.bak
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
356
source_clean/src/cloud/drivers/quark-share.ts.bak
Normal file
356
source_clean/src/cloud/drivers/quark-share.ts.bak
Normal 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' };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
330
source_clean/src/cloud/drivers/quark-storage.ts.bak
Normal file
330
source_clean/src/cloud/drivers/quark-storage.ts.bak
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
402
source_clean/src/cloud/qr-login.service.ts.bak
Executable file
402
source_clean/src/cloud/qr-login.service.ts.bak
Executable 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);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
325
source_clean/src/content/content.service.ts.bak
Executable file
325
source_clean/src/content/content.service.ts.bak
Executable 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 [];
|
||||
}
|
||||
}
|
||||
59
source_clean/src/content/tmdb-api.ts
Normal file
59
source_clean/src/content/tmdb-api.ts
Normal 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';
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
867
source_clean/src/routes/admin.routes.ts.bak
Normal file
867
source_clean/src/routes/admin.routes.ts.bak
Normal 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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
351
source_clean/src/search/rankings.service.ts.bak
Executable file
351
source_clean/src/search/rankings.service.ts.bak
Executable 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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
369
source_clean/src/validation/link-validator.service.ts.bak
Executable file
369
source_clean/src/validation/link-validator.service.ts.bak
Executable 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user