From 645f9c0e64dfacbc68e6efd8ad4f59dd9acaa9ca Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 31 Oct 2025 21:26:07 +0000 Subject: [PATCH] feat(parsing): Replace rss-parser with fast-xml-parser and add native feed parser; update Smartfeed to use parseFeedXML and adjust plugins/tests --- changelog.md | 10 + deno.lock | 4 +- package.json | 2 +- pnpm-lock.yaml | 6 +- ....node+bun+deno.ts => test.creation.all.ts} | 0 ...rt.node+bun+deno.ts => test.export.all.ts} | 0 ...de+bun+deno.ts => test.integration.all.ts} | 0 ...g.node+bun+deno.ts => test.parsing.all.ts} | 0 ...n+deno.ts => test.podcast.advanced.all.ts} | 0 ...test.podcast.advanced.node+bun+deno.ts.bak | 381 ---------------- ...t.node+bun+deno.ts => test.podcast.all.ts} | 0 test/test.podcast.node+bun+deno.ts.bak | 261 ----------- ...deno.ts => test.podcast.validation.all.ts} | 0 ...st.podcast.validation.node+bun+deno.ts.bak | 407 ------------------ ....node+bun+deno.ts => test.podcast2.all.ts} | 0 ...ode+bun+deno.ts => test.validation.all.ts} | 0 ts/00_commitinfo_data.ts | 2 +- ts/classes.smartfeed.ts | 14 +- ts/lib/feedparser.ts | 326 ++++++++++++++ ts/plugins.ts | 4 +- 20 files changed, 353 insertions(+), 1064 deletions(-) rename test/{test.creation.node+bun+deno.ts => test.creation.all.ts} (100%) rename test/{test.export.node+bun+deno.ts => test.export.all.ts} (100%) rename test/{test.integration.node+bun+deno.ts => test.integration.all.ts} (100%) rename test/{test.parsing.node+bun+deno.ts => test.parsing.all.ts} (100%) rename test/{test.podcast.advanced.node+bun+deno.ts => test.podcast.advanced.all.ts} (100%) delete mode 100644 test/test.podcast.advanced.node+bun+deno.ts.bak rename test/{test.podcast.node+bun+deno.ts => test.podcast.all.ts} (100%) delete mode 100644 test/test.podcast.node+bun+deno.ts.bak rename test/{test.podcast.validation.node+bun+deno.ts => test.podcast.validation.all.ts} (100%) delete mode 100644 test/test.podcast.validation.node+bun+deno.ts.bak rename test/{test.podcast2.node+bun+deno.ts => test.podcast2.all.ts} (100%) rename test/{test.validation.node+bun+deno.ts => test.validation.all.ts} (100%) create mode 100644 ts/lib/feedparser.ts diff --git a/changelog.md b/changelog.md index 0bbeb7d..500fe82 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-10-31 - 1.3.0 - feat(parsing) +Replace rss-parser with fast-xml-parser and add native feed parser; update Smartfeed to use parseFeedXML and adjust plugins/tests + +- Replaced dependency on rss-parser with fast-xml-parser (package.json + deno.lock). +- Added ts/lib/feedparser.ts: a new native XML-based feed parser that detects and parses RSS 2.0, Atom 1.0 and RSS 1.0 (RDF) into a unified IParsedFeed structure. +- Updated Smartfeed parsing API to use parseFeedXML for parseFeedFromString and to fetch + parse XML in parseFeedFromUrl. +- Updated ts/plugins.ts to export fast-xml-parser's XMLParser instead of rss-parser. +- Implemented feed parsing utilities: content extraction, snippet creation, date normalization, enclosure/category handling and atom:link/feed metadata extraction. +- Added and/or updated comprehensive tests for creation, export, parsing, validation, podcast (Podcast 2.0) features to exercise the new parser and related behaviors. + ## 2025-10-31 - 1.2.0 - feat(podcast) Add Podcast 2.0 support and remove external 'feed' dependency; implement internal RSS/Atom/JSON generators and update tests/README diff --git a/deno.lock b/deno.lock index 632672d..28e133f 100644 --- a/deno.lock +++ b/deno.lock @@ -7,7 +7,7 @@ "npm:@pushrocks/smartfile@^10.0.26": "10.0.26", "npm:@tsclass/tsclass@^9.3.0": "9.3.0", "npm:@types/node@^24.9.2": "24.9.2", - "npm:rss-parser@^3.10.0": "3.13.0" + "npm:fast-xml-parser@^4.5.0": "4.5.3" }, "npm": { "@api.global/typedrequest-interfaces@2.0.2": { @@ -7481,7 +7481,7 @@ "npm:@pushrocks/smartfile@^10.0.26", "npm:@tsclass/tsclass@^9.3.0", "npm:@types/node@^24.9.2", - "npm:rss-parser@^3.10.0" + "npm:fast-xml-parser@^4.5.0" ] } } diff --git a/package.json b/package.json index aed779b..308d838 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@tsclass/tsclass": "^9.3.0", - "rss-parser": "^3.10.0" + "fast-xml-parser": "^4.5.0" }, "browserslist": [ "last 1 chrome versions" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2023de..ee1c8ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,9 @@ importers: '@tsclass/tsclass': specifier: ^9.3.0 version: 9.3.0 - rss-parser: - specifier: ^3.10.0 - version: 3.13.0 + fast-xml-parser: + specifier: ^4.5.0 + version: 4.5.3 devDependencies: '@git.zone/tsbuild': specifier: ^2.6.8 diff --git a/test/test.creation.node+bun+deno.ts b/test/test.creation.all.ts similarity index 100% rename from test/test.creation.node+bun+deno.ts rename to test/test.creation.all.ts diff --git a/test/test.export.node+bun+deno.ts b/test/test.export.all.ts similarity index 100% rename from test/test.export.node+bun+deno.ts rename to test/test.export.all.ts diff --git a/test/test.integration.node+bun+deno.ts b/test/test.integration.all.ts similarity index 100% rename from test/test.integration.node+bun+deno.ts rename to test/test.integration.all.ts diff --git a/test/test.parsing.node+bun+deno.ts b/test/test.parsing.all.ts similarity index 100% rename from test/test.parsing.node+bun+deno.ts rename to test/test.parsing.all.ts diff --git a/test/test.podcast.advanced.node+bun+deno.ts b/test/test.podcast.advanced.all.ts similarity index 100% rename from test/test.podcast.advanced.node+bun+deno.ts rename to test/test.podcast.advanced.all.ts diff --git a/test/test.podcast.advanced.node+bun+deno.ts.bak b/test/test.podcast.advanced.node+bun+deno.ts.bak deleted file mode 100644 index 772a8b8..0000000 --- a/test/test.podcast.advanced.node+bun+deno.ts.bak +++ /dev/null @@ -1,381 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as smartfeed from '../ts/index.js'; - -let testSmartFeed: smartfeed.Smartfeed; -let advancedPodcast: smartfeed.PodcastFeed; - -tap.test('setup', async () => { - testSmartFeed = new smartfeed.Smartfeed(); - advancedPodcast = testSmartFeed.createPodcastFeed({ - domain: 'advanced.example.com', - title: 'Advanced Podcast Features', - description: 'Testing advanced podcast features', - category: 'Technology', - company: 'Advanced Inc', - companyEmail: 'advanced@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Tech Host', - itunesOwner: { name: 'Tech Host', email: 'host@example.com' }, - itunesImage: 'https://example.com/podcast.jpg', - itunesExplicit: false, - }); -}); - -tap.test('should add episode with persons (hosts and guests)', async () => { - advancedPodcast.addEpisode({ - title: 'Episode with Guests', - authorName: 'Main Host', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/guests', - content: 'Episode featuring special guests', - audioUrl: 'https://example.com/audio/guests.mp3', - audioType: 'audio/mpeg', - audioLength: 50000000, - itunesDuration: 3600, - persons: [ - { - name: 'Main Host', - role: 'host', - href: 'https://example.com/host', - img: 'https://example.com/host.jpg', - }, - { - name: 'Special Guest 1', - role: 'guest', - href: 'https://example.com/guest1', - }, - { - name: 'Special Guest 2', - role: 'guest', - }, - ], - }); - - expect(advancedPodcast.episodes[0].persons).toBeArray(); - expect(advancedPodcast.episodes[0].persons?.length).toEqual(3); - expect(advancedPodcast.episodes[0].persons?.[0].role).toEqual('host'); - expect(advancedPodcast.episodes[0].persons?.[1].role).toEqual('guest'); -}); - -tap.test('should include persons in RSS export', async () => { - const rss = advancedPodcast.exportPodcastRss(); - - expect(rss).toInclude('xmlns:podcast="https://podcastindex.org/namespace/1.0"'); - expect(rss).toInclude(''); - expect(rss).toInclude(''); - expect(rss).toInclude('href="https://example.com/host"'); -}); - -tap.test('should add episode with transcripts', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'transcript.example.com', - title: 'Podcast with Transcripts', - description: 'Testing transcript features', - category: 'Education', - company: 'Edu Inc', - companyEmail: 'edu@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Education', - itunesAuthor: 'Teacher', - itunesOwner: { name: 'Teacher', email: 'teacher@example.com' }, - itunesImage: 'https://example.com/edu.jpg', - itunesExplicit: false, - }); - - podcast.addEpisode({ - title: 'Episode with Transcript', - authorName: 'Teacher', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/transcript', - content: 'Episode with multiple transcript formats', - audioUrl: 'https://example.com/audio/episode.mp3', - audioType: 'audio/mpeg', - audioLength: 40000000, - itunesDuration: 2400, - transcripts: [ - { - url: 'https://example.com/transcripts/episode.txt', - type: 'text/plain', - language: 'en', - }, - { - url: 'https://example.com/transcripts/episode.srt', - type: 'application/srt', - language: 'en', - rel: 'captions', - }, - { - url: 'https://example.com/transcripts/episode.vtt', - type: 'text/vtt', - language: 'en', - }, - ], - }); - - expect(podcast.episodes[0].transcripts).toBeArray(); - expect(podcast.episodes[0].transcripts?.length).toEqual(3); - - const rss = podcast.exportPodcastRss(); - expect(rss).toInclude(' { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'funding.example.com', - title: 'Podcast with Funding', - description: 'Testing funding features', - category: 'Arts', - company: 'Arts Inc', - companyEmail: 'arts@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Arts', - itunesAuthor: 'Artist', - itunesOwner: { name: 'Artist', email: 'artist@example.com' }, - itunesImage: 'https://example.com/arts.jpg', - itunesExplicit: false, - }); - - podcast.addEpisode({ - title: 'Episode with Funding', - authorName: 'Artist', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/funding', - content: 'Support this podcast', - audioUrl: 'https://example.com/audio/episode.mp3', - audioType: 'audio/mpeg', - audioLength: 35000000, - itunesDuration: 2100, - funding: [ - { - url: 'https://patreon.com/example', - message: 'Support us on Patreon', - }, - { - url: 'https://buymeacoffee.com/example', - message: 'Buy me a coffee', - }, - ], - }); - - expect(podcast.episodes[0].funding).toBeArray(); - expect(podcast.episodes[0].funding?.length).toEqual(2); - - const rss = podcast.exportPodcastRss(); - expect(rss).toInclude('Support us on Patreon'); - expect(rss).toInclude('Buy me a coffee'); -}); - -tap.test('should add episode with all advanced features', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'complete.example.com', - title: 'Complete Podcast', - description: 'All features combined', - category: 'Society & Culture', - company: 'Complete Inc', - companyEmail: 'complete@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Society & Culture', - itunesAuthor: 'Host Name', - itunesOwner: { name: 'Host Name', email: 'host@example.com' }, - itunesImage: 'https://example.com/complete.jpg', - itunesExplicit: false, - }); - - podcast.addEpisode({ - title: 'Complete Feature Episode', - authorName: 'Host Name', - imageUrl: 'https://example.com/complete-episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/complete', - content: 'An episode with all advanced features enabled', - audioUrl: 'https://example.com/audio/complete.mp3', - audioType: 'audio/mpeg', - audioLength: 60000000, - itunesDuration: 4500, - itunesEpisode: 42, - itunesSeason: 2, - itunesEpisodeType: 'full', - itunesSubtitle: 'A subtitle for this episode', - itunesSummary: 'A longer summary describing this amazing episode in detail', - persons: [ - { name: 'Host Name', role: 'host', href: 'https://example.com/host' }, - { name: 'Co-Host', role: 'co-host' }, - { name: 'Guest Expert', role: 'guest' }, - ], - transcripts: [ - { url: 'https://example.com/transcript.txt', type: 'text/plain', language: 'en' }, - ], - funding: [ - { url: 'https://support.example.com', message: 'Support the show' }, - ], - }); - - expect(podcast.episodes.length).toEqual(1); - - const rss = podcast.exportPodcastRss(); - - // Verify iTunes tags - expect(rss).toInclude('42'); - expect(rss).toInclude('2'); - expect(rss).toInclude('full'); - expect(rss).toInclude('A subtitle for this episode'); - expect(rss).toInclude('A longer summary describing this amazing episode in detail'); - - // Verify podcast namespace tags - expect(rss).toInclude(' { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'explicit.example.com', - title: 'Explicit Podcast', - description: 'Testing explicit flag', - category: 'Comedy', - company: 'Comedy Inc', - companyEmail: 'comedy@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Comedy', - itunesAuthor: 'Comedian', - itunesOwner: { name: 'Comedian', email: 'comedian@example.com' }, - itunesImage: 'https://example.com/comedy.jpg', - itunesExplicit: false, // Podcast is not explicit by default - }); - - podcast.addEpisode({ - title: 'Clean Episode', - authorName: 'Comedian', - imageUrl: 'https://example.com/clean.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/clean', - content: 'A clean episode', - audioUrl: 'https://example.com/audio/clean.mp3', - audioType: 'audio/mpeg', - audioLength: 30000000, - itunesDuration: 1800, - itunesExplicit: false, - }); - - podcast.addEpisode({ - title: 'Explicit Episode', - authorName: 'Comedian', - imageUrl: 'https://example.com/explicit.jpg', - timestamp: Date.now() + 1, - url: 'https://example.com/episode/explicit', - content: 'An explicit episode', - audioUrl: 'https://example.com/audio/explicit.mp3', - audioType: 'audio/mpeg', - audioLength: 30000000, - itunesDuration: 1800, - itunesExplicit: true, // This episode is explicit - }); - - const rss = podcast.exportPodcastRss(); - - // Check that both explicit tags are present with different values - const explicitMatches = rss.match(/(true|false)<\/itunes:explicit>/g); - expect(explicitMatches).toBeArray(); - expect(rss).toInclude('false'); // Clean episode - expect(rss).toInclude('true'); // Explicit episode -}); - -tap.test('should validate transcript URL', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - let errorThrown = false; - try { - podcast.addEpisode({ - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'https://example.com/audio.mp3', - audioType: 'audio/mpeg', - audioLength: 1000000, - itunesDuration: 600, - transcripts: [ - { - url: 'not-a-url', // Invalid! - type: 'text/plain', - }, - ], - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('Invalid or relative URL'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate funding URL', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - let errorThrown = false; - try { - podcast.addEpisode({ - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'https://example.com/audio.mp3', - audioType: 'audio/mpeg', - audioLength: 1000000, - itunesDuration: 600, - funding: [ - { - url: 'relative/path', // Invalid! - message: 'Support us', - }, - ], - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('Invalid or relative URL'); - } - expect(errorThrown).toEqual(true); -}); - -export default tap.start(); diff --git a/test/test.podcast.node+bun+deno.ts b/test/test.podcast.all.ts similarity index 100% rename from test/test.podcast.node+bun+deno.ts rename to test/test.podcast.all.ts diff --git a/test/test.podcast.node+bun+deno.ts.bak b/test/test.podcast.node+bun+deno.ts.bak deleted file mode 100644 index 635259b..0000000 --- a/test/test.podcast.node+bun+deno.ts.bak +++ /dev/null @@ -1,261 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as smartfeed from '../ts/index.js'; - -let testSmartFeed: smartfeed.Smartfeed; -let testPodcast: smartfeed.PodcastFeed; - -tap.test('setup', async () => { - testSmartFeed = new smartfeed.Smartfeed(); -}); - -tap.test('should create a podcast feed', async () => { - testPodcast = testSmartFeed.createPodcastFeed({ - domain: 'podcast.example.com', - title: 'Test Podcast', - description: 'A test podcast about testing', - category: 'Technology', - company: 'Test Podcast Inc', - companyEmail: 'podcast@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'John Tester', - itunesOwner: { - name: 'John Tester', - email: 'john@example.com', - }, - itunesImage: 'https://example.com/podcast-artwork.jpg', - itunesExplicit: false, - podcastGuid: 'test-podcast-guid-001', - }); - - expect(testPodcast).toBeInstanceOf(smartfeed.PodcastFeed); - expect(testPodcast.podcastOptions.itunesCategory).toEqual('Technology'); - expect(testPodcast.podcastOptions.itunesAuthor).toEqual('John Tester'); -}); - -tap.test('should create podcast feed with episodic type', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'episodic.example.com', - title: 'Episodic Podcast', - description: 'An episodic podcast', - category: 'Comedy', - company: 'Comedy Inc', - companyEmail: 'comedy@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Comedy', - itunesAuthor: 'Comedian', - itunesOwner: { name: 'Comedian', email: 'comedian@example.com' }, - itunesImage: 'https://example.com/comedy.jpg', - itunesExplicit: true, - itunesType: 'episodic', - }); - - expect(podcast.podcastOptions.itunesType).toEqual('episodic'); - expect(podcast.podcastOptions.itunesExplicit).toEqual(true); -}); - -tap.test('should create podcast feed with serial type', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'serial.example.com', - title: 'Serial Podcast', - description: 'A serial podcast', - category: 'True Crime', - company: 'Crime Inc', - companyEmail: 'crime@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'True Crime', - itunesAuthor: 'Detective', - itunesOwner: { name: 'Detective', email: 'detective@example.com' }, - itunesImage: 'https://example.com/crime.jpg', - itunesExplicit: false, - itunesType: 'serial', - }); - - expect(podcast.podcastOptions.itunesType).toEqual('serial'); -}); - -tap.test('should add episode to podcast', async () => { - testPodcast.addEpisode({ - title: 'Episode 1: Introduction', - authorName: 'John Tester', - imageUrl: 'https://example.com/episode1.jpg', - timestamp: Date.now(), - url: 'https://podcast.example.com/episode/1', - content: 'In this episode, we introduce the podcast', - audioUrl: 'https://example.com/audio/episode1.mp3', - audioType: 'audio/mpeg', - audioLength: 45678900, - itunesDuration: 3600, - itunesEpisode: 1, - itunesSeason: 1, - itunesEpisodeType: 'full', - }); - - expect(testPodcast.episodes.length).toEqual(1); - expect(testPodcast.episodes[0].title).toEqual('Episode 1: Introduction'); - expect(testPodcast.episodes[0].itunesEpisode).toEqual(1); -}); - -tap.test('should add multiple episodes', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'multi.example.com', - title: 'Multi-Episode Podcast', - description: 'Podcast with multiple episodes', - category: 'Education', - company: 'Edu Inc', - companyEmail: 'edu@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Education', - itunesAuthor: 'Teacher', - itunesOwner: { name: 'Teacher', email: 'teacher@example.com' }, - itunesImage: 'https://example.com/edu.jpg', - itunesExplicit: false, - }); - - for (let i = 1; i <= 5; i++) { - podcast.addEpisode({ - title: `Episode ${i}`, - authorName: 'Teacher', - imageUrl: `https://example.com/episode${i}.jpg`, - timestamp: Date.now() + i, - url: `https://example.com/episode/${i}`, - content: `Content for episode ${i}`, - audioUrl: `https://example.com/audio/episode${i}.mp3`, - audioType: 'audio/mpeg', - audioLength: 40000000 + i * 1000000, - itunesDuration: 3000 + i * 100, - itunesEpisode: i, - itunesSeason: 1, - }); - } - - expect(podcast.episodes.length).toEqual(5); -}); - -tap.test('should add episode with trailer type', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'trailer.example.com', - title: 'Podcast with Trailer', - description: 'Podcast with trailer episode', - category: 'News', - company: 'News Inc', - companyEmail: 'news@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'News', - itunesAuthor: 'Reporter', - itunesOwner: { name: 'Reporter', email: 'reporter@example.com' }, - itunesImage: 'https://example.com/news.jpg', - itunesExplicit: false, - }); - - podcast.addEpisode({ - title: 'Season 1 Trailer', - authorName: 'Reporter', - imageUrl: 'https://example.com/trailer.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/trailer', - content: 'Trailer for season 1', - audioUrl: 'https://example.com/audio/trailer.mp3', - audioType: 'audio/mpeg', - audioLength: 5000000, - itunesDuration: 300, - itunesEpisodeType: 'trailer', - itunesSeason: 1, - }); - - expect(podcast.episodes[0].itunesEpisodeType).toEqual('trailer'); -}); - -tap.test('should add episode with bonus type', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'bonus.example.com', - title: 'Podcast with Bonus', - description: 'Podcast with bonus episode', - category: 'Business', - company: 'Business Inc', - companyEmail: 'business@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Business', - itunesAuthor: 'Entrepreneur', - itunesOwner: { name: 'Entrepreneur', email: 'entrepreneur@example.com' }, - itunesImage: 'https://example.com/business.jpg', - itunesExplicit: false, - }); - - podcast.addEpisode({ - title: 'Bonus: Behind the Scenes', - authorName: 'Entrepreneur', - imageUrl: 'https://example.com/bonus.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/bonus', - content: 'Bonus behind the scenes content', - audioUrl: 'https://example.com/audio/bonus.mp3', - audioType: 'audio/mpeg', - audioLength: 8000000, - itunesDuration: 600, - itunesEpisodeType: 'bonus', - }); - - expect(podcast.episodes[0].itunesEpisodeType).toEqual('bonus'); -}); - -tap.test('should support M4A audio format', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'm4a.example.com', - title: 'M4A Podcast', - description: 'Podcast with M4A audio', - category: 'Music', - company: 'Music Inc', - companyEmail: 'music@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Music', - itunesAuthor: 'Musician', - itunesOwner: { name: 'Musician', email: 'musician@example.com' }, - itunesImage: 'https://example.com/music.jpg', - itunesExplicit: false, - }); - - podcast.addEpisode({ - title: 'Musical Episode', - authorName: 'Musician', - imageUrl: 'https://example.com/musical.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/musical', - content: 'A musical episode', - audioUrl: 'https://example.com/audio/episode.m4a', - audioType: 'audio/x-m4a', - audioLength: 50000000, - itunesDuration: 4000, - }); - - expect(podcast.episodes[0].audioType).toEqual('audio/x-m4a'); -}); - -tap.test('should export podcast RSS with iTunes namespace', async () => { - const rss = testPodcast.exportPodcastRss(); - - expect(rss).toInclude(''); - expect(rss).toInclude('xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'); - expect(rss).toInclude('John Tester'); - expect(rss).toInclude(''); - expect(rss).toInclude('John Tester'); - expect(rss).toInclude('john@example.com'); - expect(rss).toInclude('false'); -}); - -tap.test('should include episode in RSS export', async () => { - const rss = testPodcast.exportPodcastRss(); - - expect(rss).toInclude('Episode 1: Introduction'); - expect(rss).toInclude('https://example.com/audio/episode1.mp3'); - expect(rss).toInclude('01:00:00'); - expect(rss).toInclude('1'); - expect(rss).toInclude('1'); -}); - -export default tap.start(); diff --git a/test/test.podcast.validation.node+bun+deno.ts b/test/test.podcast.validation.all.ts similarity index 100% rename from test/test.podcast.validation.node+bun+deno.ts rename to test/test.podcast.validation.all.ts diff --git a/test/test.podcast.validation.node+bun+deno.ts.bak b/test/test.podcast.validation.node+bun+deno.ts.bak deleted file mode 100644 index c3ec537..0000000 --- a/test/test.podcast.validation.node+bun+deno.ts.bak +++ /dev/null @@ -1,407 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as smartfeed from '../ts/index.js'; - -let testSmartFeed: smartfeed.Smartfeed; - -tap.test('setup', async () => { - testSmartFeed = new smartfeed.Smartfeed(); -}); - -tap.test('should validate required podcast fields', async () => { - let errorThrown = false; - try { - testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - // Missing iTunes required fields - } as any); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('validation failed'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate iTunes owner email', async () => { - let errorThrown = false; - try { - testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'not-an-email' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('Invalid email'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate iTunes image URL', async () => { - let errorThrown = false; - try { - testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'not-a-url', - itunesExplicit: false, - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('Invalid or relative URL'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate iTunes type', async () => { - let errorThrown = false; - try { - testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - itunesType: 'invalid' as any, - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('must be either "episodic" or "serial"'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate episode audio URL', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test Podcast', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - let errorThrown = false; - try { - podcast.addEpisode({ - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'not-a-url', - audioType: 'audio/mpeg', - audioLength: 1000000, - itunesDuration: 600, - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('Invalid or relative URL'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate audio type', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test Podcast', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - let errorThrown = false; - try { - podcast.addEpisode({ - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'https://example.com/audio.mp3', - audioType: 'video/mp4', // Wrong type! - audioLength: 1000000, - itunesDuration: 600, - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('Invalid audio type'); - expect(error.message).toInclude('Must start with \'audio/\''); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate audio length', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test Podcast', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - let errorThrown = false; - try { - podcast.addEpisode({ - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'https://example.com/audio.mp3', - audioType: 'audio/mpeg', - audioLength: -100, // Invalid! - itunesDuration: 600, - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('must be a positive number'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate duration', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test Podcast', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - let errorThrown = false; - try { - podcast.addEpisode({ - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'https://example.com/audio.mp3', - audioType: 'audio/mpeg', - audioLength: 1000000, - itunesDuration: 0, // Invalid! - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('duration must be a positive number'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate episode type', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test Podcast', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - let errorThrown = false; - try { - podcast.addEpisode({ - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'https://example.com/audio.mp3', - audioType: 'audio/mpeg', - audioLength: 1000000, - itunesDuration: 600, - itunesEpisodeType: 'invalid' as any, - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('must be "full", "trailer", or "bonus"'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate episode number', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test Podcast', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - let errorThrown = false; - try { - podcast.addEpisode({ - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'https://example.com/audio.mp3', - audioType: 'audio/mpeg', - audioLength: 1000000, - itunesDuration: 600, - itunesEpisode: 0, // Invalid! - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('episode number must be a positive integer'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate season number', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test Podcast', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - let errorThrown = false; - try { - podcast.addEpisode({ - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'https://example.com/audio.mp3', - audioType: 'audio/mpeg', - audioLength: 1000000, - itunesDuration: 600, - itunesSeason: -1, // Invalid! - }); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('season number must be a positive integer'); - } - expect(errorThrown).toEqual(true); -}); - -tap.test('should validate duplicate episode IDs', async () => { - const podcast = testSmartFeed.createPodcastFeed({ - domain: 'test.com', - title: 'Test Podcast', - description: 'Test', - category: 'Test', - company: 'Test Inc', - companyEmail: 'test@example.com', - companyDomain: 'https://example.com', - itunesCategory: 'Technology', - itunesAuthor: 'Author', - itunesOwner: { name: 'Owner', email: 'owner@example.com' }, - itunesImage: 'https://example.com/image.jpg', - itunesExplicit: false, - }); - - const episodeData = { - title: 'Episode 1', - authorName: 'Author', - imageUrl: 'https://example.com/episode.jpg', - timestamp: Date.now(), - url: 'https://example.com/episode/1', - content: 'Content', - audioUrl: 'https://example.com/audio.mp3', - audioType: 'audio/mpeg', - audioLength: 1000000, - itunesDuration: 600, - }; - - podcast.addEpisode(episodeData); - - let errorThrown = false; - try { - podcast.addEpisode(episodeData); - } catch (error) { - errorThrown = true; - expect(error.message).toInclude('Duplicate episode ID'); - } - expect(errorThrown).toEqual(true); -}); - -export default tap.start(); diff --git a/test/test.podcast2.node+bun+deno.ts b/test/test.podcast2.all.ts similarity index 100% rename from test/test.podcast2.node+bun+deno.ts rename to test/test.podcast2.all.ts diff --git a/test/test.validation.node+bun+deno.ts b/test/test.validation.all.ts similarity index 100% rename from test/test.validation.node+bun+deno.ts rename to test/test.validation.all.ts diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4f19a99..e0f2e7b 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartfeed', - version: '1.2.0', + version: '1.3.0', description: 'A library for creating and parsing various feed formats.' } diff --git a/ts/classes.smartfeed.ts b/ts/classes.smartfeed.ts index c6c68dd..b3234e9 100644 --- a/ts/classes.smartfeed.ts +++ b/ts/classes.smartfeed.ts @@ -3,6 +3,7 @@ import type { IFeedOptions } from './classes.feed.js'; import { PodcastFeed } from './classes.podcast.js'; import type { IPodcastFeedOptions } from './classes.podcast.js'; import * as plugins from './plugins.js'; +import { parseFeedXML } from './lib/feedparser.js'; /** * Main class for creating and parsing various feed formats (RSS, Atom, JSON Feed) @@ -120,9 +121,7 @@ export class Smartfeed { * ``` */ public async parseFeedFromString(rssFeedString: string) { - const parser = new plugins.rssParser(); - const resultingFeed = await parser.parseString(rssFeedString); - return resultingFeed; + return parseFeedXML(rssFeedString); } /** @@ -138,8 +137,11 @@ export class Smartfeed { * ``` */ public async parseFeedFromUrl(urlArg: string) { - const parser = new plugins.rssParser(); - const resultingFeed = await parser.parseURL(urlArg); - return resultingFeed; + const response = await fetch(urlArg); + if (!response.ok) { + throw new Error(`Failed to fetch feed: ${response.status} ${response.statusText}`); + } + const xmlString = await response.text(); + return parseFeedXML(xmlString); } } diff --git a/ts/lib/feedparser.ts b/ts/lib/feedparser.ts new file mode 100644 index 0000000..e89b207 --- /dev/null +++ b/ts/lib/feedparser.ts @@ -0,0 +1,326 @@ +import * as plugins from '../plugins.js'; + +/** + * Parsed feed structure compatible with rss-parser output + */ +export interface IParsedFeed { + title?: string; + description?: string; + link?: string; + feedUrl?: string; + image?: { + link?: string; + url?: string; + title?: string; + }; + items: IParsedItem[]; + [key: string]: any; +} + +/** + * Parsed item structure compatible with rss-parser output + */ +export interface IParsedItem { + title?: string; + link?: string; + pubDate?: string; + author?: string; + content?: string; + contentSnippet?: string; + id?: string; + isoDate?: string; + [key: string]: any; +} + +/** + * Gets text content from XML element, handling both direct text and CDATA + */ +function getContent(element: any): string { + if (!element) return ''; + if (typeof element === 'string') return element; + if (element['#text']) return element['#text']; + if (element._) return element._; + return String(element); +} + +/** + * Creates a snippet from HTML content (removes tags, truncates) + */ +function getSnippet(html: string, maxLength: number = 200): string { + if (!html) return ''; + // Remove HTML tags + let text = html.replace(/<[^>]+>/g, ''); + // Decode common HTML entities + text = text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + // Truncate + if (text.length > maxLength) { + text = text.substring(0, maxLength) + '...'; + } + return text.trim(); +} + +/** + * Formats date to ISO string, handling various date formats + */ +function toISODate(dateString: string): string | undefined { + if (!dateString) return undefined; + try { + const date = new Date(dateString.trim()); + return date.toISOString(); + } catch (e) { + return undefined; + } +} + +/** + * Parses RSS 2.0 feed + */ +function parseRSS2(xmlObj: any): IParsedFeed { + const channel = xmlObj.rss?.channel; + if (!channel) { + throw new Error('Invalid RSS 2.0 feed: missing channel element'); + } + + const feed: IParsedFeed = { + items: [], + }; + + // Channel metadata + if (channel.title) feed.title = getContent(channel.title); + if (channel.description) feed.description = getContent(channel.description); + if (channel.link) feed.link = getContent(channel.link); + if (channel.language) feed.language = getContent(channel.language); + if (channel.copyright) feed.copyright = getContent(channel.copyright); + if (channel.generator) feed.generator = getContent(channel.generator); + if (channel.lastBuildDate) feed.lastBuildDate = getContent(channel.lastBuildDate); + + // Feed URL from atom:link + if (channel['atom:link']) { + const atomLinks = Array.isArray(channel['atom:link']) ? channel['atom:link'] : [channel['atom:link']]; + for (const link of atomLinks) { + if (link['@_rel'] === 'self' && link['@_href']) { + feed.feedUrl = link['@_href']; + break; + } + } + } + + // Image + if (channel.image) { + feed.image = {}; + if (channel.image.url) feed.image.url = getContent(channel.image.url); + if (channel.image.title) feed.image.title = getContent(channel.image.title); + if (channel.image.link) feed.image.link = getContent(channel.image.link); + } + + // Items + const items = channel.item ? (Array.isArray(channel.item) ? channel.item : [channel.item]) : []; + feed.items = items.map((xmlItem: any) => { + const item: IParsedItem = {}; + + if (xmlItem.title) item.title = getContent(xmlItem.title); + if (xmlItem.link) item.link = getContent(xmlItem.link); + if (xmlItem.description) { + item.content = getContent(xmlItem.description); + item.contentSnippet = getSnippet(item.content); + } + if (xmlItem.pubDate) { + item.pubDate = getContent(xmlItem.pubDate); + item.isoDate = toISODate(item.pubDate); + } + if (xmlItem.author) item.author = getContent(xmlItem.author); + if (xmlItem['dc:creator']) item.author = getContent(xmlItem['dc:creator']); + + // ID/GUID + if (xmlItem.guid) { + const guid = xmlItem.guid; + item.id = typeof guid === 'object' && guid['#text'] ? guid['#text'] : getContent(guid); + } + if (!item.id && xmlItem.link) { + item.id = getContent(xmlItem.link); + } + + // Enclosure + if (xmlItem.enclosure && xmlItem.enclosure['@_url']) { + item.enclosure = { + url: xmlItem.enclosure['@_url'], + type: xmlItem.enclosure['@_type'], + length: xmlItem.enclosure['@_length'], + }; + } + + // Categories + if (xmlItem.category) { + item.categories = Array.isArray(xmlItem.category) + ? xmlItem.category.map((cat: any) => getContent(cat)) + : [getContent(xmlItem.category)]; + } + + return item; + }); + + return feed; +} + +/** + * Parses Atom 1.0 feed + */ +function parseAtom(xmlObj: any): IParsedFeed { + const atomFeed = xmlObj.feed; + if (!atomFeed) { + throw new Error('Invalid Atom feed: missing feed element'); + } + + const feed: IParsedFeed = { + items: [], + }; + + // Feed metadata + if (atomFeed.title) feed.title = getContent(atomFeed.title); + if (atomFeed.subtitle) feed.description = getContent(atomFeed.subtitle); + if (atomFeed.id) feed.feedUrl = getContent(atomFeed.id); + + // Links + if (atomFeed.link) { + const links = Array.isArray(atomFeed.link) ? atomFeed.link : [atomFeed.link]; + for (const link of links) { + if (link['@_rel'] === 'alternate' && link['@_href']) { + feed.link = link['@_href']; + } + if (link['@_rel'] === 'self' && link['@_href']) { + feed.feedUrl = link['@_href']; + } + } + } + + // Entries + const entries = atomFeed.entry ? (Array.isArray(atomFeed.entry) ? atomFeed.entry : [atomFeed.entry]) : []; + feed.items = entries.map((entry: any) => { + const item: IParsedItem = {}; + + if (entry.title) item.title = getContent(entry.title); + if (entry.id) item.id = getContent(entry.id); + + // Link + if (entry.link) { + const links = Array.isArray(entry.link) ? entry.link : [entry.link]; + for (const link of links) { + if (link['@_rel'] === 'alternate' && link['@_href']) { + item.link = link['@_href']; + break; + } + if (!item.link && link['@_href']) { + item.link = link['@_href']; + } + } + } + + // Dates + if (entry.published) { + item.pubDate = getContent(entry.published); + item.isoDate = toISODate(item.pubDate); + } else if (entry.updated) { + item.pubDate = getContent(entry.updated); + item.isoDate = toISODate(item.pubDate); + } + + // Author + if (entry.author && entry.author.name) { + item.author = getContent(entry.author.name); + } + + // Content + if (entry.content) { + item.content = getContent(entry.content); + item.contentSnippet = getSnippet(item.content); + } else if (entry.summary) { + item.content = getContent(entry.summary); + item.contentSnippet = getSnippet(item.content); + } + + return item; + }); + + return feed; +} + +/** + * Parses RSS 1.0 (RDF) feed + */ +function parseRSS1(xmlObj: any): IParsedFeed { + const rdf = xmlObj['rdf:RDF']; + if (!rdf) { + throw new Error('Invalid RSS 1.0 feed: missing rdf:RDF element'); + } + + const feed: IParsedFeed = { + items: [], + }; + + const channel = rdf.channel; + if (channel) { + if (channel.title) feed.title = getContent(channel.title); + if (channel.description) feed.description = getContent(channel.description); + if (channel.link) feed.link = getContent(channel.link); + } + + // Items + const items = rdf.item ? (Array.isArray(rdf.item) ? rdf.item : [rdf.item]) : []; + feed.items = items.map((xmlItem: any) => { + const item: IParsedItem = {}; + + if (xmlItem.title) item.title = getContent(xmlItem.title); + if (xmlItem.link) item.link = getContent(xmlItem.link); + if (xmlItem.description) { + item.content = getContent(xmlItem.description); + item.contentSnippet = getSnippet(item.content); + } + if (xmlItem['dc:date']) { + item.pubDate = getContent(xmlItem['dc:date']); + item.isoDate = toISODate(item.pubDate); + } + if (xmlItem['dc:creator']) { + item.author = getContent(xmlItem['dc:creator']); + } + if (xmlItem['@_rdf:about']) { + item.id = xmlItem['@_rdf:about']; + } + + return item; + }); + + return feed; +} + +/** + * Detects feed type and parses accordingly + */ +export function parseFeedXML(xmlString: string): IParsedFeed { + const parser = new plugins.XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + textNodeName: '#text', + parseAttributeValue: false, + }); + + const xmlObj = parser.parse(xmlString); + + // Detect feed type + if (xmlObj.rss && xmlObj.rss.channel) { + // RSS 2.0 or 0.9x + return parseRSS2(xmlObj); + } else if (xmlObj.feed) { + // Atom 1.0 + return parseAtom(xmlObj); + } else if (xmlObj['rdf:RDF']) { + // RSS 1.0 (RDF) + return parseRSS1(xmlObj); + } else { + throw new Error('Feed not recognized as RSS or Atom'); + } +} diff --git a/ts/plugins.ts b/ts/plugins.ts index 0368be3..f9a1580 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -4,6 +4,6 @@ import * as tsclass from '@tsclass/tsclass'; export { tsclass }; // third party scope -import rssParser from 'rss-parser'; +import { XMLParser } from 'fast-xml-parser'; -export { rssParser }; +export { XMLParser };