From 49861c24ec8955899cbdd5a2ef329aad74a5ca60 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 27 Apr 2026 21:25:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BD=91=E7=9B=98=E6=9C=89?= =?UTF-8?q?=E6=95=88=E6=80=A7=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/netdisk/check/cancel/route.ts | 33 +++ src/app/api/netdisk/check/start/route.ts | 38 +++ src/app/api/netdisk/check/task/route.ts | 35 +++ src/components/PansouSearch.tsx | 274 ++++++++++++++++++++- src/lib/netdisk-check-task.ts | 239 ++++++++++++++++++ src/lib/pancheck/cache.ts | 74 ++++++ src/lib/pancheck/index.ts | 185 ++++++++++++++ src/lib/pancheck/types.ts | 65 +++++ src/lib/pancheck/vendor/checkers/aliyun.js | 67 +++++ src/lib/pancheck/vendor/checkers/baidu.js | 133 ++++++++++ src/lib/pancheck/vendor/checkers/cmcc.js | 122 +++++++++ src/lib/pancheck/vendor/checkers/http.js | 60 +++++ src/lib/pancheck/vendor/checkers/pan115.js | 77 ++++++ src/lib/pancheck/vendor/checkers/pan123.js | 77 ++++++ src/lib/pancheck/vendor/checkers/quark.js | 97 ++++++++ src/lib/pancheck/vendor/checkers/tianyi.js | 98 ++++++++ src/lib/pancheck/vendor/checkers/uc.js | 58 +++++ src/lib/pancheck/vendor/checkers/xunlei.js | 191 ++++++++++++++ src/lib/pancheck/vendor/http.js | 60 +++++ 19 files changed, 1982 insertions(+), 1 deletion(-) create mode 100644 src/app/api/netdisk/check/cancel/route.ts create mode 100644 src/app/api/netdisk/check/start/route.ts create mode 100644 src/app/api/netdisk/check/task/route.ts create mode 100644 src/lib/netdisk-check-task.ts create mode 100644 src/lib/pancheck/cache.ts create mode 100644 src/lib/pancheck/index.ts create mode 100644 src/lib/pancheck/types.ts create mode 100644 src/lib/pancheck/vendor/checkers/aliyun.js create mode 100644 src/lib/pancheck/vendor/checkers/baidu.js create mode 100644 src/lib/pancheck/vendor/checkers/cmcc.js create mode 100644 src/lib/pancheck/vendor/checkers/http.js create mode 100644 src/lib/pancheck/vendor/checkers/pan115.js create mode 100644 src/lib/pancheck/vendor/checkers/pan123.js create mode 100644 src/lib/pancheck/vendor/checkers/quark.js create mode 100644 src/lib/pancheck/vendor/checkers/tianyi.js create mode 100644 src/lib/pancheck/vendor/checkers/uc.js create mode 100644 src/lib/pancheck/vendor/checkers/xunlei.js create mode 100644 src/lib/pancheck/vendor/http.js diff --git a/src/app/api/netdisk/check/cancel/route.ts b/src/app/api/netdisk/check/cancel/route.ts new file mode 100644 index 0000000..0b28326 --- /dev/null +++ b/src/app/api/netdisk/check/cancel/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/netdisk/check/start/route.ts b/src/app/api/netdisk/check/start/route.ts new file mode 100644 index 0000000..f897d8c --- /dev/null +++ b/src/app/api/netdisk/check/start/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/netdisk/check/task/route.ts b/src/app/api/netdisk/check/task/route.ts new file mode 100644 index 0000000..3bb6de3 --- /dev/null +++ b/src/app/api/netdisk/check/task/route.ts @@ -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 } + ); + } +} diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx index e3b2d9b..004b4fb 100644 --- a/src/components/PansouSearch.tsx +++ b/src/components/PansouSearch.tsx @@ -49,6 +49,73 @@ const CLOUD_TYPE_COLORS: Record = { 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 = { + '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; + error?: string; +} + +interface StoredCloudCheckState { + taskId: string; + task: NetdiskCheckTaskPayload; +} + +const CHECK_STATUS_STYLE: Record = { + 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 = { + 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(null); const [playingUrl, setPlayingUrl] = useState(null); const [toast, setToast] = useState(null); + const [cooldownRemainingMs, setCooldownRemainingMs] = useState(0); + const [checkStatesByType, setCheckStatesByType] = useState>({}); + + 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 (
{/* 网盘类型标题 */} -
+
{typeName} {links.length} 个链接 + {groupProgress && ( + + 进度 {groupProgress.done}/{groupProgress.total} · 有效 {groupProgress.valid} · 失效 {groupProgress.invalid} · 未知 {groupProgress.unknown + groupProgress.rateLimited} + + )} + {checkable && ( + <> + + {isCheckingThisType && ( + + )} + + )} + {cooldownRemainingMs > 0 && ( + + 冷却中 {Math.ceil(cooldownRemainingMs / 1000)}s + + )}
{/* 链接列表 */} @@ -351,6 +606,18 @@ export default function PansouSearch({ {/* 操作按钮 */}
+ {(() => { + const checkResult = getCheckResultForUrl(cloudType, link.url); + if (!checkResult) return null; + return ( + + {CHECK_STATUS_TEXT[checkResult.status]} + + ); + })()} {(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi' || cloudType === '123' || cloudType === 'uc') && ( <>
{/* 图片预览 */} diff --git a/src/lib/netdisk-check-task.ts b/src/lib/netdisk-check-task.ts new file mode 100644 index 0000000..1e1ea86 --- /dev/null +++ b/src/lib/netdisk-check-task.ts @@ -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(); +const activeTaskIds = new Set(); +let cooldownUntil = 0; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function withTimeout(promise: Promise, timeoutMs: number) { + return Promise.race([ + promise, + new Promise((_, 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; +} diff --git a/src/lib/pancheck/cache.ts b/src/lib/pancheck/cache.ts new file mode 100644 index 0000000..458e742 --- /dev/null +++ b/src/lib/pancheck/cache.ts @@ -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(); +const INFLIGHT = new Map>(); + +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) { + INFLIGHT.set(cacheKey, promise); +} + +export function clearNetdiskCheckInflight(cacheKey: string) { + INFLIGHT.delete(cacheKey); +} diff --git a/src/lib/pancheck/index.ts b/src/lib/pancheck/index.ts new file mode 100644 index 0000000..6df98cf --- /dev/null +++ b/src/lib/pancheck/index.ts @@ -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; +}; + +const PLATFORM_PATTERNS: Record = { + '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 = { + '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 Promise>(); + +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 { + 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; +} diff --git a/src/lib/pancheck/types.ts b/src/lib/pancheck/types.ts new file mode 100644 index 0000000..2bda223 --- /dev/null +++ b/src/lib/pancheck/types.ts @@ -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; + 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; + createdAt: number; + updatedAt: number; + error?: string; + shouldStop?: boolean; +} + +export interface NetdiskCheckStartInput { + platform: NetdiskCheckPlatform; + links: string[]; +} diff --git a/src/lib/pancheck/vendor/checkers/aliyun.js b/src/lib/pancheck/vendor/checkers/aliyun.js new file mode 100644 index 0000000..c504a32 --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/aliyun.js @@ -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 }; diff --git a/src/lib/pancheck/vendor/checkers/baidu.js b/src/lib/pancheck/vendor/checkers/baidu.js new file mode 100644 index 0000000..3b98af1 --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/baidu.js @@ -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 }; \ No newline at end of file diff --git a/src/lib/pancheck/vendor/checkers/cmcc.js b/src/lib/pancheck/vendor/checkers/cmcc.js new file mode 100644 index 0000000..986e24a --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/cmcc.js @@ -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 }; \ No newline at end of file diff --git a/src/lib/pancheck/vendor/checkers/http.js b/src/lib/pancheck/vendor/checkers/http.js new file mode 100644 index 0000000..7d8c756 --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/http.js @@ -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 }; diff --git a/src/lib/pancheck/vendor/checkers/pan115.js b/src/lib/pancheck/vendor/checkers/pan115.js new file mode 100644 index 0000000..7aa905a --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/pan115.js @@ -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 }; diff --git a/src/lib/pancheck/vendor/checkers/pan123.js b/src/lib/pancheck/vendor/checkers/pan123.js new file mode 100644 index 0000000..4fe6899 --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/pan123.js @@ -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 }; diff --git a/src/lib/pancheck/vendor/checkers/quark.js b/src/lib/pancheck/vendor/checkers/quark.js new file mode 100644 index 0000000..1b25792 --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/quark.js @@ -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 }; diff --git a/src/lib/pancheck/vendor/checkers/tianyi.js b/src/lib/pancheck/vendor/checkers/tianyi.js new file mode 100644 index 0000000..5ae2955 --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/tianyi.js @@ -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 }; \ No newline at end of file diff --git a/src/lib/pancheck/vendor/checkers/uc.js b/src/lib/pancheck/vendor/checkers/uc.js new file mode 100644 index 0000000..0a96106 --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/uc.js @@ -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 }; \ No newline at end of file diff --git a/src/lib/pancheck/vendor/checkers/xunlei.js b/src/lib/pancheck/vendor/checkers/xunlei.js new file mode 100644 index 0000000..dadffe5 --- /dev/null +++ b/src/lib/pancheck/vendor/checkers/xunlei.js @@ -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 }; \ No newline at end of file diff --git a/src/lib/pancheck/vendor/http.js b/src/lib/pancheck/vendor/http.js new file mode 100644 index 0000000..7d8c756 --- /dev/null +++ b/src/lib/pancheck/vendor/http.js @@ -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 };