feat: init media-center skill

资源中心——从多渠道获取资源链接,转存到夸克网盘并整理归档。
- sources/tencent-doc: 腾讯文档读取
- sources/search: 网盘搜索
- storage/quark: 夸克网盘操作
- ref/: 来源 skill 参考归档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:28:23 +08:00
commit 750f981c7e
37 changed files with 7847 additions and 0 deletions
+390
View File
@@ -0,0 +1,390 @@
import axios, { AxiosInstance } from 'axios';
import { Config } from './config.js';
export interface ShareInfo {
type: 'quark' | '115';
pwdId?: string;
passcode?: string;
shareCode?: string;
receiveCode?: string;
}
export interface FileItem {
name: string;
size: number;
fid?: string;
fileId?: string;
token?: string;
dir?: string;
}
export class NetdiskClient {
private quarkClient: AxiosInstance;
private client115: AxiosInstance;
private config: Config;
constructor(config: Config) {
this.config = config;
this.quarkClient = axios.create({
baseURL: 'https://drive-h.quark.cn',
timeout: config.timeout,
headers: {
'accept': 'application/json, text/plain, */*',
'content-type': 'application/json',
'cookie': config.quarkCookie,
},
});
this.client115 = axios.create({
baseURL: 'https://webapi.115.com',
timeout: config.timeout,
headers: {
'accept': 'application/json, text/javascript, */*; q=0.01',
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
'cookie': config.cookie115,
'referer': 'https://115.com/',
},
});
}
parseShareLink(shareLink: string): ShareInfo {
// Quark: https://pan.quark.cn/s/355379af69a8?pwd=BnxD#/list/share
if (shareLink.includes('quark.cn')) {
const match = shareLink.match(/\/s\/([a-zA-Z0-9]+)/);
if (match) {
let passcode = '';
try {
const url = new URL(shareLink.split('#')[0]);
passcode = url.searchParams.get('pwd') || '';
} catch {}
return { type: 'quark', pwdId: match[1], passcode };
}
} else if (/^[a-zA-Z0-9]{12}$/.test(shareLink)) {
return { type: 'quark', pwdId: shareLink };
}
// 115: https://115cdn.com/s/swfeyyj3zrk?password=eec5
if (shareLink.includes('115.com') || shareLink.includes('115cdn.com')) {
const match = shareLink.match(/\/s\/([a-zA-Z0-9]+)/);
if (match) {
let receiveCode = '';
try {
const url = new URL(shareLink.split('#')[0]);
receiveCode = url.searchParams.get('password') || '';
} catch {}
return { type: '115', shareCode: match[1], receiveCode };
}
}
throw new Error('Unsupported share link format');
}
// ══════════════════════════════════════════════════════
// Quark API
// ══════════════════════════════════════════════════════
async getQuarkToken(pwdId: string, passcode = ''): Promise<string> {
const { data } = await axios.post('https://drive-h.quark.cn/1/clouddrive/share/sharepage/token', {
pwd_id: pwdId,
passcode,
});
if (data?.data?.stoken) return data.data.stoken;
throw new Error(`Failed to get Quark token: ${data?.message || 'unknown error'}`);
}
async getQuarkShareTree(pwdId: string, stoken: string, pdirFid = '0', dirName = '/', maxDepth = 5): Promise<FileItem[]> {
if (maxDepth <= 0) return [];
const { data } = await this.quarkClient.get('/1/clouddrive/share/sharepage/detail', {
params: { pwd_id: pwdId, stoken, pdir_fid: pdirFid, _size: '1000', _fetch_total: '1' },
});
if (!data?.data?.list) return [];
const files: FileItem[] = [];
for (const item of data.data.list) {
if (item.file_type === 1) {
files.push({ name: item.file_name, size: item.size, fid: item.fid, token: item.share_fid_token, dir: dirName });
} else if (item.file_type === 0) {
const sub = await this.getQuarkShareTree(pwdId, stoken, item.fid, item.file_name, maxDepth - 1);
files.push(...sub);
}
}
return files;
}
async listQuark(dirPath = '/'): Promise<string[]> {
const fid = await this.resolveQuarkPathToFID(dirPath);
const { data } = await this.quarkClient.get('/1/clouddrive/file/sort', {
params: { pr: 'ucpro', fr: 'pc', pdir_fid: fid, _page: '1', _size: '1000', _fetch_total: 'false', _fetch_sub_dirs: '1' },
});
if (!data?.data?.list) return [];
return data.data.list.map((item: any, i: number) => {
const type = item.file_type === 1 ? 'file' : 'dir';
const size = item.size ? ` (${formatSize(item.size)})` : '';
return `${i + 1}. [${type}] ${item.file_name}${size} (ID: ${item.fid})`;
});
}
async resolveQuarkPathToFID(targetPath: string): Promise<string> {
if (/^[a-f0-9]{32,}$/i.test(targetPath)) return targetPath;
if (targetPath === '/' || targetPath === '') return '0';
const parts = targetPath.split('/').filter(Boolean);
let currentFID = '0';
for (const name of parts) {
const { data } = await this.quarkClient.get('/1/clouddrive/file/sort', {
params: { pr: 'ucpro', fr: 'pc', pdir_fid: currentFID, _page: '1', _size: '1000', _fetch_total: 'false', _fetch_sub_dirs: '1' },
});
const folder = data?.data?.list?.find((item: any) => item.file_name === name && item.file_type === 0);
if (folder) currentFID = folder.fid;
else throw new Error(`Folder not found in Quark: "${name}" (path: ${targetPath})`);
}
return currentFID;
}
// ══════════════════════════════════════════════════════
// 115 API
// ══════════════════════════════════════════════════════
async get115ShareInfo(shareCode: string, receiveCode: string) {
const { data } = await axios.get('https://webapi.115.com/share/snap', {
params: { share_code: shareCode, receive_code: receiveCode },
headers: { referer: `https://115.com/s/${shareCode}` },
});
if (data?.state) return data.data;
throw new Error(`Failed to get 115 share info: ${data?.error || 'unknown error'}`);
}
async get115ShareTree(shareCode: string, receiveCode: string, cid = '', dirName = '/', maxDepth = 5): Promise<FileItem[]> {
if (maxDepth <= 0) return [];
const { data } = await axios.get('https://webapi.115.com/share/snap', {
params: { share_code: shareCode, receive_code: receiveCode, cid, limit: 1000, offset: 0, asc: '0', format: 'json' },
headers: { referer: `https://115.com/s/${shareCode}` },
});
if (!data?.state || !data.data?.list) return [];
const files: FileItem[] = [];
for (const item of data.data.list) {
if (item.s > 0) {
files.push({ name: item.n, size: item.s, fileId: item.fid || item.cid, dir: dirName });
} else if (item.s === 0 && item.fc === 0) {
const sub = await this.get115ShareTree(shareCode, receiveCode, item.cid, item.n, maxDepth - 1);
files.push(...sub);
}
}
return files;
}
async list115(dirPath = '/'): Promise<string[]> {
const cid = await this.resolve115PathToCID(dirPath);
const { data } = await this.client115.get('/files', {
params: { cid, aid: 1, o: 'user_ptime', asc: 0, offset: 0, limit: 1000, show_dir: 1, snap: 0, natsort: 1 },
});
if (!data?.state) {
throw new Error(`115 API error: ${data?.error || 'login expired, please refresh cookie'}`);
}
if (!data?.data?.length) return [];
return data.data.map((item: any, i: number) => {
const type = (item.fc === 1 || item.fc === '1') ? 'file' : 'dir';
const size = item.s ? ` (${formatSize(item.s)})` : '';
return `${i + 1}. [${type}] ${item.name || item.n}${size} (ID: ${item.cid})`;
});
}
async resolve115PathToCID(targetPath: string): Promise<string> {
if (/^\d+$/.test(targetPath)) return targetPath;
if (targetPath === '/' || targetPath === '') return '0';
const parts = targetPath.split('/').filter(Boolean);
let currentCID = '0';
for (const name of parts) {
const { data } = await this.client115.get('/files', {
params: { cid: currentCID, aid: 1, o: 'user_ptime', asc: 0, offset: 0, limit: 1000, show_dir: 1, snap: 0, natsort: 1 },
});
if (!data?.state) {
throw new Error(`115 API error: ${data?.error || 'login expired, please refresh cookie'}`);
}
const folder = data?.data?.find((item: any) =>
item.n === name && (item.fc === 0 || item.fc === '0' || item.s === 0)
);
if (folder) currentCID = folder.cid;
else throw new Error(`Folder not found in 115: "${name}" (path: ${targetPath})`);
}
return currentCID;
}
// ══════════════════════════════════════════════════════
// Health checks
// ══════════════════════════════════════════════════════
async checkQuarkCookie(): Promise<{ ok: boolean; message: string }> {
try {
const { data } = await this.quarkClient.get('/1/clouddrive/file/sort', {
params: { pr: 'ucpro', fr: 'pc', pdir_fid: '0', _page: '1', _size: '1', _fetch_total: 'false', _fetch_sub_dirs: '0' },
});
if (data?.data?.list) return { ok: true, message: 'Quark cookie is valid' };
return { ok: false, message: `Quark API error: ${data?.message || JSON.stringify(data).substring(0, 200)}` };
} catch (err: any) {
if (err.response?.status === 401 || err.response?.status === 403) {
return { ok: false, message: 'Quark cookie expired or invalid (401/403)' };
}
return { ok: false, message: `Quark check failed: ${err.message}` };
}
}
async check115Cookie(): Promise<{ ok: boolean; message: string }> {
try {
const { data } = await this.client115.get('/files', {
params: { cid: '0', aid: 1, o: 'user_ptime', asc: 0, offset: 0, limit: 1, show_dir: 1, snap: 0, natsort: 1 },
});
if (data?.state) return { ok: true, message: '115 cookie is valid' };
return { ok: false, message: `115 cookie expired: ${data?.error || 'unknown error'}` };
} catch (err: any) {
return { ok: false, message: `115 check failed: ${err.message}` };
}
}
// ══════════════════════════════════════════════════════
// View
// ══════════════════════════════════════════════════════
async viewShare(shareLink: string, filePattern = '*'): Promise<string[]> {
const info = this.parseShareLink(shareLink);
let files: FileItem[];
if (info.type === 'quark') {
const stoken = await this.getQuarkToken(info.pwdId!, info.passcode || '');
files = await this.getQuarkShareTree(info.pwdId!, stoken);
} else {
const shareData = await this.get115ShareInfo(info.shareCode!, info.receiveCode || '');
files = await this.get115ShareTree(info.shareCode!, info.receiveCode || '', shareData.list?.[0]?.cid || '');
}
const filtered = filterFiles(files, filePattern);
if (filtered.length === 0) return ['No files found'];
const totalSize = filtered.reduce((s, f) => s + f.size, 0);
return [
`Share type: ${info.type}, Total: ${filtered.length} files (${formatSize(totalSize)})`,
...filtered.map((f, i) => `${i + 1}. ${f.name} (${formatSize(f.size)}) [${f.dir}]`),
];
}
// ══════════════════════════════════════════════════════
// Transfer
// ══════════════════════════════════════════════════════
async transfer(shareLink: string, targetPath: string, filePattern = '*'): Promise<string> {
const info = this.parseShareLink(shareLink);
if (info.type === 'quark') {
return this.transferQuark(info.pwdId!, info.passcode || '', targetPath, filePattern);
} else {
return this.transfer115(info.shareCode!, info.receiveCode || '', targetPath, filePattern);
}
}
async transferRecursive(shareLink: string, sourcePattern: string, targetPath: string): Promise<string> {
const info = this.parseShareLink(shareLink);
// Extract file pattern from source path, e.g. "/folder/*S01E02*.mp4" -> "*S01E02*.mp4"
let filePattern = '*';
if (sourcePattern !== '/' && sourcePattern !== '') {
const match = sourcePattern.match(/\/([^/]+)$/);
if (match && match[1] !== '*') {
filePattern = match[1];
}
}
if (info.type === 'quark') {
return this.transferQuark(info.pwdId!, info.passcode || '', targetPath, filePattern);
} else {
return this.transfer115(info.shareCode!, info.receiveCode || '', targetPath, filePattern);
}
}
private async transferQuark(pwdId: string, passcode: string, targetPath: string, filePattern: string): Promise<string> {
const stoken = await this.getQuarkToken(pwdId, passcode);
const allFiles = await this.getQuarkShareTree(pwdId, stoken);
const filtered = filterFiles(allFiles, filePattern);
if (filtered.length === 0) return 'No matching files found';
const targetFolderId = await this.resolveQuarkPathToFID(targetPath);
const { data } = await this.quarkClient.post(
`/1/clouddrive/share/sharepage/save?pr=ucpro&fr=pc&uc_param_str=&__t=${Date.now()}`,
{
fid_list: filtered.map(f => f.fid),
fid_token_list: filtered.map(f => f.token),
to_pdir_fid: targetFolderId,
pwd_id: pwdId,
stoken,
pdir_fid: '0',
scene: 'link',
},
{ headers: { referer: `https://pan.quark.cn/s/${pwdId}`, origin: 'https://pan.quark.cn' } }
);
if (data?.status === 200 && data?.code === 0) {
const taskData = data.data?.task_resp?.data || data.data;
const count = taskData.save_as_sum_num || filtered.length;
const size = formatSize(taskData.min_save_file_size || filtered.reduce((s, f) => s + f.size, 0));
return `Transfer success: ${count} files (${size}) saved to ${targetPath}`;
}
return `Transfer failed: ${data?.message || JSON.stringify(data)}`;
}
private async transfer115(shareCode: string, receiveCode: string, targetPath: string, filePattern: string): Promise<string> {
const shareData = await this.get115ShareInfo(shareCode, receiveCode);
const rootCid = shareData.list?.[0]?.cid || '';
const allFiles = await this.get115ShareTree(shareCode, receiveCode, rootCid);
const filtered = filterFiles(allFiles, filePattern);
if (filtered.length === 0) return 'No matching files found';
const targetFolderId = await this.resolve115PathToCID(targetPath);
const param = new URLSearchParams({
cid: targetFolderId,
share_code: shareCode,
receive_code: receiveCode,
file_id: filtered.map(f => f.fileId).join(','),
});
const { data } = await this.client115.post('/share/receive', param.toString());
if (data?.state) {
const count = data.data?.recv_file_count || filtered.length;
const size = formatSize(data.data?.receive_size || filtered.reduce((s, f) => s + f.size, 0));
return `Transfer success: ${count} files (${size}) to ${targetPath}. Note: 115 transfers may have delay.`;
}
return `Transfer failed: ${data?.error || 'Unknown error'}`;
}
}
// ══════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════
export function filterFiles(files: FileItem[], pattern: string): FileItem[] {
if (!pattern || pattern === '*' || pattern === '.*') return files;
if (pattern.includes('.') && !pattern.includes('*') && !pattern.includes('?')) {
return files.filter(f => f.name === pattern);
}
let regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
if (pattern.endsWith('.mp4')) regexPattern = '^.*\\.mp4$';
else if (pattern.endsWith('.mkv')) regexPattern = '^.*\\.mkv$';
else if (pattern.endsWith('.avi')) regexPattern = '^.*\\.avi$';
const regex = new RegExp(regexPattern, 'i');
return files.filter(f => regex.test(f.name));
}
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${bytes} B`;
}
+17
View File
@@ -0,0 +1,17 @@
export interface Config {
quarkCookie: string;
cookie115: string;
timeout: number;
logLevel: string;
pansouUrl: string;
}
export function loadConfig(): Config {
const quarkCookie = process.env.NETDISK_QUARK_COOKIE || process.env.CLOUD_TRANSFER_QUARK_COOKIE || '';
const cookie115 = process.env.NETDISK_115_COOKIE || process.env.CLOUD_TRANSFER_115_COOKIE || '';
const timeout = parseInt(process.env.NETDISK_TIMEOUT || process.env.CLOUD_TRANSFER_TIMEOUT || '30', 10) * 1000;
const logLevel = process.env.NETDISK_LOG_LEVEL || process.env.CLOUD_TRANSFER_LOG_LEVEL || 'info';
const pansouUrl = (process.env.PANSOU_URL || '').replace(/\/+$/, '');
return { quarkCookie, cookie115, timeout, logLevel, pansouUrl };
}
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { loadConfig } from './config.js';
import { NetdiskClient } from './client.js';
import { PansouClient } from './pansou.js';
import { OfflineDownloader } from './offline.js';
const config = loadConfig();
const client = new NetdiskClient(config);
const pansou = config.pansouUrl ? new PansouClient(config.pansouUrl) : null;
const downloader = new OfflineDownloader(config);
const server = new McpServer({
name: 'netdisk-mcp-server',
version: '3.0.0',
});
// ── Tool: list ──
server.tool(
'list',
[
'List files and folders in your Quark or 115 cloud drive.',
'Returns numbered entries with [dir]/[file] type, name, size and ID.',
'',
'Examples:',
' list(cloud="quark", path="/") → list Quark root',
' list(cloud="115", path="/媒体库") → list 115 媒体库 folder',
'',
'The path is resolved internally — you never need to know folder IDs.',
].join('\n'),
{
cloud: z.enum(['quark', '115']).describe('"quark" for 夸克网盘, "115" for 115网盘'),
path: z.string().default('/').describe(
'Directory path. Use "/" for root. Sub-folders like "/3670" or "/媒体库/电视剧".'
),
},
async ({ cloud, path }) => {
try {
const lines = cloud === 'quark'
? await client.listQuark(path)
: await client.list115(path);
if (lines.length === 0) return { content: [{ type: 'text', text: 'Directory is empty or not found' }] };
return {
content: [{ type: 'text', text: `Listing ${cloud} drive: ${path}\n\n${lines.join('\n')}` }],
};
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: view ──
server.tool(
'view',
[
'View the file listing of a Quark or 115 share link.',
'Returns file name, size, and the folder each file lives in.',
'',
'Supported link formats:',
' Quark: https://pan.quark.cn/s/<id> (optionally with ?pwd=<code>)',
' 115: https://115.com/s/<code> (optionally with ?password=<code>)',
' 115: https://115cdn.com/s/<code> (optionally with ?password=<code>)',
'',
'file_pattern uses glob-style matching:',
' * all files (default)',
' *.mp4 all MP4 files',
' *.mkv all MKV files',
' S01E01* files starting with "S01E01"',
' *2160p* files containing "2160p"',
' exact.mp4 match exact filename',
].join('\n'),
{
share_link: z.string().describe('Full share link URL from Quark or 115'),
file_pattern: z.string().default('*').describe(
'Glob pattern to filter by filename. Use "*" for all, "*.mp4" for videos, "S01E01*" for a specific episode, etc.'
),
},
async ({ share_link, file_pattern }) => {
try {
const lines = await client.viewShare(share_link, file_pattern);
return { content: [{ type: 'text', text: lines.join('\n') }] };
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: transfer ──
server.tool(
'transfer',
[
'Transfer files from a Quark or 115 share link into your own cloud drive.',
'Uses CP-like path patterns: the last segment of source_pattern can contain wildcards.',
'',
'source_pattern rules:',
' / → all files in root of the share',
' /Season 1 → all files in "Season 1" folder',
' /Season 1/*.mp4 → only .mp4 files in "Season 1"',
' /Season 1/S01E01* → files starting with "S01E01" in "Season 1"',
' /folder/subfolder/*.mkv → .mkv files in a nested folder',
'',
'target_path is a path in YOUR drive (not the share). Examples: "/3670", "/媒体库/电视剧"',
'',
'Workflow: search → view → transfer',
' 1. search("流浪地球", cloud_types=["quark"]) to find share links',
' 2. view(share_link, "*.mp4") to see what files are available',
' 3. transfer(share_link, "/Season 1/*.mp4", "/3670") to save them',
'',
'Note: 115 transfers may have a delay before files appear in the target folder.',
].join('\n'),
{
share_link: z.string().describe('Full share link URL from Quark or 115'),
source_pattern: z.string().describe(
'Path pattern inside the share. "/" = all files. The last segment supports wildcards: "/Season 1/*.mp4"'
),
target_path: z.string().describe(
'Destination path in YOUR cloud drive, e.g. "/3670", "/媒体库/电视剧". Path is resolved internally.'
),
},
async ({ share_link, source_pattern, target_path }) => {
try {
const result = await client.transferRecursive(share_link, source_pattern, target_path);
return { content: [{ type: 'text', text: result }] };
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: offline_download ──
server.tool(
'offline_download',
[
'Add magnet link download tasks to 115 cloud drive.',
'115 will download the files server-side — no local bandwidth needed.',
'',
'After adding the task, check progress in the 115 app "云下载" page.',
'Downloaded files appear in the target_path directory.',
'',
'Typical workflow:',
' 1. search("电影名", cloud_types=["magnet"]) to find magnet links',
' 2. offline_download(magnet_links=[...], target_path="/媒体库/云下载电影")',
'',
'Note: 115 has offline download quota limits. Check 115 app for current limits.',
].join('\n'),
{
magnet_links: z.array(z.string()).describe(
'Array of magnet links, e.g. ["magnet:?xt=urn:btih:abc123...", ...]'
),
target_path: z.string().default('/').describe(
'Target directory path in your 115 drive, e.g. "/媒体库/云下载电影"'
),
},
async ({ magnet_links, target_path }) => {
try {
const result = await downloader.download(magnet_links, target_path);
return { content: [{ type: 'text', text: result }] };
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: search ──
server.tool(
'search',
[
'Search for movies, TV shows and resources across 12+ cloud storage platforms.',
'Returns share links (and optionally magnet links) grouped by cloud type.',
'',
'Results include: title, share URL, password (if any), date, and source.',
'',
'cloud_types filter:',
' quark 夸克网盘 baidu 百度网盘 aliyun 阿里云盘',
' 115 115网盘 pikpak PikPak xunlei 迅雷网盘',
' tianyi 天翼云盘 uc UC网盘 123 123网盘',
' magnet 磁力链接 ed2k eD2K链接 mobile 移动云盘',
'',
'Examples:',
' search(query="肖申克的救赎")',
' search(query="权力的游戏", cloud_types=["quark", "115"])',
' search(query="电影", cloud_types=["magnet"])',
' search(query="电视剧", include=["合集"], exclude=["预告", "花絮"])',
].join('\n'),
{
query: z.string().describe('Search keyword — movie name, TV show name, or resource title'),
cloud_types: z.array(z.string()).optional().describe(
'Filter results to specific cloud platforms, e.g. ["quark", "magnet"]. Omit to search all.'
),
source: z.enum(['all', 'tg', 'plugin']).default('all').describe(
'"all" = all sources, "tg" = Telegram channels only, "plugin" = search plugins only'
),
include: z.array(z.string()).optional().describe(
'Only show results whose title contains ALL of these keywords, e.g. ["合集", "全集"]'
),
exclude: z.array(z.string()).optional().describe(
'Hide results whose title contains any of these keywords, e.g. ["预告", "花絮"]'
),
refresh: z.boolean().default(false).describe('Set true to bypass cache and fetch fresh results'),
},
async ({ query, cloud_types, source, include, exclude, refresh }) => {
if (!pansou) {
return { content: [{ type: 'text', text: 'Error: PANSOU_URL environment variable is not set' }], isError: true };
}
try {
const result = await pansou.search({ query, cloudTypes: cloud_types, source, include, exclude, refresh });
if (result.total === 0) {
return { content: [{ type: 'text', text: `No results found for "${query}"` }] };
}
const lines: string[] = [`Found ${result.total} results for "${query}":`, ''];
for (const [type, items] of Object.entries(result.merged_by_type)) {
lines.push(`=== ${type} (${items.length}) ===`);
for (const item of items) {
lines.push(` ${item.note}`);
lines.push(` Link: ${item.url}`);
if (item.password) lines.push(` Password: ${item.password}`);
lines.push(` Date: ${item.datetime?.split('T')[0] || 'N/A'} | Source: ${item.source}`);
}
lines.push('');
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: health ──
server.tool(
'health',
[
'Check connectivity and validity of all configured services:',
' - Quark cookie: attempts a lightweight API call to list Quark root',
' - 115 cookie: attempts a lightweight API call to list 115 root',
' - PanSou API: checks /api/health and lists available search plugins',
'',
'Use this to diagnose which services are working and which need attention.',
'Each check runs independently — partial failures are reported, not fatal.',
].join('\n'),
{},
async () => {
const lines: string[] = ['=== Health Check ===', ''];
// Check Quark
if (config.quarkCookie) {
const quark = await client.checkQuarkCookie();
lines.push(quark.ok ? `✅ Quark: ${quark.message}` : `❌ Quark: ${quark.message}`);
} else {
lines.push('⏭️ Quark: not configured (NETDISK_QUARK_COOKIE not set)');
}
// Check 115
if (config.cookie115) {
const c115 = await client.check115Cookie();
lines.push(c115.ok ? `✅ 115: ${c115.message}` : `❌ 115: ${c115.message}`);
} else {
lines.push('⏭️ 115: not configured (NETDISK_115_COOKIE not set)');
}
// Check PanSou
if (pansou) {
try {
const data = await pansou.health();
lines.push(`✅ PanSou: status ${data.status}`);
if (data.plugins?.length) {
lines.push(` Plugins (${data.plugins.length}): ${data.plugins.join(', ')}`);
}
} catch (err: any) {
lines.push(`❌ PanSou: ${err.message}`);
}
} else {
lines.push('⏭️ PanSou: not configured (PANSOU_URL not set)');
}
const hasError = lines.some(l => l.startsWith('❌'));
return { content: [{ type: 'text', text: lines.join('\n') }], isError: hasError };
}
);
// ── Start ──
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('netdisk-mcp-server started');
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
+149
View File
@@ -0,0 +1,149 @@
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
import https from 'https';
import { Config } from './config.js';
import { NetdiskClient } from './client.js';
const RSS2CLOUD_VERSION = 'v0.2.3';
const RSS2CLOUD_BIN_DIR = '/root/netdisk-mcp-server/bin';
const RSS2CLOUD_BIN = path.join(RSS2CLOUD_BIN_DIR, 'rss2cloud');
function getDownloadURL(): { url: string; ext: string } {
const base = `https://github.com/zhifengle/rss2cloud/releases/download/${RSS2CLOUD_VERSION}`;
const platform = os.platform();
const arch = os.arch();
if (platform === 'linux' && arch === 'x64') {
return { url: `${base}/rss2cloud-${RSS2CLOUD_VERSION}-linux-amd64-musl.tar.gz`, ext: 'tar.gz' };
}
if (platform === 'darwin' && arch === 'arm64') {
return { url: `${base}/rss2cloud-${RSS2CLOUD_VERSION}-darwin-arm64.tar.gz`, ext: 'tar.gz' };
}
if (platform === 'win32' && arch === 'x64') {
return { url: `${base}/rss2cloud-${RSS2CLOUD_VERSION}-windows-amd64.zip`, ext: 'zip' };
}
throw new Error(`No rss2cloud binary for ${platform}/${arch}. Supported: linux-x64, darwin-arm64, win32-x64`);
}
function downloadToBuffer(url: string, maxRedirects = 5): Promise<Buffer> {
return new Promise((resolve, reject) => {
if (maxRedirects <= 0) return reject(new Error('Too many redirects'));
https.get(url, { headers: { 'User-Agent': 'netdisk-mcp-server' } }, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode || 0) && res.headers.location) {
return downloadToBuffer(res.headers.location, maxRedirects - 1).then(resolve, reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
}
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
res.on('end', () => resolve(Buffer.concat(chunks)));
res.on('error', reject);
}).on('error', reject);
});
}
async function ensureRss2cloud(): Promise<void> {
if (fs.existsSync(RSS2CLOUD_BIN)) {
try { fs.accessSync(RSS2CLOUD_BIN, fs.constants.X_OK); } catch {
fs.chmodSync(RSS2CLOUD_BIN, '755');
}
console.error(`[netdisk] rss2cloud found at ${RSS2CLOUD_BIN}`);
return;
}
fs.mkdirSync(RSS2CLOUD_BIN_DIR, { recursive: true });
const { url, ext } = getDownloadURL();
const tmpFile = path.join(RSS2CLOUD_BIN_DIR, `rss2cloud-${RSS2CLOUD_VERSION}.${ext}`);
console.error(`[netdisk] rss2cloud not found, downloading ${RSS2CLOUD_VERSION}...`);
console.error(`[netdisk] Download: ${url}`);
const data = await downloadToBuffer(url);
fs.writeFileSync(tmpFile, data);
console.error(`[netdisk] Downloaded ${(data.byteLength / 1024 / 1024).toFixed(1)} MB, extracting...`);
if (ext === 'zip') {
execSync(`powershell -Command "Expand-Archive -Path '${tmpFile}' -DestinationPath '${RSS2CLOUD_BIN_DIR}' -Force"`, { timeout: 15000 });
} else {
execSync(`tar -xzf "${tmpFile}" -C "${RSS2CLOUD_BIN_DIR}"`, { timeout: 15000 });
}
// tarball may nest binary in a subdirectory
if (!fs.existsSync(RSS2CLOUD_BIN)) {
const found = execSync(
`find "${RSS2CLOUD_BIN_DIR}" -maxdepth 3 -name rss2cloud -type f ! -name "*.tar.gz" ! -name "*.zip" 2>/dev/null | head -1`,
{ encoding: 'utf8' }
).trim();
if (found) fs.copyFileSync(found, RSS2CLOUD_BIN);
}
if (fs.existsSync(RSS2CLOUD_BIN)) {
fs.chmodSync(RSS2CLOUD_BIN, '755');
}
try { fs.unlinkSync(tmpFile); } catch {}
console.error(`[netdisk] rss2cloud ${RSS2CLOUD_VERSION} installed → ${RSS2CLOUD_BIN}`);
}
export class OfflineDownloader {
private client: NetdiskClient;
private config: Config;
constructor(config: Config) {
this.config = config;
this.client = new NetdiskClient(config);
}
async download(magnetLinks: string[], targetPath: string): Promise<string> {
if (!this.config.cookie115) {
throw new Error('115 cookie is required for offline download. Set NETDISK_115_COOKIE.');
}
await ensureRss2cloud();
const targetFolderId = await this.client.resolve115PathToCID(targetPath);
const workDir = path.dirname(RSS2CLOUD_BIN);
const cookieFile = path.join(workDir, '.cookies');
const magnetFile = path.join(workDir, `magnets-${Date.now()}.txt`);
try {
fs.writeFileSync(cookieFile, this.config.cookie115);
fs.writeFileSync(magnetFile, magnetLinks.join('\n'));
const cmd = `${RSS2CLOUD_BIN} magnet --text ${magnetFile} --cid ${targetFolderId}`;
console.error(`[netdisk] Running: ${cmd}`);
const result = execSync(cmd, {
cwd: workDir,
encoding: 'utf8',
timeout: 30000,
});
const lines = [
'Offline download task added successfully',
`Tasks: ${magnetLinks.length}`,
`Target: ${targetPath} (CID: ${targetFolderId})`,
'',
'Links:',
...magnetLinks.map((l, i) => ` ${i + 1}. ${l.length > 80 ? l.substring(0, 80) + '...' : l}`),
'',
'Check progress in 115 cloud drive "云下载" page.',
];
if (result.trim()) {
lines.push('', `rss2cloud output: ${result.trim()}`);
}
return lines.join('\n');
} finally {
try { fs.unlinkSync(magnetFile); } catch {}
try { fs.unlinkSync(cookieFile); } catch {}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import axios from 'axios';
export interface SearchResult {
url: string;
password?: string;
note: string;
datetime: string;
source: string;
}
export interface SearchResponse {
total: number;
merged_by_type: Record<string, SearchResult[]>;
results?: any[];
}
export class PansouClient {
private baseUrl: string;
constructor(baseUrl: string) {
if (!baseUrl) throw new Error('PANSOU_URL is required');
this.baseUrl = baseUrl.replace(/\/+$/, '');
}
async health(): Promise<any> {
const { data } = await axios.get(`${this.baseUrl}/api/health`);
return data;
}
async search(opts: {
query: string;
resultType?: string;
source?: string;
refresh?: boolean;
cloudTypes?: string[];
plugins?: string[];
channels?: string[];
concurrency?: number;
include?: string[];
exclude?: string[];
}): Promise<SearchResponse> {
const params: Record<string, string> = {
kw: opts.query,
res: opts.resultType || 'merge',
src: opts.source || 'all',
};
if (opts.refresh) params.refresh = 'true';
if (opts.cloudTypes?.length) params.cloud_types = opts.cloudTypes.join(',');
if (opts.plugins?.length) params.plugins = opts.plugins.join(',');
if (opts.channels?.length) params.channels = opts.channels.join(',');
if (opts.concurrency) params.conc = String(opts.concurrency);
if (opts.include?.length || opts.exclude?.length) {
const filter: Record<string, string[]> = {};
if (opts.include?.length) filter.include = opts.include;
if (opts.exclude?.length) filter.exclude = opts.exclude;
params.filter = JSON.stringify(filter);
}
const { data } = await axios.get(`${this.baseUrl}/api/search`, { params });
if (data.error) throw new Error(data.error);
return {
total: data.data?.total ?? 0,
merged_by_type: data.data?.merged_by_type ?? {},
results: data.results,
};
}
}