diff --git a/.gitignore b/.gitignore index 5a83ae0..52a5f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -76,5 +76,5 @@ docs-site/ # Local agent logs / scratch .agent-logs/ +.codevetter/ *.agent.log - diff --git a/src/lib/__tests__/rss-parser-performance.test.ts b/src/lib/__tests__/rss-parser-performance.test.ts new file mode 100644 index 0000000..cbbc5b4 --- /dev/null +++ b/src/lib/__tests__/rss-parser-performance.test.ts @@ -0,0 +1,64 @@ +import { createHash } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; + +import { expect, test } from 'vitest'; + +import { parseFeed } from '../rss-parser'; + +const SIZES = [10, 100, 200]; +const ITERATIONS = 25; +const EXPECTED_HASHES = new Map([ + [10, 'e2c04d0f621e652d5cf5e180862d5ef0d3e420414fc6dd2766aa638cdd255d02'], + [100, 'bcc8798f918ca05af112d4a6c74f1defa2ae4073c4675bdcd65fb84dc91c2da9'], + [200, 'bb8a0c80775d61191a5ee3a23ea651a5b32b777250478821040a84cbce6148c0'], +]); + +test('RSS parsing scales across supported feed sizes', { timeout: 30_000 }, () => { + const metrics: string[] = []; + + for (const size of SIZES) { + const xml = buildFeed(size); + const expected = JSON.stringify(parseFeed(xml)); + const expectedHash = createHash('sha256').update(expected).digest('hex'); + expect(expectedHash).toBe(EXPECTED_HASHES.get(size)); + let durationMs = 0; + + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + const startedAt = performance.now(); + const parsed = parseFeed(xml); + durationMs += performance.now() - startedAt; + expect(JSON.stringify(parsed)).toBe(expected); + expect(createHash('sha256').update(JSON.stringify(parsed)).digest('hex')).toBe(expectedHash); + } + + metrics.push(`size${size}=${(durationMs / ITERATIONS).toFixed(3)}ms/op`); + } + + console.log(`[benchmark] ${metrics.join(' ')} (${ITERATIONS} iterations)`); + console.log(`[resource] maximum_supported_entries=${SIZES.at(-1)}`); +}); + +function buildFeed(size: number): string { + const items = Array.from({ length: size }, (_, index) => { + const day = String((index % 28) + 1).padStart(2, '0'); + return ` + entry-${index} + Research update ${index} & notes + https://example.com/articles/${index}?source=reader + Author ${index % 20} + Mon, ${day} Jul 2026 00:00:00 GMT +

Finding ${index}

This is a useful research summary with + evidence, context, and follow-up details for the Reader library.

+

Additional paragraph ${index} with source.

+ + ]]>
+
`; + }).join(''); + + return ` + Reader performance feed + https://example.com/ + ${items} + `; +} diff --git a/src/lib/__tests__/rss-parser.test.ts b/src/lib/__tests__/rss-parser.test.ts index b00842d..333862c 100644 --- a/src/lib/__tests__/rss-parser.test.ts +++ b/src/lib/__tests__/rss-parser.test.ts @@ -47,6 +47,19 @@ describe('parseFeed', () => { expect(feed.entries[0].content).not.toContain(' { + const rss = ` + <![CDATA[Example <strong>RSS</strong>]]>https://example.com/ + post-1<![CDATA[First <em>entry</em>]]> + Summary + `; + + expect(parseFeed(rss)).toMatchObject({ + title: 'Example RSS', + entries: [{ title: 'First entry' }], + }); + }); + it('normalizes Atom entries and alternate links', () => { const atom = ` Example Atom diff --git a/src/lib/rss-parser.ts b/src/lib/rss-parser.ts index 949196d..e72b931 100644 --- a/src/lib/rss-parser.ts +++ b/src/lib/rss-parser.ts @@ -41,6 +41,12 @@ function cleanText(value: string | null | undefined, maxLength = 2_000): string return (document.body.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, maxLength); } +function normalizeText(value: string | null | undefined, maxLength = 2_000): string { + const text = value ?? ''; + if (text.includes('<')) return cleanText(text, maxLength); + return text.replace(/\s+/g, ' ').trim().slice(0, maxLength); +} + function cleanHtml(value: string | null | undefined): string | undefined { const cleaned = sanitizeHtml(value ?? '', { allowedTags: sanitizeHtml.defaults.allowedTags, @@ -56,7 +62,7 @@ function cleanHtml(value: string | null | undefined): string | undefined { function firstText(element: Element, names: string[]): string { for (const name of names) { const node = element.getElementsByTagName(name)[0]; - const value = cleanText(node?.textContent); + const value = normalizeText(node?.textContent); if (value) return value; } return ''; @@ -132,7 +138,7 @@ export function parseOpml(xml: string): OpmlSubscription[] { if (!feedUrl || seen.has(feedUrl)) continue; seen.add(feedUrl); subscriptions.push({ - title: cleanText( + title: normalizeText( outline.getAttribute('title') || outline.getAttribute('text') || new URL(feedUrl).hostname, 500 ),