import Parser from 'rss-parser';
import fetch from 'node-fetch';
import { FeedResponse, FeedInfo, FeedItem, ErrorResponse } from '../types/index.js';
import { validateUrl, sanitizeString, isDateInRange } from './validation.js';
export class RssParser {
private parser: Parser;
private timeout: number;
private userAgent: string;
constructor(timeout: number = 10000, userAgent: string = 'RSS-Feed-MCP-Server/1.0.0') {
this.parser = new Parser({
customFields: {
item: ['content:encoded', 'content']
}
});
this.timeout = timeout;
this.userAgent = userAgent;
}
async fetchFeed(url: string, limit: number = 10, includeContent: boolean = false, startDate?: string, endDate?: string): Promise<FeedResponse | ErrorResponse> {
try {
if (!validateUrl(url)) {
return {
error: true,
code: 'VALIDATION_ERROR',
message: '無効なURLです'
};
}
const response = await fetch(url, {
headers: {
'User-Agent': this.userAgent
},
timeout: this.timeout
});
if (!response.ok) {
return {
error: true,
code: 'NETWORK_ERROR',
message: `フィードの取得に失敗しました: ${response.status} ${response.statusText}`
};
}
const feedText = await response.text();
if (feedText.length > 10 * 1024 * 1024) { // 10MB制限
return {
error: true,
code: 'VALIDATION_ERROR',
message: 'フィードサイズが大きすぎます(10MB制限)'
};
}
const feed = await this.parser.parseString(feedText);
const feedInfo: FeedInfo = {
title: sanitizeString(feed.title || ''),
description: sanitizeString(feed.description || ''),
link: feed.link || '',
lastBuildDate: feed.lastBuildDate || new Date().toISOString(),
language: feed.language
};
const items: FeedItem[] = (feed.items || [])
.filter(item => {
const itemDate = item.pubDate || item.isoDate || new Date().toISOString();
return isDateInRange(itemDate, startDate, endDate);
})
.slice(0, limit)
.map(item => {
const feedItem: FeedItem = {
title: sanitizeString(item.title || ''),
link: item.link || '',
description: sanitizeString(item.contentSnippet || item.summary || ''),
pubDate: item.pubDate || item.isoDate || new Date().toISOString(),
author: item.creator || item.author,
categories: item.categories || []
};
if (includeContent) {
const content = item['content:encoded'] || item.content || item.contentSnippet;
if (content) {
feedItem.content = sanitizeString(content);
}
}
return feedItem;
});
return {
feedInfo,
items
};
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('timeout')) {
return {
error: true,
code: 'NETWORK_ERROR',
message: 'フィード取得がタイムアウトしました'
};
}
if (error.message.includes('parse') || error.message.includes('XML')) {
return {
error: true,
code: 'PARSE_ERROR',
message: 'RSSフィードの解析に失敗しました'
};
}
}
return {
error: true,
code: 'NETWORK_ERROR',
message: `フィード取得エラー: ${error instanceof Error ? error.message : 'Unknown error'}`,
details: error
};
}
}
}