增加网盘有效性检测
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',
|
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({
|
export default function PansouSearch({
|
||||||
keyword,
|
keyword,
|
||||||
triggerSearch,
|
triggerSearch,
|
||||||
@@ -63,6 +130,80 @@ export default function PansouSearch({
|
|||||||
const [transferingUrl, setTransferingUrl] = useState<string | null>(null);
|
const [transferingUrl, setTransferingUrl] = useState<string | null>(null);
|
||||||
const [playingUrl, setPlayingUrl] = useState<string | null>(null);
|
const [playingUrl, setPlayingUrl] = useState<string | null>(null);
|
||||||
const [toast, setToast] = useState<ToastProps | 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 () => {
|
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 = () => {
|
const renderBody = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -309,17 +527,54 @@ export default function PansouSearch({
|
|||||||
|
|
||||||
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
|
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
|
||||||
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
|
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 (
|
return (
|
||||||
<div key={cloudType} className='space-y-3'>
|
<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}`}>
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${typeColor}`}>
|
||||||
{typeName}
|
{typeName}
|
||||||
</span>
|
</span>
|
||||||
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
{links.length} 个链接
|
{links.length} 个链接
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 链接列表 */}
|
{/* 链接列表 */}
|
||||||
@@ -351,6 +606,18 @@ export default function PansouSearch({
|
|||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
<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') && (
|
{(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi' || cloudType === '123' || cloudType === 'uc') && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -405,6 +672,11 @@ export default function PansouSearch({
|
|||||||
{link.datetime && (
|
{link.datetime && (
|
||||||
<span>{new Date(link.datetime).toLocaleDateString()}</span>
|
<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>
|
</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