/**
* HackerNews Algolia API client
*/
import { APIError, NotFoundError, RateLimitError } from "../errors/index.js";
import type { HackerNewsPost, HackerNewsUser, SearchResult } from "../types/index.js";
import { buildApiUrl } from "../utils/index.js";
/**
* Rate limit tracker
*/
class RateLimitTracker {
private requestCount = 0;
private windowStart = Date.now();
private readonly maxRequests = 10000;
private readonly windowMs = 60 * 60 * 1000; // 1 hour
track(): void {
const now = Date.now();
// Reset window if expired
if (now - this.windowStart >= this.windowMs) {
this.requestCount = 0;
this.windowStart = now;
}
// Check limit
if (this.requestCount >= this.maxRequests) {
const resetTime = this.windowStart + this.windowMs;
const retryAfter = Math.ceil((resetTime - now) / 1000);
throw new RateLimitError(
`Rate limit exceeded. Retry after ${retryAfter} seconds`,
retryAfter,
);
}
this.requestCount++;
}
getRemaining(): number {
const now = Date.now();
if (now - this.windowStart >= this.windowMs) {
return this.maxRequests;
}
return this.maxRequests - this.requestCount;
}
}
/**
* HackerNews API client
*/
export class HackerNewsAPIClient {
private rateLimiter = new RateLimitTracker();
private readonly baseUrl = "https://hn.algolia.com/api/v1";
/**
* Make HTTP GET request to HN API
*/
private async request<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
this.rateLimiter.track();
const url = buildApiUrl(endpoint, params);
try {
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
throw new NotFoundError(`Resource not found: ${endpoint}`);
}
if (response.status === 429) {
throw new RateLimitError("Rate limit exceeded");
}
throw new APIError(`API request failed: ${response.statusText}`, response.status);
}
const data = await response.json();
return data as T;
} catch (error) {
if (
error instanceof APIError ||
error instanceof NotFoundError ||
error instanceof RateLimitError
) {
throw error;
}
throw new APIError(
`Network error: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Search posts with filters
*/
async search(params: {
query?: string;
tags?: string;
numericFilters?: string;
page?: number;
hitsPerPage?: number;
}): Promise<SearchResult> {
const queryParams: Record<string, string> = {};
if (params.query) queryParams.query = params.query;
if (params.tags) queryParams.tags = params.tags;
if (params.numericFilters) queryParams.numericFilters = params.numericFilters;
if (params.page !== undefined) queryParams.page = params.page.toString();
if (params.hitsPerPage !== undefined) queryParams.hitsPerPage = params.hitsPerPage.toString();
return this.request<SearchResult>("search", queryParams);
}
/**
* Search posts by date (chronological)
*/
async searchByDate(params: {
query?: string;
tags?: string;
numericFilters?: string;
page?: number;
hitsPerPage?: number;
}): Promise<SearchResult> {
const queryParams: Record<string, string> = {};
if (params.query) queryParams.query = params.query;
if (params.tags) queryParams.tags = params.tags;
if (params.numericFilters) queryParams.numericFilters = params.numericFilters;
if (params.page !== undefined) queryParams.page = params.page.toString();
if (params.hitsPerPage !== undefined) queryParams.hitsPerPage = params.hitsPerPage.toString();
return this.request<SearchResult>("search_by_date", queryParams);
}
/**
* Get specific post by ID
*/
async getPost(postId: string): Promise<HackerNewsPost> {
return this.request<HackerNewsPost>(`items/${postId}`);
}
/**
* Get user profile by username
*/
async getUser(username: string): Promise<HackerNewsUser> {
return this.request<HackerNewsUser>(`users/${username}`);
}
/**
* Get remaining rate limit quota
*/
getRemainingQuota(): number {
return this.rateLimiter.getRemaining();
}
}
/**
* Singleton instance
*/
export const apiClient = new HackerNewsAPIClient();