增加网盘有效性检测
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 图片预览 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
Vendored
+60
@@ -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 };
|
||||
Reference in New Issue
Block a user