/** * Content Fetcher Module * * Fetches content from RSS, Reddit, and news API sources with exponential backoff * retry logic on failures. Tracks consecutive errors per source and stores * fetched content items in the database. * * Requirements: 4.5, 4.7 * Validates: Property 13 - Content Item Storage * Validates: Property 15 - Fetch Error Retry with Backoff */ import { db, botContentSources, botContentItems } from '@/db'; import { eq, and } from 'drizzle-orm'; import { parseRSSFeed, FeedItem } from './rssParser'; import { ContentSource, ContentSourceType, getSourceById, BraveNewsConfig } from './contentSource'; import { decryptApiKey, deserializeEncryptedData } from './encryption'; // ============================================ // TYPES // ============================================ /** * Result of a content fetch operation. */ export interface FetchResult { success: boolean; sourceId: string; itemsFetched: number; itemsStored: number; error?: string; warnings: string[]; } /** * Content item to be stored in the database. */ export interface ContentItemInput { sourceId: string; externalId: string; title: string; content: string | null; url: string; url_overridden_by_dest?: string; publishedAt: Date; } /** * Stored content item from the database. */ export interface StoredContentItem { id: string; sourceId: string; externalId: string; title: string; content: string | null; url: string; publishedAt: Date; fetchedAt: Date; isProcessed: boolean; processedAt: Date | null; postId: string | null; interestScore: number | null; interestReason: string | null; } /** * Options for fetch operations. */ export interface FetchOptions { /** Maximum number of items to fetch (default: 50) */ maxItems?: number; /** Timeout for HTTP requests in milliseconds (default: 30000) */ timeout?: number; /** Whether to skip duplicate detection (default: false) */ skipDuplicateCheck?: boolean; } // ============================================ // CONSTANTS // ============================================ /** Default maximum items to fetch per source */ export const DEFAULT_MAX_ITEMS = 50; /** Default HTTP request timeout in milliseconds */ export const DEFAULT_TIMEOUT_MS = 30000; /** Base delay for exponential backoff in milliseconds */ export const BASE_BACKOFF_DELAY_MS = 1000; /** Maximum backoff delay in milliseconds (1 hour) */ export const MAX_BACKOFF_DELAY_MS = 3600000; /** Maximum consecutive errors before disabling source */ export const MAX_CONSECUTIVE_ERRORS = 10; /** Reddit API base URL */ const REDDIT_API_BASE = 'https://www.reddit.com'; /** User agent for HTTP requests */ const USER_AGENT = 'Synapsis Bot Content Fetcher/1.0'; // ============================================ // ERROR CLASSES // ============================================ /** * Base error class for content fetching operations. */ export class ContentFetchError extends Error { constructor( message: string, public code: string, public sourceId?: string, public retryable: boolean = true ) { super(message); this.name = 'ContentFetchError'; } } /** * Error thrown when a network request fails. */ export class NetworkError extends ContentFetchError { constructor(message: string, sourceId?: string) { super(message, 'NETWORK_ERROR', sourceId, true); this.name = 'NetworkError'; } } /** * Error thrown when content parsing fails. */ export class ParseError extends ContentFetchError { constructor(message: string, sourceId?: string) { super(message, 'PARSE_ERROR', sourceId, false); this.name = 'ParseError'; } } /** * Error thrown when source is not found. */ export class SourceNotFoundError extends ContentFetchError { constructor(sourceId: string) { super(`Content source not found: ${sourceId}`, 'SOURCE_NOT_FOUND', sourceId, false); this.name = 'SourceNotFoundError'; } } // ============================================ // EXPONENTIAL BACKOFF // ============================================ /** * Calculate the backoff delay based on consecutive errors. * Uses exponential backoff with jitter. * * @param consecutiveErrors - Number of consecutive errors * @returns Delay in milliseconds * * Validates: Requirements 4.7 */ export function calculateBackoffDelay(consecutiveErrors: number): number { if (consecutiveErrors <= 0) { return 0; } // Exponential backoff: base * 2^(errors - 1) const exponentialDelay = BASE_BACKOFF_DELAY_MS * Math.pow(2, consecutiveErrors - 1); // Cap at maximum delay const cappedDelay = Math.min(exponentialDelay, MAX_BACKOFF_DELAY_MS); // Add jitter (±10% of the delay) const jitter = cappedDelay * 0.1 * (Math.random() * 2 - 1); return Math.floor(cappedDelay + jitter); } /** * Check if a source should be retried based on its error state. * * @param source - The content source * @returns Whether the source should be retried */ export function shouldRetrySource(source: ContentSource): boolean { // Don't retry if source is inactive if (!source.isActive) { return false; } // Don't retry if max consecutive errors reached if (source.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { return false; } // Check if enough time has passed since last fetch if (source.lastFetchAt && source.consecutiveErrors > 0) { const backoffDelay = calculateBackoffDelay(source.consecutiveErrors); const timeSinceLastFetch = Date.now() - source.lastFetchAt.getTime(); if (timeSinceLastFetch < backoffDelay) { return false; } } return true; } /** * Check if a source is due for fetching based on its interval. * * @param source - The content source * @returns Whether the source is due for fetching */ export function isSourceDueForFetch(source: ContentSource): boolean { // Only check if source is active // Content is now fetched on-demand when posting, not on a schedule return source.isActive; } // ============================================ // HTTP FETCHING // ============================================ /** * Fetch content from a URL with timeout. * * @param url - The URL to fetch * @param options - Fetch options * @returns The response text * @throws NetworkError on failure */ async function fetchUrl( url: string, options: { timeout?: number; headers?: Record } = {} ): Promise { const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/rss+xml, application/xml, application/atom+xml, text/xml, application/json, */*', ...options.headers, }, }); if (!response.ok) { throw new NetworkError( `HTTP ${response.status}: ${response.statusText}` ); } return await response.text(); } catch (error) { if (error instanceof NetworkError) { throw error; } if (error instanceof Error) { if (error.name === 'AbortError') { throw new NetworkError(`Request timeout after ${timeout}ms`); } throw new NetworkError(error.message); } throw new NetworkError('Unknown network error'); } finally { clearTimeout(timeoutId); } } // ============================================ // RSS FETCHING // ============================================ /** * Fetch and parse an RSS feed. * * @param url - The RSS feed URL * @param options - Fetch options * @returns Array of feed items * @throws NetworkError or ParseError on failure * * Validates: Requirements 4.2 */ export async function fetchRSSFeed( url: string, options: FetchOptions = {} ): Promise { const xml = await fetchUrl(url, { timeout: options.timeout }); const result = parseRSSFeed(xml); if (!result.success || !result.feed) { throw new ParseError(result.error || 'Failed to parse RSS feed'); } const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS; return result.feed.items.slice(0, maxItems); } // ============================================ // REDDIT FETCHING // ============================================ /** * Fetch posts from a Reddit subreddit. * Uses the RSS feed which is more reliable than the JSON API. * * @param subreddit - The subreddit name * @param options - Fetch options * @returns Array of feed items * @throws NetworkError or ParseError on failure * * Validates: Requirements 4.3 */ export async function fetchRedditPosts( subreddit: string, options: FetchOptions = {} ): Promise { const requestedMaxItems = options.maxItems ?? DEFAULT_MAX_ITEMS; const fetchLimit = Math.min(Math.max(requestedMaxItems + 10, requestedMaxItems), 100); const url = new URL(`${REDDIT_API_BASE}/r/${subreddit}/hot.json`); url.searchParams.set('raw_json', '1'); url.searchParams.set('limit', String(fetchLimit)); const responseText = await fetchUrl(url.toString(), { timeout: options.timeout, headers: { 'Accept': 'application/json', }, }); let response: RedditListingResponse; try { response = JSON.parse(responseText) as RedditListingResponse; } catch { throw new ParseError('Failed to parse Reddit JSON response'); } const posts = response.data?.children ?.map((child) => child.data) .filter((post): post is RedditListingChildData => Boolean(post)) || []; const filteredPosts = posts.filter((post) => !shouldExcludeRedditPost(post)); return filteredPosts.slice(0, requestedMaxItems).map((post): FeedItem => ({ id: post.name || post.id, title: post.title, content: post.selftext || '', url: `${REDDIT_API_BASE}${post.permalink}`, publishedAt: new Date(post.created_utc * 1000), })); } // ============================================ // NEWS API FETCHING // ============================================ /** * News API article structure. */ interface NewsApiArticle { source?: { id?: string; name?: string }; title: string; description?: string; url: string; publishedAt: string; content?: string; } /** * News API response structure. */ interface NewsApiResponse { status: string; articles?: NewsApiArticle[]; error?: string; } /** * Brave News API result structure. */ interface BraveNewsResult { title: string; url: string; description?: string; age?: string; page_age?: string; meta_url?: { hostname?: string; }; } /** * Brave News API response structure. */ interface BraveNewsResponse { type: string; results?: BraveNewsResult[]; } interface RedditListingChildData { id: string; name?: string; title: string; selftext?: string; permalink: string; url: string; url_overridden_by_dest?: string; created_utc: number; stickied?: boolean; pinned?: boolean; } interface RedditListingResponse { data?: { children?: Array<{ data?: RedditListingChildData; }>; }; } export function shouldExcludeRedditPost(post: RedditListingChildData): boolean { return Boolean(post.stickied || post.pinned); } /** * Fetch articles from a news API. * * @param url - The news API URL * @param apiKey - The API key for authentication * @param options - Fetch options * @returns Array of feed items * @throws NetworkError or ParseError on failure * * Validates: Requirements 4.4 */ export async function fetchNewsApi( url: string, apiKey: string, options: FetchOptions = {} ): Promise { // Append API key to URL if not already present const urlObj = new URL(url); if (!urlObj.searchParams.has('apiKey') && !urlObj.searchParams.has('api_key')) { urlObj.searchParams.set('apiKey', apiKey); } const responseText = await fetchUrl(urlObj.toString(), { timeout: options.timeout, headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, }); let response: NewsApiResponse; try { response = JSON.parse(responseText); } catch { throw new ParseError('Failed to parse News API JSON response'); } if (response.status !== 'ok' || !response.articles) { throw new ParseError(response.error || 'Invalid News API response'); } const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS; return response.articles.slice(0, maxItems).map((article, index): FeedItem => ({ id: `${article.source?.id || 'news'}-${index}-${Date.now()}`, title: article.title, content: article.description || article.content || '', url: article.url, publishedAt: new Date(article.publishedAt), })); } /** * Fetch articles from Brave News Search API. * * @param config - The Brave News configuration * @param apiKey - The API key for authentication * @param options - Fetch options * @returns Array of feed items * @throws NetworkError or ParseError on failure */ export async function fetchBraveNews( config: BraveNewsConfig, apiKey: string, options: FetchOptions = {} ): Promise { // Build the URL from config const url = new URL('https://api.search.brave.com/res/v1/news/search'); url.searchParams.set('q', config.query); if (config.freshness) { url.searchParams.set('freshness', config.freshness); } if (config.country) { url.searchParams.set('country', config.country); } if (config.searchLang) { url.searchParams.set('search_lang', config.searchLang); } const count = Math.min(config.count || 20, 50); url.searchParams.set('count', String(count)); const responseText = await fetchUrl(url.toString(), { timeout: options.timeout, headers: { 'Accept': 'application/json', 'X-Subscription-Token': apiKey, }, }); let response: BraveNewsResponse; try { response = JSON.parse(responseText); } catch { throw new ParseError('Failed to parse Brave News API JSON response'); } if (!response.results || response.results.length === 0) { return []; } const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS; return response.results.slice(0, maxItems).map((result, index): FeedItem => ({ id: `brave-${config.query.replace(/\s+/g, '-')}-${index}-${Date.now()}`, title: result.title, content: result.description || '', url: result.url, publishedAt: new Date(), // Brave doesn't always provide exact dates })); } // ============================================ // CONTENT STORAGE // ============================================ /** * Check if a content item already exists in the database. * * @param sourceId - The source ID * @param externalId - The external ID from the source * @returns True if the item exists */ export async function contentItemExists( sourceId: string, externalId: string ): Promise { const existing = await db.query.botContentItems.findFirst({ where: { AND: [{ sourceId: sourceId }, { externalId: externalId }] }, columns: { id: true }, }); return existing !== undefined; } /** * Store a content item in the database. * * @param item - The content item to store * @returns The stored content item * * Validates: Requirements 4.5 */ export async function storeContentItem( item: ContentItemInput ): Promise { const [stored] = await db .insert(botContentItems) .values({ sourceId: item.sourceId, externalId: item.externalId, title: item.title, content: item.content, url: item.url, publishedAt: item.publishedAt, isProcessed: false, }) .returning(); return { id: stored.id, sourceId: stored.sourceId, externalId: stored.externalId, title: stored.title, content: stored.content, url: stored.url, publishedAt: stored.publishedAt, fetchedAt: stored.fetchedAt, isProcessed: stored.isProcessed, processedAt: stored.processedAt, postId: stored.postId, interestScore: stored.interestScore, interestReason: stored.interestReason, }; } /** * Store multiple content items, skipping duplicates. * * @param items - The content items to store * @param skipDuplicateCheck - Whether to skip duplicate checking * @returns Number of items stored * * Validates: Requirements 4.5 */ export async function storeContentItems( items: ContentItemInput[], skipDuplicateCheck: boolean = false ): Promise { let storedCount = 0; for (const item of items) { // Check for duplicates unless skipped if (!skipDuplicateCheck) { const exists = await contentItemExists(item.sourceId, item.externalId); if (exists) { continue; } } try { await storeContentItem(item); storedCount++; } catch (error) { // Skip items that fail to store (e.g., constraint violations) console.error(`Failed to store content item: ${error}`); } } return storedCount; } // ============================================ // SOURCE STATE MANAGEMENT // ============================================ /** * Update source state after a successful fetch. * * @param sourceId - The source ID */ export async function recordFetchSuccess(sourceId: string): Promise { await db .update(botContentSources) .set({ lastFetchAt: new Date(), lastError: null, consecutiveErrors: 0, updatedAt: new Date(), }) .where(eq(botContentSources.id, sourceId)); } /** * Update source state after a failed fetch. * * @param sourceId - The source ID * @param error - The error message * * Validates: Requirements 4.7 */ export async function recordFetchError( sourceId: string, error: string ): Promise { // Get current consecutive errors const source = await db.query.botContentSources.findFirst({ where: { id: sourceId }, columns: { consecutiveErrors: true }, }); const currentErrors = source?.consecutiveErrors ?? 0; const newErrors = currentErrors + 1; // Disable source if max errors reached const shouldDisable = newErrors >= MAX_CONSECUTIVE_ERRORS; await db .update(botContentSources) .set({ lastFetchAt: new Date(), lastError: error, consecutiveErrors: newErrors, isActive: shouldDisable ? false : undefined, updatedAt: new Date(), }) .where(eq(botContentSources.id, sourceId)); } // ============================================ // MAIN FETCH FUNCTION // ============================================ /** * Fetch content from a source and store new items. * Implements exponential backoff on failures. * * @param sourceId - The ID of the content source * @param options - Fetch options * @returns Fetch result with statistics * * Validates: Requirements 4.5, 4.7 */ export async function fetchContent( sourceId: string, options: FetchOptions = {} ): Promise { const warnings: string[] = []; // Get the source const source = await getSourceById(sourceId); if (!source) { throw new SourceNotFoundError(sourceId); } // Check if source should be retried if (!shouldRetrySource(source)) { return { success: false, sourceId, itemsFetched: 0, itemsStored: 0, error: source.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS ? 'Source disabled due to too many consecutive errors' : 'Source is in backoff period', warnings, }; } try { let items: FeedItem[]; // Fetch based on source type switch (source.type) { case 'rss': case 'youtube': // YouTube channels/playlists use RSS feeds items = await fetchRSSFeed(source.url, options); break; case 'reddit': if (!source.subreddit) { throw new ParseError('Reddit source missing subreddit'); } items = await fetchRedditPosts(source.subreddit, options); break; case 'news_api': // Decrypt API key const dbSource = await db.query.botContentSources.findFirst({ where: { id: sourceId }, columns: { apiKeyEncrypted: true }, }); if (!dbSource?.apiKeyEncrypted) { throw new ParseError('News API source missing API key'); } const encryptedData = deserializeEncryptedData(dbSource.apiKeyEncrypted); const apiKey = decryptApiKey(encryptedData); items = await fetchNewsApi(source.url, apiKey, options); break; case 'brave_news': // Decrypt API key const braveDbSource = await db.query.botContentSources.findFirst({ where: { id: sourceId }, columns: { apiKeyEncrypted: true, sourceConfig: true }, }); if (!braveDbSource?.apiKeyEncrypted) { throw new ParseError('Brave News source missing API key'); } const braveEncryptedData = deserializeEncryptedData(braveDbSource.apiKeyEncrypted); const braveApiKey = decryptApiKey(braveEncryptedData); // Get config from sourceConfig or parse from URL let braveConfig: BraveNewsConfig; if (braveDbSource.sourceConfig) { braveConfig = JSON.parse(braveDbSource.sourceConfig) as BraveNewsConfig; } else { // Fallback: extract query from URL const urlObj = new URL(source.url); braveConfig = { query: urlObj.searchParams.get('q') || 'news', freshness: (urlObj.searchParams.get('freshness') as BraveNewsConfig['freshness']) || undefined, country: urlObj.searchParams.get('country') || undefined, searchLang: urlObj.searchParams.get('search_lang') || undefined, }; } items = await fetchBraveNews(braveConfig, braveApiKey, options); break; default: throw new ParseError(`Unsupported source type: ${source.type}`); } // Filter by keywords if configured if (source.keywords && source.keywords.length > 0) { const keywords = source.keywords.map(k => k.toLowerCase()); items = items.filter(item => { const text = `${item.title} ${item.content}`.toLowerCase(); return keywords.some(keyword => text.includes(keyword)); }); } // Convert to content items const contentItems: ContentItemInput[] = items.map(item => ({ sourceId, externalId: item.id, title: item.title, content: item.content, url: item.url, publishedAt: item.publishedAt, })); // Store items const storedCount = await storeContentItems( contentItems, options.skipDuplicateCheck ); // Record success await recordFetchSuccess(sourceId); return { success: true, sourceId, itemsFetched: items.length, itemsStored: storedCount, warnings, }; } catch (error) { // Record error const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await recordFetchError(sourceId, errorMessage); return { success: false, sourceId, itemsFetched: 0, itemsStored: 0, error: errorMessage, warnings, }; } } /** * Fetch content from a source with retry logic. * Retries on retryable errors with exponential backoff. * * @param sourceId - The ID of the content source * @param maxRetries - Maximum number of retries (default: 3) * @param options - Fetch options * @returns Fetch result * * Validates: Requirements 4.7 */ export async function fetchContentWithRetry( sourceId: string, maxRetries: number = 3, options: FetchOptions = {} ): Promise { let lastResult: FetchResult | null = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { // Wait for backoff delay on retries if (attempt > 0) { const delay = calculateBackoffDelay(attempt); await new Promise(resolve => setTimeout(resolve, delay)); } try { const result = await fetchContent(sourceId, options); if (result.success) { return result; } lastResult = result; // Don't retry if error is not retryable if (result.error?.includes('disabled') || result.error?.includes('backoff')) { return result; } } catch (error) { if (error instanceof ContentFetchError && !error.retryable) { throw error; } lastResult = { success: false, sourceId, itemsFetched: 0, itemsStored: 0, error: error instanceof Error ? error.message : 'Unknown error', warnings: [], }; } } return lastResult || { success: false, sourceId, itemsFetched: 0, itemsStored: 0, error: 'Max retries exceeded', warnings: [], }; } /** * Fetch content from all active sources for a bot. * * @param botId - The bot ID * @param options - Fetch options * @returns Array of fetch results */ export async function fetchAllSourcesForBot( botId: string, options: FetchOptions = {} ): Promise { const sources = await db.query.botContentSources.findMany({ where: { AND: [{ botId: botId }, { isActive: true }] }, }); const results: FetchResult[] = []; for (const source of sources) { // Check if source is due for fetching const sourceObj: ContentSource = { id: source.id, botId: source.botId, type: source.type as ContentSourceType, url: source.url, subreddit: source.subreddit, keywords: source.keywords ? JSON.parse(source.keywords) : null, sourceConfig: source.sourceConfig ? JSON.parse(source.sourceConfig) : null, isActive: source.isActive, lastFetchAt: source.lastFetchAt, lastError: source.lastError, consecutiveErrors: source.consecutiveErrors, createdAt: source.createdAt, updatedAt: source.updatedAt, }; if (!isSourceDueForFetch(sourceObj)) { continue; } const result = await fetchContentWithRetry(source.id, 3, options); results.push(result); } return results; } /** * Get unprocessed content items for a source. * * @param sourceId - The source ID * @param limit - Maximum number of items to return * @returns Array of unprocessed content items */ export async function getUnprocessedItems( sourceId: string, limit: number = 10 ): Promise { const items = await db.query.botContentItems.findMany({ where: { AND: [{ sourceId: sourceId }, { isProcessed: false }] }, orderBy: (items, { asc }) => [asc(items.publishedAt)], limit, }); return items.map(item => ({ id: item.id, sourceId: item.sourceId, externalId: item.externalId, title: item.title, content: item.content, url: item.url, publishedAt: item.publishedAt, fetchedAt: item.fetchedAt, isProcessed: item.isProcessed, processedAt: item.processedAt, postId: item.postId, interestScore: item.interestScore, interestReason: item.interestReason, })); } /** * Mark a content item as processed. * * @param itemId - The content item ID * @param postId - Optional post ID if a post was created */ export async function markItemProcessed( itemId: string, postId?: string ): Promise { await db .update(botContentItems) .set({ isProcessed: true, processedAt: new Date(), postId: postId || null, }) .where(eq(botContentItems.id, itemId)); }