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:
@@ -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`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user