/**
* Hacker News API client
*/
import {
HNItem,
HNUser,
HNSearchResponse,
HNStoryType,
ProcessedStory,
ProcessedComment,
} from '../types/hn.types.js';
import { RateLimiter } from '../core/rate-limiter.js';
import { CacheManager } from '../core/cache.js';
export interface HNAPIOptions {
rateLimiter: RateLimiter;
cacheManager: CacheManager;
timeout?: number;
}
export class HackerNewsAPI {
private rateLimiter: RateLimiter;
private cache: CacheManager;
private timeout: number;
private baseUrl = 'https://hacker-news.firebaseio.com/v0';
private searchUrl = 'https://hn.algolia.com/api/v1';
constructor(options: HNAPIOptions) {
this.rateLimiter = options.rateLimiter;
this.cache = options.cacheManager;
this.timeout = options.timeout ?? 5000;
}
/**
* Get stories by type
*/
async getStories(
type: HNStoryType,
limit: number = 30
): Promise<ProcessedStory[]> {
const cacheKey = CacheManager.createKey('stories', type, limit);
const cached = this.cache.get<ProcessedStory[]>(cacheKey);
if (cached) return cached;
await this.rateLimiter.acquire();
const endpoint = `${this.baseUrl}/${type}stories.json`;
const storyIds = await this.fetch<number[]>(endpoint);
const limitedIds = storyIds.slice(0, limit);
const stories = await Promise.all(
limitedIds.map((id) => this.getItem(id))
);
const processedStories = stories
.filter((item): item is HNItem => item !== null && !item.deleted && !item.dead)
.map((item) => this.processStory(item));
this.cache.set(cacheKey, processedStories);
return processedStories;
}
/**
* Get a single item (story or comment)
*/
async getItem(id: number): Promise<HNItem | null> {
const cacheKey = CacheManager.createKey('item', id);
const cached = this.cache.get<HNItem>(cacheKey);
if (cached) return cached;
await this.rateLimiter.acquire();
const endpoint = `${this.baseUrl}/item/${id}.json`;
const item = await this.fetch<HNItem>(endpoint);
if (item) {
this.cache.set(cacheKey, item);
}
return item;
}
/**
* Get story with comments
*/
async getStoryWithComments(
id: number,
maxComments: number = 10,
maxDepth: number = 3
): Promise<{ story: ProcessedStory; comments: ProcessedComment[] }> {
const cacheKey = CacheManager.createKey('story-comments', id, maxComments, maxDepth);
const cached = this.cache.get<{ story: ProcessedStory; comments: ProcessedComment[] }>(cacheKey);
if (cached) return cached;
const item = await this.getItem(id);
if (!item || item.type !== 'story') {
throw new Error(`Item ${id} is not a story or not found`);
}
const story = this.processStory(item);
const comments = await this.fetchComments(item.kids || [], maxComments, maxDepth, 0);
const result = { story, comments };
this.cache.set(cacheKey, result);
return result;
}
/**
* Search Hacker News
*/
async search(
query: string,
options: {
tags?: string;
numericFilters?: string;
page?: number;
hitsPerPage?: number;
} = {}
): Promise<HNSearchResponse> {
const cacheKey = CacheManager.createKey('search', query, options);
const cached = this.cache.get<HNSearchResponse>(cacheKey);
if (cached) return cached;
await this.rateLimiter.acquire();
const params = new URLSearchParams({
query,
page: String(options.page || 0),
hitsPerPage: String(options.hitsPerPage || 30),
});
if (options.tags) {
params.append('tags', options.tags);
}
if (options.numericFilters) {
params.append('numericFilters', options.numericFilters);
}
const endpoint = `${this.searchUrl}/search?${params.toString()}`;
const response = await this.fetch<HNSearchResponse>(endpoint);
this.cache.set(cacheKey, response);
return response;
}
/**
* Get user info
*/
async getUser(username: string): Promise<HNUser | null> {
const cacheKey = CacheManager.createKey('user', username);
const cached = this.cache.get<HNUser>(cacheKey);
if (cached) return cached;
await this.rateLimiter.acquire();
const endpoint = `${this.baseUrl}/user/${username}.json`;
const user = await this.fetch<HNUser>(endpoint);
if (user) {
this.cache.set(cacheKey, user);
}
return user;
}
/**
* Get user's recent items
*/
async getUserItems(
username: string,
limit: number = 30
): Promise<ProcessedStory[]> {
const user = await this.getUser(username);
if (!user || !user.submitted) {
return [];
}
const itemIds = user.submitted.slice(0, limit);
const items = await Promise.all(itemIds.map((id) => this.getItem(id)));
return items
.filter((item): item is HNItem =>
item !== null &&
!item.deleted &&
!item.dead &&
(item.type === 'story' || item.type === 'comment')
)
.map((item) => this.processStory(item));
}
/**
* Fetch comments recursively
*/
private async fetchComments(
ids: number[],
maxComments: number,
maxDepth: number,
currentDepth: number
): Promise<ProcessedComment[]> {
if (currentDepth >= maxDepth || ids.length === 0) {
return [];
}
const limitedIds = ids.slice(0, maxComments);
const items = await Promise.all(limitedIds.map((id) => this.getItem(id)));
const comments: ProcessedComment[] = [];
for (const item of items) {
if (!item || item.deleted || item.dead || item.type !== 'comment') {
continue;
}
const comment = this.processComment(item, currentDepth);
if (item.kids && item.kids.length > 0) {
comment.children = await this.fetchComments(
item.kids,
5,
maxDepth,
currentDepth + 1
);
}
comments.push(comment);
}
return comments;
}
/**
* Process story item
*/
private processStory(item: HNItem): ProcessedStory {
return {
id: item.id,
title: item.title || '',
author: item.by || 'unknown',
score: item.score || 0,
time: item.time,
time_ago: this.timeAgo(item.time),
url: item.url,
text: item.text,
num_comments: item.descendants || 0,
hn_url: `https://news.ycombinator.com/item?id=${item.id}`,
type: item.type,
};
}
/**
* Process comment item
*/
private processComment(item: HNItem, depth: number): ProcessedComment {
return {
id: item.id,
author: item.by || 'unknown',
text: item.text || '',
time: item.time,
time_ago: this.timeAgo(item.time),
parent_id: item.parent,
depth,
children: [],
};
}
/**
* Convert timestamp to time ago string
*/
private timeAgo(timestamp: number): string {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
return 'just now';
}
/**
* Fetch helper with timeout
*/
private async fetch<T>(url: string): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'HackerNews-MCP/1.0',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
}