Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/spicy-doors-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
67 changes: 66 additions & 1 deletion server/src/addie/services/feed-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,47 @@ const parser = new Parser({
},
});

/**
* Validate that content is actually RSS/Atom XML, not HTML
* Some sites disable their RSS feeds and redirect to HTML pages
*/
function validateRssContent(content: string, contentType: string): { valid: boolean; error?: string } {
// Check content type header first
const isXmlContentType =
contentType.includes('xml') || contentType.includes('rss') || contentType.includes('atom');

// Check for HTML doctype or opening tags (indicates redirect to HTML page)
const trimmed = content.trim().toLowerCase();
if (trimmed.startsWith('<!doctype html') || trimmed.startsWith('<html')) {
return {
valid: false,
error: 'Feed URL returned HTML instead of RSS/XML (feed may be disabled)',
};
}

// Check for valid XML/RSS markers (use trimmed which is lowercased for case-insensitive matching)
const hasXmlDeclaration = trimmed.startsWith('<?xml');
const hasRssTag = trimmed.includes('<rss');
const hasFeedTag = trimmed.includes('<feed'); // Atom feeds
const hasRdfTag = trimmed.includes('<rdf:'); // RDF feeds

if (!hasXmlDeclaration && !hasRssTag && !hasFeedTag && !hasRdfTag) {
// If content type says XML but content doesn't look like RSS
if (isXmlContentType) {
return {
valid: false,
error: 'Content type is XML but content does not appear to be RSS/Atom',
};
}
return {
valid: false,
error: 'Feed URL did not return valid RSS/Atom content',
};
}

return { valid: true };
}

interface FeedItem {
guid?: string;
link?: string;
Expand Down Expand Up @@ -78,7 +119,31 @@ async function fetchFeed(feed: IndustryFeed): Promise<RssArticleInput[]> {

logger.debug({ feedId: feed.id, name: feed.name, url: feed.feed_url }, 'Fetching RSS feed');

const parsed = await parser.parseURL(feed.feed_url);
// Pre-fetch content to validate it's actually RSS/XML before parsing
// This prevents cryptic XML parsing errors when sites return HTML
const response = await fetch(feed.feed_url, {
headers: {
'User-Agent': 'AddieBot/1.0 (AgenticAdvertising.org industry monitor)',
Accept: 'application/rss+xml, application/xml, text/xml',
},
signal: AbortSignal.timeout(30000),
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const contentType = response.headers.get('content-type') || '';
const content = await response.text();

// Validate content is RSS/XML, not HTML (e.g., from a redirect)
const validation = validateRssContent(content, contentType);
if (!validation.valid) {
throw new Error(validation.error);
}

// Parse the validated content
const parsed = await parser.parseString(content);

if (!parsed.items || parsed.items.length === 0) {
logger.warn({ feedId: feed.id }, 'Feed returned no items');
Expand Down
2 changes: 2 additions & 0 deletions server/src/db/industry-feeds-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ export interface RssPerspective {

/**
* Get all active feeds that need fetching
* Only returns feeds with a valid feed_url (excludes email-only feeds)
*/
export async function getFeedsToFetch(): Promise<IndustryFeed[]> {
const result = await query<IndustryFeed>(
`SELECT * FROM industry_feeds
WHERE is_active = true
AND feed_url IS NOT NULL
AND (last_fetched_at IS NULL
OR last_fetched_at < NOW() - (fetch_interval_minutes || ' minutes')::interval)
ORDER BY last_fetched_at ASC NULLS FIRST
Expand Down
19 changes: 19 additions & 0 deletions server/src/db/migrations/146_allow_null_feed_url.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Allow feed_url to be NULL for email-only feeds
-- Previously email-only feeds used 'email://slug' placeholder URLs,
-- but this is cleaner and matches how the code/UI expects to work

-- Drop the NOT NULL constraint on feed_url
ALTER TABLE industry_feeds ALTER COLUMN feed_url DROP NOT NULL;

-- Drop the UNIQUE constraint since NULL values should be allowed
-- (multiple email-only feeds would all have NULL feed_url)
ALTER TABLE industry_feeds DROP CONSTRAINT IF EXISTS industry_feeds_feed_url_key;

-- Add a partial unique constraint that only applies to non-null URLs
CREATE UNIQUE INDEX IF NOT EXISTS industry_feeds_feed_url_unique
ON industry_feeds (feed_url) WHERE feed_url IS NOT NULL;

-- Migrate existing email:// placeholder URLs to NULL
UPDATE industry_feeds
SET feed_url = NULL
WHERE feed_url LIKE 'email://%';