增加网盘有效性检测

This commit is contained in:
mtvpls
2026-04-27 21:25:31 +08:00
parent dba22b98b3
commit 49861c24ec
19 changed files with 1982 additions and 1 deletions
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { cancelNetdiskCheckTask } from '@/lib/netdisk-check-task';
import { requireFeaturePermission } from '@/lib/permissions';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
const authResult = await requireFeaturePermission(
request,
'netdisk_search',
'无权限使用网盘有效性检测'
);
if (authResult instanceof NextResponse) return authResult;
const body = await request.json();
const taskId = String(body?.taskId || '');
if (!taskId) {
return NextResponse.json({ error: '缺少任务ID' }, { status: 400 });
}
const task = cancelNetdiskCheckTask(taskId);
if (!task) {
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
}
return NextResponse.json({ task });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : '取消检测任务失败' },
{ status: 500 }
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireFeaturePermission } from '@/lib/permissions';
import {
assertNetdiskCheckPlatform,
getNetdiskCheckCooldownRemainingMs,
startNetdiskCheckTask,
} from '@/lib/netdisk-check-task';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
const authResult = await requireFeaturePermission(
request,
'netdisk_search',
'无权限使用网盘有效性检测'
);
if (authResult instanceof NextResponse) return authResult;
const body = await request.json();
const platform = assertNetdiskCheckPlatform(String(body?.platform || ''));
const links = Array.isArray(body?.links)
? body.links.map((item: unknown) => String(item || ''))
: [];
const task = startNetdiskCheckTask({ platform, links });
return NextResponse.json({
taskId: task.id,
task,
cooldownRemainingMs: getNetdiskCheckCooldownRemainingMs(),
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : '启动检测任务失败' },
{ status: 400 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { getNetdiskCheckCooldownRemainingMs, getNetdiskCheckTask } from '@/lib/netdisk-check-task';
import { requireFeaturePermission } from '@/lib/permissions';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const authResult = await requireFeaturePermission(
request,
'netdisk_search',
'无权限使用网盘有效性检测'
);
if (authResult instanceof NextResponse) return authResult;
const taskId = request.nextUrl.searchParams.get('id') || '';
if (!taskId) {
return NextResponse.json({ error: '缺少任务ID' }, { status: 400 });
}
const task = getNetdiskCheckTask(taskId);
if (!task) {
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
}
return NextResponse.json({
task,
cooldownRemainingMs: getNetdiskCheckCooldownRemainingMs(),
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : '获取检测任务失败' },
{ status: 500 }
);
}
}
+273 -1
View File
@@ -49,6 +49,73 @@ const CLOUD_TYPE_COLORS: Record<string, string> = {
others: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200',
};
const CHECKABLE_CLOUD_TYPES = new Set([
'115',
'aliyun',
'baidu',
'mobile',
'quark',
'tianyi',
'uc',
'xunlei',
'123',
]);
const CLOUD_TYPE_TO_CHECK_PLATFORM: Record<string, string> = {
'115': '115',
aliyun: 'aliyun',
baidu: 'baidu',
mobile: 'cmcc',
quark: 'quark',
tianyi: 'tianyi',
uc: 'uc',
xunlei: 'xunlei',
'123': 'pan123',
};
type CheckItemStatus = 'pending' | 'checking' | 'valid' | 'invalid' | 'unknown' | 'rate_limited';
interface NetdiskCheckTaskPayload {
id: string;
platform: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
progress: {
total: number;
done: number;
valid: number;
invalid: number;
unknown: number;
rateLimited: number;
currentBatch: number;
totalBatches: number;
};
results: Record<string, { status: CheckItemStatus; reason?: string; fromCache?: boolean }>;
error?: string;
}
interface StoredCloudCheckState {
taskId: string;
task: NetdiskCheckTaskPayload;
}
const CHECK_STATUS_STYLE: Record<CheckItemStatus, string> = {
pending: 'bg-gray-100 text-gray-700 dark:bg-gray-700/50 dark:text-gray-200',
checking: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200',
valid: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200',
invalid: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200',
unknown: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-200',
rate_limited: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-200',
};
const CHECK_STATUS_TEXT: Record<CheckItemStatus, string> = {
pending: '未检测',
checking: '检测中',
valid: '有效',
invalid: '失效',
unknown: '未知',
rate_limited: '受限',
};
export default function PansouSearch({
keyword,
triggerSearch,
@@ -63,6 +130,80 @@ export default function PansouSearch({
const [transferingUrl, setTransferingUrl] = useState<string | null>(null);
const [playingUrl, setPlayingUrl] = useState<string | null>(null);
const [toast, setToast] = useState<ToastProps | null>(null);
const [cooldownRemainingMs, setCooldownRemainingMs] = useState(0);
const [checkStatesByType, setCheckStatesByType] = useState<Record<string, StoredCloudCheckState>>({});
useEffect(() => {
setCooldownRemainingMs(0);
setCheckStatesByType({});
}, [keyword, triggerSearch]);
useEffect(() => {
const runningEntries = Object.entries(checkStatesByType).filter(
([, state]) => state.task.status === 'running'
);
if (runningEntries.length === 0) return;
let cancelled = false;
const timer = setInterval(async () => {
const updates = await Promise.all(
runningEntries.map(async ([cloudType, state]) => {
try {
const response = await fetch(`/api/netdisk/check/task?id=${encodeURIComponent(state.taskId)}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '获取检测进度失败');
}
return {
cloudType,
taskId: state.taskId,
task: data.task as NetdiskCheckTaskPayload,
cooldownRemainingMs: Number(data.cooldownRemainingMs || 0),
};
} catch (error) {
return {
cloudType,
taskId: state.taskId,
task: {
...state.task,
status: 'failed',
error: error instanceof Error ? error.message : '获取检测进度失败',
} as NetdiskCheckTaskPayload,
cooldownRemainingMs: 0,
};
}
})
);
if (cancelled) return;
setCheckStatesByType((prev) => {
const next = { ...prev };
updates.forEach((update) => {
next[update.cloudType] = {
taskId: update.taskId,
task: update.task,
};
});
return next;
});
setCooldownRemainingMs(Math.max(0, ...updates.map((item) => item.cooldownRemainingMs)));
updates.forEach((update) => {
if (update.task.status === 'failed' && update.task.error) {
setToast({
message: `${CLOUD_TYPE_NAMES[update.cloudType] || update.cloudType}: ${update.task.error}`,
type: 'error',
onClose: () => setToast(null),
});
}
});
}, 2000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [checkStatesByType]);
// 提取搜索函数,以便在重试时调用
const searchPansou = useCallback(async () => {
@@ -206,6 +347,83 @@ export default function PansouSearch({
}
};
const handleStartCheck = async (cloudType: string, links: PansouLink[]) => {
try {
const platform = CLOUD_TYPE_TO_CHECK_PLATFORM[cloudType];
if (!platform) {
throw new Error('当前网盘类型暂不支持有效性检测');
}
const response = await fetch('/api/netdisk/check/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
platform,
links: links.map((item) => item.url),
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '启动检测失败');
}
setCheckStatesByType((prev) => ({
...prev,
[cloudType]: {
taskId: data.taskId,
task: data.task,
},
}));
setCooldownRemainingMs(Number(data.cooldownRemainingMs || 0));
} catch (err: any) {
setToast({
message: err?.message || '启动检测失败',
type: 'error',
onClose: () => setToast(null),
});
}
};
const handleCancelCheck = async (cloudType: string) => {
const state = checkStatesByType[cloudType];
if (!state?.taskId) return;
try {
const response = await fetch('/api/netdisk/check/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ taskId: state.taskId }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '停止检测失败');
}
setCheckStatesByType((prev) => ({
...prev,
[cloudType]: {
taskId: state.taskId,
task: data.task,
},
}));
} catch (err: any) {
setToast({
message: err?.message || '停止检测失败',
type: 'error',
onClose: () => setToast(null),
});
}
};
const getCloudCheckState = (cloudType: string) => {
return checkStatesByType[cloudType]?.task || null;
};
const getCheckResultForUrl = (cloudType: string, url: string) => {
const task = getCloudCheckState(cloudType);
return task?.results?.[url] || null;
};
const renderBody = () => {
if (loading) {
return (
@@ -309,17 +527,54 @@ export default function PansouSearch({
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
const checkable = CHECKABLE_CLOUD_TYPES.has(cloudType);
const cloudCheckTask = getCloudCheckState(cloudType);
const isCheckingThisType = cloudCheckTask?.status === 'running';
const groupProgress = cloudCheckTask?.progress || null;
return (
<div key={cloudType} className='space-y-3'>
{/* 网盘类型标题 */}
<div className='flex items-center gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${typeColor}`}>
{typeName}
</span>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{links.length}
</span>
{groupProgress && (
<span className='text-xs text-gray-500 dark:text-gray-400'>
{groupProgress.done}/{groupProgress.total} · {groupProgress.valid} · {groupProgress.invalid} · {groupProgress.unknown + groupProgress.rateLimited}
</span>
)}
{checkable && (
<>
<button
onClick={() => handleStartCheck(cloudType, links)}
disabled={isCheckingThisType}
className='px-3 py-1 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs transition-colors disabled:opacity-60'
>
{cloudCheckTask
? cloudCheckTask.status === 'running'
? '检测中...'
: '重新检测'
: '有效性检测'}
</button>
{isCheckingThisType && (
<button
onClick={() => handleCancelCheck(cloudType)}
className='px-3 py-1 rounded-md bg-gray-600 hover:bg-gray-700 text-white text-xs transition-colors'
>
</button>
)}
</>
)}
{cooldownRemainingMs > 0 && (
<span className='text-xs text-orange-600 dark:text-orange-400'>
{Math.ceil(cooldownRemainingMs / 1000)}s
</span>
)}
</div>
{/* 链接列表 */}
@@ -351,6 +606,18 @@ export default function PansouSearch({
{/* 操作按钮 */}
<div className='flex items-center gap-1 flex-shrink-0'>
{(() => {
const checkResult = getCheckResultForUrl(cloudType, link.url);
if (!checkResult) return null;
return (
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${CHECK_STATUS_STYLE[checkResult.status]}`}
title={checkResult.reason || CHECK_STATUS_TEXT[checkResult.status]}
>
{CHECK_STATUS_TEXT[checkResult.status]}
</span>
);
})()}
{(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi' || cloudType === '123' || cloudType === 'uc') && (
<>
<button
@@ -405,6 +672,11 @@ export default function PansouSearch({
{link.datetime && (
<span>{new Date(link.datetime).toLocaleDateString()}</span>
)}
{(() => {
const checkResult = getCheckResultForUrl(cloudType, link.url);
if (!checkResult?.reason) return null;
return <span className='truncate'>: {checkResult.reason}</span>;
})()}
</div>
{/* 图片预览 */}
+239
View File
@@ -0,0 +1,239 @@
import { nanoid } from 'nanoid';
import { checkNetdiskLink } from '@/lib/pancheck';
import type {
NetdiskCheckPlatform,
NetdiskCheckResult,
NetdiskCheckStartInput,
NetdiskCheckTask,
NetdiskCheckTaskResultItem,
} from '@/lib/pancheck/types';
const NETDISK_CHECK_RULE = {
batchSize: 3,
batchIntervalMs: 2000,
requestTimeoutMs: 12000,
maxTaskLinks: 60,
cooldownMs: 60000,
taskRetentionMs: 60 * 60 * 1000,
maxActiveTasks: 5,
} as const;
const tasks = new Map<string, NetdiskCheckTask>();
const activeTaskIds = new Set<string>();
let cooldownUntil = 0;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number) {
return Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error(`执行超时(${timeoutMs}ms)`)), timeoutMs);
}),
]);
}
function cleanupOldTasks() {
const now = Date.now();
for (const [id, task] of Array.from(tasks.entries())) {
if (now - task.updatedAt > NETDISK_CHECK_RULE.taskRetentionMs) {
tasks.delete(id);
}
}
}
function cloneTask(task: NetdiskCheckTask): NetdiskCheckTask {
return JSON.parse(JSON.stringify(task)) as NetdiskCheckTask;
}
function createTaskResultItem(status: NetdiskCheckTaskResultItem['status']): NetdiskCheckTaskResultItem {
return { status };
}
function countBatchSize(total: number) {
return Math.max(1, Math.ceil(total / NETDISK_CHECK_RULE.batchSize));
}
function updateTaskAfterResult(task: NetdiskCheckTask, url: string, result: NetdiskCheckResult) {
task.results[url] = {
status: result.status,
reason: result.reason,
fromCache: result.fromCache,
checkedAt: result.checkedAt,
durationMs: result.durationMs,
};
task.progress.done += 1;
if (result.status === 'valid') task.progress.valid += 1;
else if (result.status === 'invalid') task.progress.invalid += 1;
else if (result.status === 'rate_limited') task.progress.rateLimited += 1;
else task.progress.unknown += 1;
task.updatedAt = Date.now();
}
async function runTask(taskId: string, links: string[]) {
const task = tasks.get(taskId);
if (!task) return;
try {
const pendingLinks = links.slice(0, NETDISK_CHECK_RULE.maxTaskLinks);
let batchIndex = 0;
while (pendingLinks.length > 0) {
if (task.shouldStop) {
task.status = 'cancelled';
task.updatedAt = Date.now();
return;
}
if (Date.now() < cooldownUntil) {
task.status = 'failed';
task.error = '检测功能冷却中,请稍后再试';
task.updatedAt = Date.now();
return;
}
batchIndex += 1;
task.progress.currentBatch = batchIndex;
task.updatedAt = Date.now();
const batch = pendingLinks.splice(0, NETDISK_CHECK_RULE.batchSize);
batch.forEach((url) => {
task.results[url] = createTaskResultItem('checking');
});
const batchResults = await Promise.all(
batch.map(async (url) => {
try {
return await withTimeout(checkNetdiskLink(task.platform, url), NETDISK_CHECK_RULE.requestTimeoutMs);
} catch (error) {
const reason = error instanceof Error ? error.message : '检测失败';
return {
platform: task.platform,
url,
normalizedUrl: url,
status: 'unknown',
valid: null,
reason,
checkedAt: Date.now(),
durationMs: NETDISK_CHECK_RULE.requestTimeoutMs,
} satisfies NetdiskCheckResult;
}
})
);
let hasRateLimited = false;
batchResults.forEach((result) => {
updateTaskAfterResult(task, result.url, result);
if (result.status === 'rate_limited') {
hasRateLimited = true;
}
});
if (hasRateLimited) {
cooldownUntil = Date.now() + NETDISK_CHECK_RULE.cooldownMs;
task.status = 'failed';
task.error = '触发网盘平台限流,请稍后重试';
task.updatedAt = Date.now();
return;
}
if (pendingLinks.length > 0) {
await sleep(NETDISK_CHECK_RULE.batchIntervalMs);
}
}
task.status = 'completed';
task.updatedAt = Date.now();
} catch (error) {
task.status = 'failed';
task.error = error instanceof Error ? error.message : '检测任务失败';
task.updatedAt = Date.now();
} finally {
activeTaskIds.delete(taskId);
}
}
export function startNetdiskCheckTask(input: NetdiskCheckStartInput) {
cleanupOldTasks();
if (Date.now() < cooldownUntil) {
throw new Error('检测功能冷却中,请稍后再试');
}
for (const taskId of Array.from(activeTaskIds)) {
const activeTask = tasks.get(taskId);
if (!activeTask || activeTask.status !== 'running') {
activeTaskIds.delete(taskId);
}
}
if (activeTaskIds.size >= NETDISK_CHECK_RULE.maxActiveTasks) {
throw new Error(`当前最多只允许 ${NETDISK_CHECK_RULE.maxActiveTasks} 个检测任务同时执行,请稍后再试`);
}
const uniqueLinks = Array.from(
new Set(
input.links
.map((item) => item.trim())
.filter(Boolean)
)
).slice(0, NETDISK_CHECK_RULE.maxTaskLinks);
if (uniqueLinks.length === 0) {
throw new Error('没有可检测的链接');
}
const taskId = `netdisk_check_${nanoid(10)}`;
const task: NetdiskCheckTask = {
id: taskId,
platform: input.platform,
status: 'running',
progress: {
total: uniqueLinks.length,
done: 0,
valid: 0,
invalid: 0,
unknown: 0,
rateLimited: 0,
currentBatch: 0,
totalBatches: countBatchSize(uniqueLinks.length),
},
results: Object.fromEntries(uniqueLinks.map((url) => [url, createTaskResultItem('pending')])),
createdAt: Date.now(),
updatedAt: Date.now(),
};
tasks.set(taskId, task);
activeTaskIds.add(taskId);
void runTask(taskId, uniqueLinks);
return cloneTask(task);
}
export function getNetdiskCheckTask(taskId: string) {
cleanupOldTasks();
const task = tasks.get(taskId);
return task ? cloneTask(task) : null;
}
export function cancelNetdiskCheckTask(taskId: string) {
const task = tasks.get(taskId);
if (!task) return null;
task.shouldStop = true;
task.updatedAt = Date.now();
if (task.status !== 'running') {
return cloneTask(task);
}
return cloneTask(task);
}
export function getNetdiskCheckCooldownRemainingMs() {
return Math.max(0, cooldownUntil - Date.now());
}
export function assertNetdiskCheckPlatform(value: string): NetdiskCheckPlatform {
const platform = value as NetdiskCheckPlatform;
const allowed: NetdiskCheckPlatform[] = ['115', 'aliyun', 'baidu', 'cmcc', 'pan123', 'quark', 'tianyi', 'uc', 'xunlei'];
if (!allowed.includes(platform)) {
throw new Error('不支持的网盘平台');
}
return platform;
}
+74
View File
@@ -0,0 +1,74 @@
import type { NetdiskCheckPlatform, NetdiskCheckResult } from './types';
const CACHE_TTL_MS = {
valid: 30 * 60 * 1000,
invalid: 6 * 60 * 60 * 1000,
unknown: 3 * 60 * 1000,
rate_limited: 2 * 60 * 1000,
} as const;
const MAX_CACHE_SIZE = 3000;
const CACHE = new Map<string, { expiresAt: number; result: NetdiskCheckResult }>();
const INFLIGHT = new Map<string, Promise<NetdiskCheckResult>>();
function pruneExpired() {
const now = Date.now();
for (const [key, value] of Array.from(CACHE.entries())) {
if (value.expiresAt <= now) CACHE.delete(key);
}
}
function evictIfNeeded() {
pruneExpired();
if (CACHE.size <= MAX_CACHE_SIZE) return;
const overflow = CACHE.size - MAX_CACHE_SIZE;
const keys = CACHE.keys();
for (let i = 0; i < overflow; i += 1) {
const key = keys.next().value;
if (!key) break;
CACHE.delete(key);
}
}
export function buildNetdiskCheckCacheKey(platform: NetdiskCheckPlatform, normalizedUrl: string) {
return `${platform}:${normalizedUrl}`;
}
export function getCachedNetdiskCheckResult(cacheKey: string): NetdiskCheckResult | null {
pruneExpired();
const cached = CACHE.get(cacheKey);
if (!cached) return null;
if (cached.expiresAt <= Date.now()) {
CACHE.delete(cacheKey);
return null;
}
return { ...cached.result, fromCache: true };
}
export function setCachedNetdiskCheckResult(cacheKey: string, result: NetdiskCheckResult) {
const ttl =
result.status === 'valid'
? CACHE_TTL_MS.valid
: result.status === 'invalid'
? CACHE_TTL_MS.invalid
: result.status === 'rate_limited'
? CACHE_TTL_MS.rate_limited
: CACHE_TTL_MS.unknown;
CACHE.set(cacheKey, {
expiresAt: Date.now() + ttl,
result: { ...result, fromCache: false },
});
evictIfNeeded();
}
export function getNetdiskCheckInflight(cacheKey: string) {
return INFLIGHT.get(cacheKey) || null;
}
export function setNetdiskCheckInflight(cacheKey: string, promise: Promise<NetdiskCheckResult>) {
INFLIGHT.set(cacheKey, promise);
}
export function clearNetdiskCheckInflight(cacheKey: string) {
INFLIGHT.delete(cacheKey);
}
+185
View File
@@ -0,0 +1,185 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
buildNetdiskCheckCacheKey,
clearNetdiskCheckInflight,
getCachedNetdiskCheckResult,
getNetdiskCheckInflight,
setCachedNetdiskCheckResult,
setNetdiskCheckInflight,
} from './cache';
import type { NetdiskCheckPlatform, NetdiskCheckResult } from './types';
// eslint-disable-next-line no-eval
const nodeRequire = eval('require') as NodeRequire;
const RATE_LIMIT_REASON_PATTERNS = [/频率限制/i, /请求过快/i, /rate.?limit/i, /too many/i, /风控/i];
type RawCheckerResult = {
valid?: boolean;
reason?: string;
isRateLimited?: boolean;
};
type CheckerModule = {
[key: string]: (url: string) => Promise<RawCheckerResult>;
};
const PLATFORM_PATTERNS: Record<NetdiskCheckPlatform, RegExp[]> = {
'115': [/115(?:cdn)?\.com\/s\//i, /anxia\.com\/s\//i],
quark: [/pan\.quark\.cn\/s\//i, /pan\.qoark\.cn\/s\//i],
aliyun: [/aliyundrive\.com\/s\//i, /alipan\.com\/s\//i],
baidu: [/pan\.baidu\.com\/s\//i, /pan\.baidu\.com\/share\//i],
tianyi: [/cloud\.189\.cn\/web\/share/i, /cloud\.189\.cn\/t\//i, /h5\.cloud\.189\.cn\/share/i],
pan123: [/123(?:pan|684|685|912|592|865)\.(?:com|cn)\/s\//i],
uc: [/drive\.uc\.cn\/s\//i, /yun\.uc\.cn\/s\//i],
xunlei: [/pan\.xunlei\.com\/s\//i],
cmcc: [/yun\.139\.com\/shareweb/i, /caiyun\.139\.com\/m\/i/i],
};
const CHECKER_EXPORTS: Record<NetdiskCheckPlatform, { modulePath: string; exportName: string }> = {
'115': { modulePath: '@/lib/pancheck/vendor/checkers/pan115.js', exportName: 'check115' },
aliyun: { modulePath: '@/lib/pancheck/vendor/checkers/aliyun.js', exportName: 'checkAliyun' },
baidu: { modulePath: '@/lib/pancheck/vendor/checkers/baidu.js', exportName: 'checkBaidu' },
cmcc: { modulePath: '@/lib/pancheck/vendor/checkers/cmcc.js', exportName: 'checkCMCC' },
pan123: { modulePath: '@/lib/pancheck/vendor/checkers/pan123.js', exportName: 'check123' },
quark: { modulePath: '@/lib/pancheck/vendor/checkers/quark.js', exportName: 'checkQuark' },
tianyi: { modulePath: '@/lib/pancheck/vendor/checkers/tianyi.js', exportName: 'checkTianyi' },
uc: { modulePath: '@/lib/pancheck/vendor/checkers/uc.js', exportName: 'checkUC' },
xunlei: { modulePath: '@/lib/pancheck/vendor/checkers/xunlei.js', exportName: 'checkXunlei' },
};
const checkerFnCache = new Map<NetdiskCheckPlatform, (url: string) => Promise<RawCheckerResult>>();
function resolveModulePath(modulePath: string) {
return modulePath.replace(/^@\//, `${process.cwd()}/src/`);
}
function getChecker(platform: NetdiskCheckPlatform) {
const cached = checkerFnCache.get(platform);
if (cached) return cached;
const config = CHECKER_EXPORTS[platform];
const mod = nodeRequire(resolveModulePath(config.modulePath)) as CheckerModule;
const fn = mod[config.exportName];
if (typeof fn !== 'function') {
throw new Error(`未找到 ${platform} 检测器`);
}
checkerFnCache.set(platform, fn);
return fn;
}
export function normalizeNetdiskCheckUrl(url: string) {
return url.trim().replace(/\s+/g, '').replace(/\/+$/, '');
}
export function detectNetdiskCheckPlatform(url: string): NetdiskCheckPlatform | null {
const normalized = normalizeNetdiskCheckUrl(url);
for (const [platform, patterns] of Object.entries(PLATFORM_PATTERNS) as Array<[NetdiskCheckPlatform, RegExp[]]>) {
if (patterns.some((pattern) => pattern.test(normalized))) return platform;
}
return null;
}
function isRateLimitedResult(result: RawCheckerResult) {
if (result.isRateLimited) return true;
const reason = String(result.reason || '');
return RATE_LIMIT_REASON_PATTERNS.some((pattern) => pattern.test(reason));
}
function toFinalResult(
platform: NetdiskCheckPlatform,
url: string,
normalizedUrl: string,
raw: RawCheckerResult,
durationMs: number
): NetdiskCheckResult {
const checkedAt = Date.now();
const rateLimited = isRateLimitedResult(raw);
if (rateLimited) {
return {
platform,
url,
normalizedUrl,
status: 'rate_limited',
valid: null,
reason: raw.reason || '检测受限',
checkedAt,
durationMs,
isRateLimited: true,
};
}
if (raw.valid === true) {
return {
platform,
url,
normalizedUrl,
status: 'valid',
valid: true,
reason: raw.reason || '',
checkedAt,
durationMs,
};
}
if (raw.valid === false) {
return {
platform,
url,
normalizedUrl,
status: 'invalid',
valid: false,
reason: raw.reason || '链接无效',
checkedAt,
durationMs,
};
}
return {
platform,
url,
normalizedUrl,
status: 'unknown',
valid: null,
reason: raw.reason || '无法确认链接状态',
checkedAt,
durationMs,
};
}
export async function checkNetdiskLink(platform: NetdiskCheckPlatform, url: string): Promise<NetdiskCheckResult> {
const normalizedUrl = normalizeNetdiskCheckUrl(url);
const cacheKey = buildNetdiskCheckCacheKey(platform, normalizedUrl);
const cached = getCachedNetdiskCheckResult(cacheKey);
if (cached) return cached;
const inflight = getNetdiskCheckInflight(cacheKey);
if (inflight) return inflight;
const runner = (async () => {
const startedAt = Date.now();
try {
const checker = getChecker(platform);
const raw = await checker(normalizedUrl);
const finalResult = toFinalResult(platform, url, normalizedUrl, raw, Date.now() - startedAt);
setCachedNetdiskCheckResult(cacheKey, finalResult);
return finalResult;
} catch (error) {
const message = error instanceof Error ? error.message : '检测失败';
const result: NetdiskCheckResult = {
platform,
url,
normalizedUrl,
status: 'unknown',
valid: null,
reason: message,
checkedAt: Date.now(),
durationMs: Date.now() - startedAt,
};
setCachedNetdiskCheckResult(cacheKey, result);
return result;
} finally {
clearNetdiskCheckInflight(cacheKey);
}
})();
setNetdiskCheckInflight(cacheKey, runner);
return runner;
}
+65
View File
@@ -0,0 +1,65 @@
export type NetdiskCheckPlatform =
| '115'
| 'aliyun'
| 'baidu'
| 'cmcc'
| 'pan123'
| 'quark'
| 'tianyi'
| 'uc'
| 'xunlei';
export type NetdiskCheckStatus =
| 'pending'
| 'checking'
| 'valid'
| 'invalid'
| 'unknown'
| 'rate_limited';
export interface NetdiskCheckResult {
platform: NetdiskCheckPlatform;
url: string;
normalizedUrl: string;
status: Exclude<NetdiskCheckStatus, 'pending' | 'checking'>;
valid: boolean | null;
reason?: string;
checkedAt: number;
durationMs: number;
fromCache?: boolean;
isRateLimited?: boolean;
}
export interface NetdiskCheckTaskResultItem {
status: NetdiskCheckStatus;
reason?: string;
fromCache?: boolean;
checkedAt?: number;
durationMs?: number;
}
export interface NetdiskCheckTask {
id: string;
platform: NetdiskCheckPlatform;
status: 'running' | 'completed' | 'failed' | 'cancelled';
progress: {
total: number;
done: number;
valid: number;
invalid: number;
unknown: number;
rateLimited: number;
currentBatch: number;
totalBatches: number;
};
results: Record<string, NetdiskCheckTaskResultItem>;
createdAt: number;
updatedAt: number;
error?: string;
shouldStop?: boolean;
}
export interface NetdiskCheckStartInput {
platform: NetdiskCheckPlatform;
links: string[];
}
+67
View File
@@ -0,0 +1,67 @@
const { request } = require('./http');
/**
* 阿里云盘链接检测
* URL格式: https://www.alipan.com/s/{share_id} 或 https://www.aliyundrive.com/s/{share_id}
* API: POST https://api.aliyundrive.com/adrive/v3/share_link/get_share_by_anonymous
*/
async function checkAliyun(link) {
const { shareId, error: parseError } = extractParamsAliPan(link);
if (parseError) {
return { valid: false, reason: '链接格式无效: ' + parseError };
}
try {
const apiURL = `https://api.aliyundrive.com/adrive/v3/share_link/get_share_by_anonymous?share_id=${encodeURIComponent(shareId)}`;
const { statusCode, body } = await request(apiURL, {
method: 'POST',
body: { share_id: shareId },
headers: {
'authorization': '',
'Content-Type': 'application/json',
'Origin': 'https://www.alipan.com',
'Referer': 'https://www.alipan.com/',
'Priority': 'u=1, i',
'Sec-Ch-Ua': '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
'X-Canary': 'client=web,app=share,version=v2.3.1',
},
});
if (statusCode === 429) {
return { valid: false, reason: 'API频率限制(429错误)', isRateLimited: true };
}
if (statusCode !== 200) {
return { valid: false, reason: `API返回错误状态码: ${statusCode}` };
}
JSON.parse(body); // 验证可解析即可
return { valid: true, reason: '' };
} catch (err) {
if (err.message === '请求超时') return { valid: false, reason: '请求超时' };
return { valid: false, reason: `检测失败: ${err.message}` };
}
}
function extractParamsAliPan(urlStr) {
try {
const u = new URL(urlStr);
const pathParts = u.pathname.replace(/\/+$/, '').split('/').filter(Boolean);
if (pathParts.length === 0) {
return { shareId: '', error: 'URL中未找到share_id' };
}
const shareId = pathParts[pathParts.length - 1];
if (!shareId) {
return { shareId: '', error: '提取的share_id为空' };
}
return { shareId, error: null };
} catch (e) {
return { shareId: '', error: e.message };
}
}
module.exports = { checkAliyun, extractParamsAliPan };
+133
View File
@@ -0,0 +1,133 @@
const { request } = require('./http');
/**
* 百度网盘链接检测
* URL格式: https://pan.baidu.com/s/{surl}?pwd={password}
* 两步检测: 有密码时先验证密码获取randsk,再调用share/list
*/
async function checkBaidu(link) {
const normalizedLink = normalizeBaiduURL(link);
if (!normalizedLink) {
return { valid: false, reason: '未找到有效的百度网盘URL' };
}
const surl = extractBaiduShareID(normalizedLink);
if (!surl) {
return { valid: false, reason: '无效的分享链接格式' };
}
let password = '';
try {
const u = new URL(normalizedLink);
password = u.searchParams.get('pwd') || '';
} catch (_) {}
const shorturl = surl.length > 1 ? surl.substring(1) : surl;
try {
let bdclnd = '';
// 如果有提取码,先验证
if (password) {
const verifyURL = `https://pan.baidu.com/share/verify?surl=${encodeURIComponent(shorturl)}&pwd=${encodeURIComponent(password)}`;
const formBody = `pwd=${encodeURIComponent(password)}&vcode=&vcode_str=`;
const { statusCode: vStatus, body: vBody } = await request(verifyURL, {
method: 'POST',
body: formBody,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': normalizedLink,
},
});
if (vStatus !== 200) {
return { valid: false, reason: `验证提取码请求失败: ${vStatus}` };
}
const vData = JSON.parse(vBody);
if (vData.errno !== 0) {
const errmsg = vData.errmsg || vData.err_msg || '未知错误';
return { valid: false, reason: `验证提取码失败: errno=${vData.errno}, ${errmsg}` };
}
bdclnd = vData.randsk || '';
}
// 调用 share/list API
const apiURL = `https://pan.baidu.com/share/list?web=5&app_id=250528&desc=1&showempty=0&page=1&num=20&order=time&shorturl=${encodeURIComponent(shorturl)}&root=1&view_mode=1&channel=chunlei&web=1&clienttype=0`;
const reqHeaders = {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh,en-GB;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6',
};
if (bdclnd) {
reqHeaders['Cookie'] = `BDCLND=${bdclnd}`;
}
const { statusCode, body } = await request(apiURL, {
headers: reqHeaders,
});
if (statusCode !== 200) {
return { valid: false, reason: `API返回错误状态码: ${statusCode}` };
}
const result = JSON.parse(body);
const errno = result.errno;
const errMsg = result.errmsg || result.err_msg || '';
if (errno === 0) {
return { valid: true, reason: '' };
}
const failureReason = getFailureReason(errno, errMsg);
const isRateLimited = errno === -62;
return { valid: false, reason: failureReason, isRateLimited };
} catch (err) {
if (err.message === '请求超时') return { valid: false, reason: '请求超时' };
return { valid: false, reason: `检测失败: ${err.message}` };
}
}
function normalizeBaiduURL(link) {
const cleaned = link.trim();
const startIdx = cleaned.indexOf('https://pan.baidu.com/s/');
if (startIdx === -1) {
return null;
}
let endIdx = startIdx;
while (endIdx < cleaned.length) {
const char = cleaned[endIdx];
if (char === ' ' || char === '\n' || char === '\r' || char === '\t') break;
if (cleaned.substring(endIdx).startsWith('提取码') || cleaned.substring(endIdx).startsWith('密码')) break;
endIdx++;
}
return cleaned.substring(startIdx, endIdx).trim();
}
function extractBaiduShareID(shareURL) {
try {
const u = new URL(shareURL);
if (u.pathname.startsWith('/s/')) {
let surl = u.pathname.replace('/s/', '');
const qIdx = surl.indexOf('?');
if (qIdx !== -1) surl = surl.substring(0, qIdx);
return surl;
}
if (u.pathname.startsWith('/share/init')) {
return u.searchParams.get('surl') || '';
}
} catch (_) {}
return '';
}
function getFailureReason(errno, errMsg) {
if (errMsg) return `分享链接无效 (errno: ${errno}, err_msg: ${errMsg})`;
switch (errno) {
case -12: return '缺少提取码 (errno: -12)';
case -9: return '提取码错误 (errno: -9)';
case -62: return '请求接口受限 (errno: -62)';
case -8: return '分享文件已过期 (errno: -8)';
default: return `分享链接无效 (errno: ${errno})`;
}
}
module.exports = { checkBaidu, normalizeBaiduURL, extractBaiduShareID };
+122
View File
@@ -0,0 +1,122 @@
const crypto = require('crypto');
const { request } = require('./http');
/**
* 中国移动云盘链接检测
* URL格式:
* https://yun.139.com/shareweb/#/w/i/{shareID}
* https://caiyun.139.com/m/i?{shareID}
* API: POST https://share-kd-njs.yun.139.com/yun-share/richlifeApp/devapp/IOutLink/getOutLinkInfoV6
* 请求和响应均通过AES-CBC加密(固定密钥)
*/
const CMCC_AES_KEY = 'PVGDwmcvfs1uV3d1';
function aesCBCEncrypt(plaintext, key) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(key, 'utf-8'), iv);
// PKCS7 padding
const blockSize = 16;
const padLen = blockSize - (Buffer.byteLength(plaintext, 'utf-8') % blockSize);
const padded = Buffer.concat([
Buffer.from(plaintext, 'utf-8'),
Buffer.alloc(padLen, padLen),
]);
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
return Buffer.concat([iv, encrypted]).toString('base64');
}
function aesCBCDecrypt(encryptedBase64, key) {
const rawData = Buffer.from(encryptedBase64, 'base64');
if (rawData.length < 16) throw new Error('加密数据长度不足');
const iv = rawData.subarray(0, 16);
const ciphertext = rawData.subarray(16);
const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(key, 'utf-8'), iv);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
// 去除PKCS7填充
const padLen = decrypted[decrypted.length - 1];
if (padLen > 0 && padLen <= 16) {
return decrypted.subarray(0, decrypted.length - padLen).toString('utf-8');
}
return decrypted.toString('utf-8');
}
async function checkCMCC(link) {
const shareID = extractShareID(link);
if (!shareID) {
return { valid: false, reason: '链接格式无效:无法提取分享ID' };
}
try {
const requestData = {
getOutLinkInfoReq: {
account: '',
linkID: shareID,
passwd: '',
caSrt: 1,
coSrt: 1,
srtDr: 0,
bNum: 1,
pCaID: 'root',
eNum: 200,
},
commonAccountInfo: {
account: '',
accountType: 1,
},
};
const jsonStr = JSON.stringify(requestData);
const encryptedData = aesCBCEncrypt(jsonStr, CMCC_AES_KEY);
const encryptedJSON = JSON.stringify(encryptedData);
const { statusCode, body } = await request(
'https://share-kd-njs.yun.139.com/yun-share/richlifeApp/devapp/IOutLink/getOutLinkInfoV6',
{
method: 'POST',
body: encryptedJSON,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/plain, */*',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'hcy-cool-flag': '1',
'x-deviceinfo': '||3|12.27.0|chrome|131.0.0.0|5c7c68368f048245e1ce47f1c0f8f2d0||windows 10|1536X695|zh-CN|||',
},
}
);
if (statusCode !== 200) {
return { valid: false, reason: `API返回错误状态码: ${statusCode}` };
}
// 解密响应
const decryptedData = aesCBCDecrypt(body.trim(), CMCC_AES_KEY);
const response = JSON.parse(decryptedData);
const resultCode = response.resultCode;
const desc = response.desc;
const data = response.data;
if (resultCode === '0' && data != null) {
return { valid: true, reason: '' };
}
const failReason = desc || (resultCode ? `错误码: ${resultCode}` : '获取分享信息失败');
return { valid: false, reason: failReason };
} catch (err) {
if (err.message === '请求超时') return { valid: false, reason: '请求超时' };
return { valid: false, reason: `检测失败: ${err.message}` };
}
}
function extractShareID(shareURL) {
const match = shareURL.match(/https:\/\/(?:yun\.139\.com\/shareweb\/#\/w\/i\/|caiyun\.139\.com\/m\/i\?)([^&]+)/);
return match ? match[1] : '';
}
module.exports = { checkCMCC, extractShareID, aesCBCEncrypt, aesCBCDecrypt };
+60
View File
@@ -0,0 +1,60 @@
const https = require('https');
const http = require('http');
const { URL } = require('url');
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
const DEFAULT_HEADERS = {
'accept': 'application/json;charset=UTF-8',
'accept-language': 'en,zh-CN;q=0.9,zh;q=0.8',
'user-agent': DEFAULT_UA,
'cache-control': 'no-cache',
'pragma': 'no-cache',
};
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const timeout = options.timeout || 15000;
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'https:' ? https : http;
const headers = { ...DEFAULT_HEADERS, ...(options.headers || {}) };
delete headers['Content-Type']; // handled below
const reqOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: options.method || 'GET',
headers,
timeout,
};
const req = transport.request(reqOptions, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
resolve({ statusCode: res.statusCode, headers: res.headers, body });
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('请求超时'));
});
if (options.body) {
if (typeof options.body === 'object') {
req.setHeader('Content-Type', 'application/json');
req.write(JSON.stringify(options.body));
} else {
req.write(options.body);
}
}
req.end();
});
}
module.exports = { request, DEFAULT_UA, DEFAULT_HEADERS };
+77
View File
@@ -0,0 +1,77 @@
const { request } = require('./http');
/**
* 115网盘链接检测
* URL格式: https://115cdn.com/s/{share_code}?password={receive_code}
* API: GET https://115cdn.com/webapi/share/snap
*/
async function check115(link) {
const { shareCode, receiveCode, error: parseError } = extractParams115(link);
if (parseError || !shareCode || !receiveCode) {
return { valid: false, reason: parseError || (!shareCode ? '缺少分享码' : '缺少提取码') };
}
try {
const apiURL = `https://115cdn.com/webapi/share/snap?share_code=${encodeURIComponent(shareCode)}&offset=0&limit=20&receive_code=${encodeURIComponent(receiveCode)}&cid=`;
const { statusCode, body } = await request(apiURL, {
headers: {
'Referer': `https://115cdn.com/s/${shareCode}?password=${receiveCode}&`,
'Sec-Ch-Ua': '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'X-Requested-With': 'XMLHttpRequest',
'Priority': 'u=1, i',
},
});
if (statusCode !== 200) {
return { valid: false, reason: `API返回错误状态码: ${statusCode}` };
}
const data = JSON.parse(body);
if (data.state === true && data.errno === 0) {
let shareState = data.data?.share_state || 0;
// 兼容部分响应只在 shareinfo 中返回 share_state
if (shareState === 0 && data.data?.shareinfo?.share_state) {
shareState = data.data.shareinfo.share_state;
}
if (shareState === 1) {
return { valid: true, reason: '' };
}
const failReason = (data.data?.shareinfo?.forbid_reason || '').trim()
|| `链接状态异常(share_state=${shareState})`;
return { valid: false, reason: failReason };
}
return { valid: false, reason: data.error || '未知错误' };
} catch (err) {
if (err.message === '请求超时') return { valid: false, reason: '请求超时' };
return { valid: false, reason: `检测失败: ${err.message}` };
}
}
function extractParams115(urlStr) {
try {
const u = new URL(urlStr);
const pathParts = u.pathname.replace(/\/+$/, '').split('/');
const shareCode = pathParts[pathParts.length - 1] || '';
let receiveCode = u.searchParams.get('password') || '';
if (!receiveCode && u.hash && u.hash.includes('password=')) {
const hashParams = new URLSearchParams(u.hash.replace(/^#/, ''));
receiveCode = hashParams.get('password') || '';
}
return { shareCode, receiveCode, error: null };
} catch (e) {
return { shareCode: '', receiveCode: '', error: e.message };
}
}
module.exports = { check115, extractParams115 };
+77
View File
@@ -0,0 +1,77 @@
const { request } = require('./http');
/**
* 123网盘链接检测
* URL格式: https://www.123pan.com/s/{shareKey}
* API: GET https://www.123pan.com/api/share/info?shareKey={shareKey}
*
* 注意: 此检测器采用保守策略,超时/403/请求错误均视为有效,避免误判
*/
async function check123(link) {
const { shareKey, error: parseError } = extractShareKey123(link);
if (parseError) {
return { valid: false, reason: '链接格式无效: ' + parseError };
}
try {
const apiURL = `https://www.123pan.com/api/share/info?shareKey=${encodeURIComponent(shareKey)}`;
const { statusCode, body } = await request(apiURL, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
// 403视为有效(访问限制,不是链接失效)
if (statusCode === 403) {
return { valid: true, reason: '' };
}
if (statusCode !== 200) {
return { valid: true, reason: '' }; // 非预期状态码也视为有效,避免误判
}
let data;
try {
data = JSON.parse(body);
} catch (_) {
return { valid: true, reason: '' }; // JSON解析错误视为有效
}
// code==0 或 HasPwd==true 均视为有效
if (data.code === 0 || data.data?.HasPwd === true) {
return { valid: true, reason: '' };
}
return { valid: false, reason: '链接已失效' };
} catch (err) {
// 超时和请求错误均视为有效,避免误判
return { valid: true, reason: '' };
}
}
function extractShareKey123(urlStr) {
const patterns = [
/https?:\/\/(?:www\.)?(?:123684|123685|123912|123pan|123592|123865)\.com\/s\/([a-zA-Z0-9-]+)/,
/https?:\/\/(?:www\.)?123pan\.cn\/s\/([a-zA-Z0-9-]+)/,
];
for (const pattern of patterns) {
const match = urlStr.match(pattern);
if (match && match[1]) {
return { shareKey: match[1], error: null };
}
}
// Fallback: 从URL路径中提取
try {
const u = new URL(urlStr);
const pathParts = u.pathname.replace(/\/+$/, '').split('/').filter(Boolean);
if (pathParts.length > 0 && pathParts[pathParts.length - 1]) {
return { shareKey: pathParts[pathParts.length - 1], error: null };
}
} catch (_) {}
return { shareKey: '', error: '无法从URL中提取shareKey' };
}
module.exports = { check123, extractShareKey123 };
+97
View File
@@ -0,0 +1,97 @@
const { request } = require('./http');
/**
* 夸克网盘链接检测
* URL格式: https://pan.quark.cn/s/{pwd_id}?pwd={passcode}
* 两步检测: 先获取stoken,再验证文件列表
*/
async function checkQuark(link) {
const { resId, pwd, error: parseError } = extractParamsQuark(link);
if (parseError) {
return { valid: false, reason: '链接格式无效: ' + parseError };
}
try {
// Step 1: 获取 stoken
const tokenURL = 'https://drive-h.quark.cn/1/clouddrive/share/sharepage/token';
const { statusCode: status1, body: body1 } = await request(tokenURL, {
method: 'POST',
body: {
pwd_id: resId,
passcode: pwd,
support_visit_limit_private_share: true,
},
headers: {
'Content-Type': 'application/json',
'Origin': 'https://pan.quark.cn',
'Referer': 'https://pan.quark.cn/',
},
});
if (status1 !== 200) {
return { valid: false, reason: `Token API返回错误状态码: ${status1}` };
}
const tokenResp = JSON.parse(body1);
if (tokenResp.status !== 200 || tokenResp.code !== 0) {
return { valid: false, reason: '分享链接失效或不存在' };
}
if (!tokenResp.data?.stoken) {
return { valid: false, reason: '分享链接无效:未获取到访问令牌' };
}
// Step 2: 获取文件列表
const detailURL = `https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail?pwd_id=${encodeURIComponent(resId)}&stoken=${encodeURIComponent(tokenResp.data.stoken)}&ver=2&pr=ucpro`;
const { statusCode: status2, body: body2 } = await request(detailURL, {
headers: {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Cache-Control': 'no-cache',
'Origin': 'https://pan.quark.cn',
'Referer': 'https://pan.quark.cn/',
'Pragma': 'no-cache',
},
});
if (status2 !== 200) {
return { valid: false, reason: `Detail API返回错误状态码: ${status2}` };
}
const detailResp = JSON.parse(body2);
if (!detailResp.data?.list || detailResp.data.list.length === 0) {
return { valid: false, reason: '分享链接无效:文件列表为空' };
}
return { valid: true, reason: '' };
} catch (err) {
if (err.message === '请求超时') return { valid: false, reason: '请求超时' };
return { valid: false, reason: `检测失败: ${err.message}` };
}
}
function extractParamsQuark(rawURL) {
const urlRegex = /^https:\/\/(?:pan\.quark\.cn|pan\.qoark\.cn)\/s\/[a-zA-Z0-9]+(?:\?[^#]*)?(?:#.*)?$/;
if (!urlRegex.test(rawURL)) {
return { resId: '', pwd: '', error: '无效的URL格式' };
}
try {
const u = new URL(rawURL);
if (!u.pathname.startsWith('/s/')) {
return { resId: '', pwd: '', error: '无效的路径格式' };
}
const pathPart = u.pathname.replace('/s/', '');
const resId = pathPart.split('/')[0].trim();
if (!resId) {
return { resId: '', pwd: '', error: '无法从URL路径中提取有效的pwd_id' };
}
const pwd = (u.searchParams.get('pwd') || '').trim();
return { resId, pwd, error: null };
} catch (e) {
return { resId: '', pwd: '', error: e.message };
}
}
module.exports = { checkQuark, extractParamsQuark };
+98
View File
@@ -0,0 +1,98 @@
const { request } = require('./http');
/**
* 天翼云盘链接检测
* URL格式:
* https://cloud.189.cn/web/share?code=xxx
* https://cloud.189.cn/t/xxx
* https://h5.cloud.189.cn/share.html#/t/xxx
* API: GET https://cloud.189.cn/api/open/share/getShareInfoByCodeV2.action
*/
async function checkTianyi(link) {
const { codeValue, accessCode, refererValue, error: parseError } = extractCodeFromURL(link);
if (parseError) {
return { valid: false, reason: '链接格式无效: ' + parseError };
}
try {
const noCache = Math.random();
// 如果有访问码,需要将访问码包含在shareCode参数中
let shareCodeParam = codeValue;
if (accessCode) {
shareCodeParam = `${codeValue}(访问码:${accessCode}`;
}
const apiURL = `https://cloud.189.cn/api/open/share/getShareInfoByCodeV2.action?noCache=${noCache}&shareCode=${encodeURIComponent(shareCodeParam)}`;
const { statusCode, body } = await request(apiURL, {
headers: {
'Priority': 'u=1, i',
'Referer': refererValue,
'Sec-Ch-Ua': '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'Sign-Type': '1',
},
});
if (statusCode !== 200) {
return { valid: false, reason: `API返回错误状态码: ${statusCode}` };
}
const data = JSON.parse(body);
if (data.shareId > 0) {
return { valid: true, reason: '' };
}
const failReason = data.res_message || `无法获取分享信息 (ShareId=${data.shareId || 0})`;
return { valid: false, reason: failReason };
} catch (err) {
if (err.message === '请求超时') return { valid: false, reason: '请求超时' };
return { valid: false, reason: `检测失败: ${err.message}` };
}
}
function extractCodeFromURL(urlStr) {
try {
const u = new URL(urlStr);
let codeValue = '';
let accessCode = '';
// 1. 从查询参数获取code
codeValue = u.searchParams.get('code') || '';
// 2. 从路径获取 /t/xxx
if (!codeValue && u.pathname.startsWith('/t/')) {
codeValue = u.pathname.replace('/t/', '').split('/')[0];
}
// 3. 从hash获取 #/t/xxx
if (!codeValue && u.hash) {
const fragment = u.hash.replace(/^#/, '');
if (fragment.startsWith('/t/')) {
codeValue = fragment.replace('/t/', '').split('/')[0];
} else if (fragment.startsWith('#/t/')) {
codeValue = fragment.replace('#/t/', '').split('/')[0];
}
}
if (!codeValue) {
return { codeValue: '', accessCode: '', refererValue: '', error: '输入URL中未找到code参数' };
}
// 提取访问码(访问码:xxx
const accessCodePattern = /[(]访问码[:]\s*([a-zA-Z0-9]+)[)]/;
const match = urlStr.match(accessCodePattern);
if (match && match[1]) {
accessCode = match[1];
}
return { codeValue, accessCode, refererValue: urlStr, error: null };
} catch (e) {
return { codeValue: '', accessCode: '', refererValue: '', error: e.message };
}
}
module.exports = { checkTianyi, extractCodeFromURL };
+58
View File
@@ -0,0 +1,58 @@
const { request, DEFAULT_UA } = require('./http');
/**
* UC网盘链接检测
* URL格式: https://drive.uc.cn/s/{shareID}
* 检测方法: 页面爬取,通过关键词判断有效性
*/
async function checkUC(link) {
const { shareID, error: parseError } = extractShareIDFromURL(link);
if (parseError) {
return { valid: false, reason: '链接格式无效: ' + parseError };
}
try {
const url = `https://drive.uc.cn/s/${shareID}`;
const { statusCode, body } = await request(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.101 Mobile Safari/537.36',
},
});
if (statusCode !== 200) {
return { valid: false, reason: `HTTP状态码: ${statusCode}` };
}
const pageText = body.toLowerCase();
const errorKeywords = ['失效', '不存在', '违规', '删除', '已过期', '被取消'];
for (const keyword of errorKeywords) {
if (pageText.includes(keyword)) {
return { valid: false, reason: '链接已失效' };
}
}
const validKeywords = ['文件', '分享'];
for (const keyword of validKeywords) {
if (pageText.includes(keyword)) {
return { valid: true, reason: '' };
}
}
return { valid: false, reason: '无法判断链接有效性' };
} catch (err) {
if (err.message === '请求超时') return { valid: true, reason: '' }; // 超时视为有效,避免误判
return { valid: true, reason: '' }; // 连接错误也视为有效,避免误判
}
}
function extractShareIDFromURL(urlStr) {
const pattern = /https?:\/\/drive\.uc\.cn\/s\/([a-zA-Z0-9]+)/;
const match = urlStr.match(pattern);
if (match && match[1]) {
return { shareID: match[1], error: null };
}
return { shareID: '', error: '无法从URL中提取share_id' };
}
module.exports = { checkUC, extractShareIDFromURL };
+191
View File
@@ -0,0 +1,191 @@
const crypto = require('crypto');
const zlib = require('zlib');
const { request } = require('./http');
/**
* 迅雷云盘链接检测
* URL格式: https://pan.xunlei.com/s/{share_id}?pwd={pass_code}
* 两步检测: 先获取captcha token,再调用share API
*/
const XUNLEI_DEVICE_ID = '5505bd0cab8c9469b98e5891d9fb3e0d';
const XUNLEI_CLIENT_ID = 'ZUBzD9J_XPXfn7f7';
const XUNLEI_CLIENT_VERSION = '1.10.0.2633';
const XUNLEI_PACKAGE_NAME = 'com.xunlei.browser';
const XUNLEI_UA = 'ANDROID-com.xunlei.browser/1.10.0.2633 networkType/WIFI appid/22062 deviceName/Xiaomi_M2004j7ac deviceModel/M2004J7AC OSVersion/13 protocolVersion/301 platformVersion/10 sdkVersion/233100 Oauth2Client/0.9 (Linux 4_9_337-perf-sn-uotan-gd9d488809c3d3d) (JAVA 0)';
const CAPTCHA_ALGORITHMS = [
'uWRwO7gPfdPB/0NfPtfQO+71',
'F93x+qPluYy6jdgNpq+lwdH1ap6WOM+nfz8/V',
'0HbpxvpXFsBK5CoTKam',
'dQhzbhzFRcawnsZqRETT9AuPAJ+wTQso82mRv',
'SAH98AmLZLRa6DB2u68sGhyiDh15guJpXhBzI',
'unqfo7Z64Rie9RNHMOB',
'7yxUdFADp3DOBvXdz0DPuKNVT35wqa5z0DEyEvf',
'RBG',
'ThTWPG5eC0UBqlbQ+04nZAptqGCdpv9o55A',
];
function getCaptchaSign(clientID, clientVersion, packageName, deviceID) {
const timestamp = Date.now().toString();
let str = `${clientID}${clientVersion}${packageName}${deviceID}${timestamp}`;
for (const algorithm of CAPTCHA_ALGORITHMS) {
str = crypto.createHash('md5').update(str + algorithm).digest('hex');
}
return { timestamp, sign: `1.${str}` };
}
async function getCaptchaToken(action, metas = {}) {
const { timestamp, sign: captchaSign } = getCaptchaSign(
XUNLEI_CLIENT_ID, XUNLEI_CLIENT_VERSION, XUNLEI_PACKAGE_NAME, XUNLEI_DEVICE_ID
);
metas.timestamp = timestamp;
metas.captcha_sign = captchaSign;
metas.client_version = XUNLEI_CLIENT_VERSION;
metas.package_name = XUNLEI_PACKAGE_NAME;
const requestBody = {
action,
captcha_token: '',
client_id: XUNLEI_CLIENT_ID,
device_id: XUNLEI_DEVICE_ID,
meta: metas,
redirect_uri: 'xlaccsdk01://xunlei.com/callback?state=harbor',
};
const { statusCode, body, headers } = await request(
'https://xluser-ssl.xunlei.com/v1/shield/captcha/init',
{
method: 'POST',
body: requestBody,
headers: {
'Accept': 'application/json;charset=UTF-8',
'Content-Type': 'application/json',
'User-Agent': XUNLEI_UA,
'X-Device-Id': XUNLEI_DEVICE_ID,
'X-Client-Id': XUNLEI_CLIENT_ID,
'X-Client-Version': XUNLEI_CLIENT_VERSION,
},
}
);
if (statusCode !== 200) {
throw new Error(`验证码token请求失败,状态码: ${statusCode}`);
}
let respBody = body;
// 解压 gzip/deflate
const encoding = (headers['content-encoding'] || '').toLowerCase();
if (encoding === 'gzip') {
respBody = zlib.gunzipSync(Buffer.from(body, 'binary')).toString('utf-8');
} else if (encoding === 'deflate') {
respBody = zlib.inflateSync(Buffer.from(body, 'binary')).toString('utf-8');
}
const data = JSON.parse(respBody);
if (data.url) {
throw new Error(`需要验证: ${data.url}`);
}
if (!data.captcha_token) {
throw new Error('未获取到验证码token');
}
return data.captcha_token;
}
async function checkXunlei(link) {
const shareID = extractShareID(link);
if (!shareID) {
return { valid: false, reason: '链接格式无效:无法提取share_id' };
}
let passCode = '';
try {
const u = new URL(link);
passCode = u.searchParams.get('pwd') || '';
} catch (_) {}
try {
// Step 1: 获取 captcha token
let captchaToken = '';
try {
captchaToken = await getCaptchaToken('get:/drive/v1/share', {
username: '',
phone_number: '',
email: '',
package_name: 'pan.xunlei.com',
client_version: '1.92.10',
user_id: '0',
});
} catch (_) {
// token获取失败时继续,不带token请求
}
// Step 2: 调用share API
const apiURL = `https://api-pan.xunlei.com/drive/v1/share?share_id=${encodeURIComponent(shareID)}&pass_code=${encodeURIComponent(passCode)}&limit=100&pass_code_token=&page_token=&thumbnail_size=SIZE_SMALL`;
const reqHeaders = {
'Accept': '*/*',
'Content-Type': 'application/json',
'Origin': 'https://pan.xunlei.com',
'Referer': 'https://pan.xunlei.com/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
'Accept-Encoding': 'gzip, deflate',
'X-Client-Id': XUNLEI_CLIENT_ID,
'X-Device-Id': XUNLEI_DEVICE_ID,
};
if (captchaToken) {
reqHeaders['X-Captcha-Token'] = captchaToken;
}
const { statusCode, body, headers } = await request(apiURL, { headers: reqHeaders });
let respBody = body;
// 解压
const encoding = (headers['content-encoding'] || '').toLowerCase();
if (encoding === 'gzip') {
respBody = zlib.gunzipSync(Buffer.from(body, 'binary')).toString('utf-8');
} else if (encoding === 'deflate') {
respBody = zlib.inflateSync(Buffer.from(body, 'binary')).toString('utf-8');
}
if (statusCode !== 200) {
let isRateLimited = false;
try {
const errData = JSON.parse(respBody);
if (errData.error_code === 9) isRateLimited = true;
return {
valid: false,
reason: `HTTP状态码: ${statusCode}, 响应: ${respBody}`,
isRateLimited,
};
} catch (_) {
return { valid: false, reason: `HTTP状态码: ${statusCode}` };
}
}
const apiResp = JSON.parse(respBody);
if (apiResp.share_status === 'OK') {
return { valid: true, reason: '' };
}
if (apiResp.error) {
return { valid: false, reason: apiResp.error };
}
const statusText = apiResp.share_status_text || `分享状态: ${apiResp.share_status}`;
return { valid: false, reason: statusText };
} catch (err) {
if (err.message === '请求超时') return { valid: true, reason: '' }; // 超时视为有效,避免误判
return { valid: false, reason: `检测失败: ${err.message}` };
}
}
function extractShareID(shareURL) {
const match = shareURL.match(/pan\.xunlei\.com\/s\/([^?/#]+)/);
return match ? match[1] : '';
}
module.exports = { checkXunlei, extractShareID, getCaptchaSign, getCaptchaToken };
+60
View File
@@ -0,0 +1,60 @@
const https = require('https');
const http = require('http');
const { URL } = require('url');
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
const DEFAULT_HEADERS = {
'accept': 'application/json;charset=UTF-8',
'accept-language': 'en,zh-CN;q=0.9,zh;q=0.8',
'user-agent': DEFAULT_UA,
'cache-control': 'no-cache',
'pragma': 'no-cache',
};
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const timeout = options.timeout || 15000;
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'https:' ? https : http;
const headers = { ...DEFAULT_HEADERS, ...(options.headers || {}) };
delete headers['Content-Type']; // handled below
const reqOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: options.method || 'GET',
headers,
timeout,
};
const req = transport.request(reqOptions, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
resolve({ statusCode: res.statusCode, headers: res.headers, body });
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('请求超时'));
});
if (options.body) {
if (typeof options.body === 'object') {
req.setHeader('Content-Type', 'application/json');
req.write(JSON.stringify(options.body));
} else {
req.write(options.body);
}
}
req.end();
});
}
module.exports = { request, DEFAULT_UA, DEFAULT_HEADERS };