feat(parsing): Replace rss-parser with fast-xml-parser and add native feed parser; update Smartfeed to use parseFeedXML and adjust plugins/tests
This commit is contained in:
10
changelog.md
10
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
|
||||
|
||||
|
||||
4
deno.lock
generated
4
deno.lock
generated
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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('<podcast:person role="host"');
|
||||
expect(rss).toInclude('Main Host</podcast:person>');
|
||||
expect(rss).toInclude('<podcast:person role="guest"');
|
||||
expect(rss).toInclude('Special Guest 1</podcast:person>');
|
||||
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('<podcast:transcript url="https://example.com/transcripts/episode.txt"');
|
||||
expect(rss).toInclude('type="text/plain"');
|
||||
expect(rss).toInclude('language="en"');
|
||||
expect(rss).toInclude('type="application/srt"');
|
||||
expect(rss).toInclude('rel="captions"');
|
||||
});
|
||||
|
||||
tap.test('should add episode with funding links', async () => {
|
||||
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('<podcast:funding url="https://patreon.com/example">Support us on Patreon</podcast:funding>');
|
||||
expect(rss).toInclude('<podcast:funding url="https://buymeacoffee.com/example">Buy me a coffee</podcast:funding>');
|
||||
});
|
||||
|
||||
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('<itunes:episode>42</itunes:episode>');
|
||||
expect(rss).toInclude('<itunes:season>2</itunes:season>');
|
||||
expect(rss).toInclude('<itunes:episodeType>full</itunes:episodeType>');
|
||||
expect(rss).toInclude('<itunes:subtitle>A subtitle for this episode</itunes:subtitle>');
|
||||
expect(rss).toInclude('<itunes:summary>A longer summary describing this amazing episode in detail</itunes:summary>');
|
||||
|
||||
// Verify podcast namespace tags
|
||||
expect(rss).toInclude('<podcast:person role="host"');
|
||||
expect(rss).toInclude('<podcast:person role="co-host"');
|
||||
expect(rss).toInclude('<podcast:person role="guest"');
|
||||
expect(rss).toInclude('<podcast:transcript');
|
||||
expect(rss).toInclude('<podcast:funding');
|
||||
});
|
||||
|
||||
tap.test('should handle explicit content flag at episode level', async () => {
|
||||
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(/<itunes:explicit>(true|false)<\/itunes:explicit>/g);
|
||||
expect(explicitMatches).toBeArray();
|
||||
expect(rss).toInclude('<itunes:explicit>false</itunes:explicit>'); // Clean episode
|
||||
expect(rss).toInclude('<itunes:explicit>true</itunes:explicit>'); // 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();
|
||||
@@ -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('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
expect(rss).toInclude('xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"');
|
||||
expect(rss).toInclude('<itunes:author>John Tester</itunes:author>');
|
||||
expect(rss).toInclude('<itunes:owner>');
|
||||
expect(rss).toInclude('<itunes:name>John Tester</itunes:name>');
|
||||
expect(rss).toInclude('<itunes:email>john@example.com</itunes:email>');
|
||||
expect(rss).toInclude('<itunes:category text="Technology"');
|
||||
expect(rss).toInclude('<itunes:image href="https://example.com/podcast-artwork.jpg"');
|
||||
expect(rss).toInclude('<itunes:explicit>false</itunes:explicit>');
|
||||
});
|
||||
|
||||
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('<enclosure url="https://example.com/audio/episode1.mp3"');
|
||||
expect(rss).toInclude('length="45678900"');
|
||||
expect(rss).toInclude('type="audio/mpeg"');
|
||||
expect(rss).toInclude('<itunes:duration>01:00:00</itunes:duration>');
|
||||
expect(rss).toInclude('<itunes:episode>1</itunes:episode>');
|
||||
expect(rss).toInclude('<itunes:season>1</itunes:season>');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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();
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
326
ts/lib/feedparser.ts
Normal file
326
ts/lib/feedparser.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user