6 Commits
v1.1.1 ... main

20 changed files with 1011 additions and 96 deletions

View File

@@ -1,5 +1,34 @@
# Changelog
## 2025-11-01 - 1.4.0 - feat(feed)
Support custom feedUrl for feeds and use it as the self-link in RSS/Atom/JSON; update docs
- Add optional feedUrl option to IFeedOptions (ts/classes.feed.ts).
- Use feedUrl as the atom:link rel="self" href in RSS and as the <link rel="self"> in Atom when provided.
- Expose feedUrl as the JSON Feed "feed_url" value (ts/classes.feed.ts).
- PodcastFeed now uses podcastOptions.feedUrl for its atom self-link (ts/classes.podcast.ts).
- Update README: add a Custom Feed URL section and mention feedUrl in the API docs (readme.md).
## 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
- Add Podcast 2.0 fields and validation: podcastGuid (required), podcastMedium, podcastLocked and podcastLockOwner
- Include Podcast 2.0 tags in RSS export (podcast:guid, podcast:medium, podcast:locked, podcast:person, podcast:transcript, podcast:funding)
- Remove dependency on the external 'feed' package and replace with internal feed generation for RSS, Atom and JSON Feed
- Update ts/plugins.ts to stop exporting the removed 'feed' plugin
- Update numerous tests to provide podcastGuid and exercise Podcast 2.0 features
- Documentation updated (readme.md) to document Podcast 2.0 support and examples
## 2025-10-31 - 1.1.1 - fix(podcast)
Improve podcast episode validation, make Feed.itemIds protected, expand README and add tests

15
deno.lock generated
View File

@@ -7,8 +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:feed@^5.1.0": "5.1.0",
"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": {
@@ -1616,7 +1615,7 @@
"integrity": "sha512-02uhXxQamgfBo3T12FsAdfyElnpoWuDUb08B2AE60DbIaukVx/7Mi17xwobApY1flNSr5StZDt8N8vxPhBhIXw==",
"dependencies": [
"@tsclass/tsclass@3.0.48",
"feed@4.2.2",
"feed",
"rss-parser"
],
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartfeed/-/smartfeed-1.0.11.tgz"
@@ -4449,13 +4448,6 @@
],
"tarball": "https://verdaccio.lossless.digital/feed/-/feed-4.2.2.tgz"
},
"feed@5.1.0": {
"integrity": "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==",
"dependencies": [
"xml-js"
],
"tarball": "https://verdaccio.lossless.digital/feed/-/feed-5.1.0.tgz"
},
"fflate@0.8.2": {
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"tarball": "https://verdaccio.lossless.digital/fflate/-/fflate-0.8.2.tgz"
@@ -7489,8 +7481,7 @@
"npm:@pushrocks/smartfile@^10.0.26",
"npm:@tsclass/tsclass@^9.3.0",
"npm:@types/node@^24.9.2",
"npm:feed@^5.1.0",
"npm:rss-parser@^3.10.0"
"npm:fast-xml-parser@^4.5.0"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartfeed",
"version": "1.1.1",
"version": "1.4.0",
"private": false,
"description": "A library for creating and parsing various feed formats.",
"main": "dist_ts/index.js",
@@ -21,8 +21,7 @@
},
"dependencies": {
"@tsclass/tsclass": "^9.3.0",
"feed": "^5.1.0",
"rss-parser": "^3.10.0"
"fast-xml-parser": "^4.5.0"
},
"browserslist": [
"last 1 chrome versions"

17
pnpm-lock.yaml generated
View File

@@ -11,12 +11,9 @@ importers:
'@tsclass/tsclass':
specifier: ^9.3.0
version: 9.3.0
feed:
specifier: ^5.1.0
version: 5.1.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
@@ -2080,10 +2077,6 @@ packages:
resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==}
engines: {node: '>=0.4.0'}
feed@5.1.0:
resolution: {integrity: sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==}
engines: {node: '>=20', pnpm: '>=10'}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@@ -6889,10 +6882,6 @@ snapshots:
dependencies:
xml-js: 1.6.11
feed@5.1.0:
dependencies:
xml-js: 1.6.11
fflate@0.8.2: {}
figures@6.1.0:

View File

@@ -7,8 +7,9 @@
## Features ✨
- 🎯 **Full TypeScript Support** - Complete type definitions for all feed formats
- 🌐 **Cross-Platform** - Works in Node.js, Bun, Deno, and browsers
- 📡 **Multiple Feed Formats** - RSS 2.0, Atom 1.0, JSON Feed 1.0, and Podcast RSS
- 🎙️ **Modern Podcast Support** - iTunes tags, Podcast namespace (chapters, transcripts, funding, persons)
- 🎙️ **Modern Podcast Support** - iTunes tags, Podcast 2.0 namespace (guid, medium, locked, persons, transcripts, funding)
- 🔒 **Built-in Validation** - Comprehensive validation for URLs, emails, domains, and timestamps
- 🛡️ **Security First** - XSS prevention, content sanitization, and secure defaults
- 📦 **Zero Config** - Works out of the box with sensible defaults
@@ -57,6 +58,30 @@ const atom = feed.exportAtomFeed();
const json = feed.exportJsonFeed();
```
### Custom Feed URL
You can specify a custom URL for your feed's self-reference instead of the default `https://${domain}/feed.xml`:
```typescript
const feed = smartfeed.createFeed({
domain: 'example.com',
title: 'My Blog',
description: 'Latest posts',
category: 'Technology',
company: 'Example Inc',
companyEmail: 'hello@example.com',
companyDomain: 'https://example.com',
feedUrl: 'https://cdn.example.com/feeds/main.xml' // Custom feed URL
});
// The feedUrl will be used in:
// - RSS: <atom:link href="..." rel="self">
// - Atom: <link href="..." rel="self">
// - JSON Feed: "feed_url" field
```
This is particularly useful when your feed is hosted on a CDN or different domain than your main site.
### Creating a Podcast Feed
```typescript
@@ -72,6 +97,7 @@ const podcast = smartfeed.createPodcastFeed({
company: 'Tech Media Inc',
companyEmail: 'podcast@example.com',
companyDomain: 'https://example.com',
// iTunes tags
itunesCategory: 'Technology',
itunesAuthor: 'John Host',
itunesOwner: {
@@ -80,7 +106,12 @@ const podcast = smartfeed.createPodcastFeed({
},
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
itunesType: 'episodic'
itunesType: 'episodic',
// Podcast 2.0 tags
podcastGuid: '92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec', // Permanent GUID
podcastMedium: 'podcast', // or 'music', 'video', 'film', 'audiobook', 'newsletter', 'blog'
podcastLocked: true, // Prevent unauthorized imports
podcastLockOwner: 'john@example.com'
});
// Add an episode
@@ -177,12 +208,13 @@ Creates a standard feed (RSS/Atom/JSON).
- `company` (string) - Company/organization name
- `companyEmail` (string) - Contact email
- `companyDomain` (string) - Company website URL (absolute)
- `feedUrl` (string, optional) - Custom URL for the feed's self-reference (defaults to `https://${domain}/feed.xml`)
#### `createPodcastFeed(options: IPodcastFeedOptions): PodcastFeed`
Creates a podcast feed with iTunes and Podcast namespace support.
**Additional Options:**
**iTunes Options:**
- `itunesCategory` (string) - iTunes category
- `itunesSubcategory` (string, optional) - iTunes subcategory
- `itunesAuthor` (string) - Podcast author
@@ -194,6 +226,12 @@ Creates a podcast feed with iTunes and Podcast namespace support.
- `copyright` (string, optional) - Custom copyright
- `language` (string, optional) - Language code (default: 'en')
**Podcast 2.0 Options:**
- `podcastGuid` (string) - **Required.** Globally unique identifier (GUID) for the podcast
- `podcastMedium` ('podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog', optional) - Content medium type
- `podcastLocked` (boolean, optional) - Prevents unauthorized podcast imports (e.g., to other platforms)
- `podcastLockOwner` (string, optional) - Email of who can unlock (required if `podcastLocked` is true)
#### `parseFeedFromUrl(url: string): Promise<ParsedFeed>`
Parses an RSS or Atom feed from a URL.
@@ -312,6 +350,25 @@ For podcast feeds, artwork should be:
- JPG or PNG format
- Maximum 512 KB file size (Apple Podcasts requirement)
### Podcast 2.0 Compatibility
The library fully supports the [Podcast 2.0 namespace](https://github.com/Podcastindex-org/podcast-namespace), making your feeds compatible with modern podcast platforms like:
- **Podcast Index** - The open podcast directory
- **Castopod** - Open-source podcast hosting platform
- **Podverse** - Open-source podcast app
- And other Podcast 2.0-compliant apps
**Key Podcast 2.0 Features:**
- `podcast:guid` - Permanent unique identifier for your podcast
- `podcast:medium` - Declare if your feed is a podcast, music, video, etc.
- `podcast:locked` - Protect your podcast from unauthorized imports
- `podcast:person` - List hosts, co-hosts, and guests with rich metadata
- `podcast:transcript` - Link to transcript files in various formats
- `podcast:funding` - Add donation/support links for your listeners
These features are included in the RSS export when you use `exportPodcastRss()`.
## TypeScript Support
Full TypeScript definitions are included. Import types as needed:

View File

@@ -19,6 +19,7 @@ tap.test('setup', async () => {
itunesOwner: { name: 'Tech Host', email: 'host@example.com' },
itunesImage: 'https://example.com/podcast.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
});
@@ -84,6 +85,7 @@ tap.test('should add episode with transcripts', async () => {
itunesOwner: { name: 'Teacher', email: 'teacher@example.com' },
itunesImage: 'https://example.com/edu.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
@@ -142,6 +144,7 @@ tap.test('should add episode with funding links', async () => {
itunesOwner: { name: 'Artist', email: 'artist@example.com' },
itunesImage: 'https://example.com/arts.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
@@ -189,6 +192,7 @@ tap.test('should add episode with all advanced features', async () => {
itunesOwner: { name: 'Host Name', email: 'host@example.com' },
itunesImage: 'https://example.com/complete.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
@@ -253,6 +257,7 @@ tap.test('should handle explicit content flag at episode level', async () => {
itunesOwner: { name: 'Comedian', email: 'comedian@example.com' },
itunesImage: 'https://example.com/comedy.jpg',
itunesExplicit: false, // Podcast is not explicit by default
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
@@ -267,6 +272,7 @@ tap.test('should handle explicit content flag at episode level', async () => {
audioLength: 30000000,
itunesDuration: 1800,
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
@@ -281,6 +287,7 @@ tap.test('should handle explicit content flag at episode level', async () => {
audioLength: 30000000,
itunesDuration: 1800,
itunesExplicit: true, // This episode is explicit
podcastGuid: 'test-guid-auto',
});
const rss = podcast.exportPodcastRss();
@@ -306,6 +313,7 @@ tap.test('should validate transcript URL', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
@@ -349,6 +357,7 @@ tap.test('should validate funding URL', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;

View File

@@ -25,6 +25,7 @@ tap.test('should create a podcast feed', async () => {
},
itunesImage: 'https://example.com/podcast-artwork.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-001',
});
expect(testPodcast).toBeInstanceOf(smartfeed.PodcastFeed);
@@ -46,6 +47,7 @@ tap.test('should create podcast feed with episodic type', async () => {
itunesOwner: { name: 'Comedian', email: 'comedian@example.com' },
itunesImage: 'https://example.com/comedy.jpg',
itunesExplicit: true,
podcastGuid: 'test-podcast-guid-002',
itunesType: 'episodic',
});
@@ -67,6 +69,7 @@ tap.test('should create podcast feed with serial type', async () => {
itunesOwner: { name: 'Detective', email: 'detective@example.com' },
itunesImage: 'https://example.com/crime.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-003',
itunesType: 'serial',
});
@@ -109,6 +112,7 @@ tap.test('should add multiple episodes', async () => {
itunesOwner: { name: 'Teacher', email: 'teacher@example.com' },
itunesImage: 'https://example.com/edu.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-004',
});
for (let i = 1; i <= 5; i++) {
@@ -145,6 +149,7 @@ tap.test('should add episode with trailer type', async () => {
itunesOwner: { name: 'Reporter', email: 'reporter@example.com' },
itunesImage: 'https://example.com/news.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-005',
});
podcast.addEpisode({
@@ -179,6 +184,7 @@ tap.test('should add episode with bonus type', async () => {
itunesOwner: { name: 'Entrepreneur', email: 'entrepreneur@example.com' },
itunesImage: 'https://example.com/business.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-006',
});
podcast.addEpisode({
@@ -212,6 +218,7 @@ tap.test('should support M4A audio format', async () => {
itunesOwner: { name: 'Musician', email: 'musician@example.com' },
itunesImage: 'https://example.com/music.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-007',
});
podcast.addEpisode({

View File

@@ -43,6 +43,7 @@ tap.test('should validate iTunes owner email', async () => {
itunesOwner: { name: 'Owner', email: 'not-an-email' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-validation-guid-001',
});
} catch (error) {
errorThrown = true;
@@ -67,6 +68,7 @@ tap.test('should validate iTunes image URL', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'not-a-url',
itunesExplicit: false,
podcastGuid: 'test-validation-guid-002',
});
} catch (error) {
errorThrown = true;
@@ -91,6 +93,7 @@ tap.test('should validate iTunes type', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
itunesType: 'invalid' as any,
});
} catch (error) {
@@ -114,6 +117,7 @@ tap.test('should validate episode audio URL', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
@@ -151,6 +155,7 @@ tap.test('should validate audio type', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
@@ -189,6 +194,7 @@ tap.test('should validate audio length', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
@@ -226,6 +232,7 @@ tap.test('should validate duration', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
@@ -263,6 +270,7 @@ tap.test('should validate episode type', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
@@ -301,6 +309,7 @@ tap.test('should validate episode number', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
@@ -339,6 +348,7 @@ tap.test('should validate season number', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
@@ -377,6 +387,7 @@ tap.test('should validate duplicate episode IDs', async () => {
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
const episodeData = {

342
test/test.podcast2.all.ts Normal file
View File

@@ -0,0 +1,342 @@
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 create podcast with podcast:guid', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'podcast2.example.com',
title: 'Podcast 2.0 Test',
description: 'Testing Podcast 2.0 features',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Tech Author',
itunesOwner: { name: 'Tech Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: '92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec',
});
expect(podcast).toBeInstanceOf(smartfeed.PodcastFeed);
expect(podcast.podcastOptions.podcastGuid).toEqual('92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec');
});
tap.test('should require podcast:guid', 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,
// Missing podcastGuid
} as any);
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('podcastGuid');
}
expect(errorThrown).toEqual(true);
});
tap.test('should create podcast with medium type', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'music.example.com',
title: 'Music Podcast',
description: 'A music podcast',
category: 'Music',
company: 'Music Inc',
companyEmail: 'music@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Music',
itunesAuthor: 'DJ',
itunesOwner: { name: 'DJ', email: 'dj@example.com' },
itunesImage: 'https://example.com/music.jpg',
itunesExplicit: false,
podcastGuid: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
podcastMedium: 'music',
});
expect(podcast.podcastOptions.podcastMedium).toEqual('music');
});
tap.test('should validate podcast medium 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,
podcastGuid: 'test-guid-123',
podcastMedium: 'invalid' as any,
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('medium must be one of');
}
expect(errorThrown).toEqual(true);
});
tap.test('should create locked podcast', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'locked.example.com',
title: 'Locked Podcast',
description: 'A locked podcast',
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,
podcastGuid: 'locked-guid-456',
podcastLocked: true,
podcastLockOwner: 'owner@example.com',
});
expect(podcast.podcastOptions.podcastLocked).toEqual(true);
expect(podcast.podcastOptions.podcastLockOwner).toEqual('owner@example.com');
});
tap.test('should require lock owner when podcast is locked', 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,
podcastGuid: 'test-guid-789',
podcastLocked: true,
// Missing podcastLockOwner
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('lock owner');
expect(error.message).toInclude('required when podcast is locked');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate lock owner email format', 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,
podcastGuid: 'test-guid-abc',
podcastLocked: true,
podcastLockOwner: 'not-an-email',
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('email');
}
expect(errorThrown).toEqual(true);
});
tap.test('should include podcast:guid in RSS export', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'export.example.com',
title: 'Export Test',
description: 'Testing RSS export',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: 'export-test-guid-123',
});
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Test episode',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 600,
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:guid>export-test-guid-123</podcast:guid>');
expect(rss).toInclude('xmlns:podcast="https://podcastindex.org/namespace/1.0"');
});
tap.test('should include podcast:medium in RSS export', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'video.example.com',
title: 'Video Podcast',
description: 'A video podcast',
category: 'Video',
company: 'Video Inc',
companyEmail: 'video@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Video',
itunesAuthor: 'Videographer',
itunesOwner: { name: 'Videographer', email: 'video@example.com' },
itunesImage: 'https://example.com/video.jpg',
itunesExplicit: false,
podcastGuid: 'video-guid-def',
podcastMedium: 'video',
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:medium>video</podcast:medium>');
});
tap.test('should default to podcast medium when not specified', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'default.example.com',
title: 'Default Podcast',
description: 'Testing default medium',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: 'default-guid-ghi',
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:medium>podcast</podcast:medium>');
});
tap.test('should include podcast:locked in RSS export', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'locked-export.example.com',
title: 'Locked Export Test',
description: 'Testing locked RSS export',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: 'locked-export-guid-jkl',
podcastLocked: true,
podcastLockOwner: 'lock@example.com',
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:locked owner="lock@example.com">yes</podcast:locked>');
});
tap.test('should include podcast:locked as "no" when not locked', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'unlocked.example.com',
title: 'Unlocked Podcast',
description: 'Testing unlocked RSS export',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: 'unlocked-guid-mno',
podcastLocked: false,
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:locked>no</podcast:locked>');
});
tap.test('should support all medium types', async () => {
const mediums: Array<'podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog'> = [
'podcast',
'music',
'video',
'film',
'audiobook',
'newsletter',
'blog',
];
for (const medium of mediums) {
const podcast = testSmartFeed.createPodcastFeed({
domain: `${medium}.example.com`,
title: `${medium} Test`,
description: `Testing ${medium} medium`,
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: `${medium}-guid`,
podcastMedium: medium,
});
expect(podcast.podcastOptions.podcastMedium).toEqual(medium);
}
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartfeed',
version: '1.1.1',
version: '1.4.0',
description: 'A library for creating and parsing various feed formats.'
}

View File

@@ -19,6 +19,8 @@ export interface IFeedOptions {
companyEmail: string;
/** The company website URL (must be absolute) */
companyDomain: string;
/** Optional: Custom URL for the feed's atom:link rel="self" (defaults to https://${domain}/feed.xml) */
feedUrl?: string;
}
/**
@@ -131,48 +133,164 @@ export class Feed {
}
/**
* Creates the internal feed object with all items
* @private
* @returns Configured feed object
* Escapes special XML characters
* @protected
* @param str - String to escape
* @returns Escaped string
*/
private getFeedObject() {
const feed = new plugins.feed.Feed({
copyright: `All rights reserved, ${this.options.company}`,
id: `https://${this.options.domain}`,
link: `https://${this.options.domain}`,
protected escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Formats a Date object to RFC 822 format for RSS 2.0
* @private
* @param date - Date to format
* @returns RFC 822 formatted date string
*/
private formatRfc822Date(date: Date): string {
return date.toUTCString();
}
/**
* Formats a Date object to ISO 8601 format for Atom/JSON
* @private
* @param date - Date to format
* @returns ISO 8601 formatted date string
*/
private formatIso8601Date(date: Date): string {
return date.toISOString();
}
/**
* Generates RSS 2.0 feed
* @private
* @returns RSS 2.0 XML string
*/
private generateRss2(): string {
let rss = '<?xml version="1.0" encoding="utf-8"?>\n';
rss += '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n';
rss += '<channel>\n';
// Channel metadata
rss += `<title>${this.escapeXml(this.options.title)}</title>\n`;
rss += `<link>https://${this.options.domain}</link>\n`;
rss += `<description>${this.escapeXml(this.options.description)}</description>\n`;
rss += `<language>en</language>\n`;
rss += `<copyright>All rights reserved, ${this.escapeXml(this.options.company)}</copyright>\n`;
rss += `<generator>@push.rocks/smartfeed</generator>\n`;
rss += `<lastBuildDate>${this.formatRfc822Date(new Date())}</lastBuildDate>\n`;
rss += `<category>${this.escapeXml(this.options.category)}</category>\n`;
// Atom self link
const selfUrl = this.options.feedUrl || `https://${this.options.domain}/feed.xml`;
rss += `<atom:link href="${selfUrl}" rel="self" type="application/rss+xml" />\n`;
// Items
for (const item of this.items) {
rss += '<item>\n';
rss += `<title>${this.escapeXml(item.title)}</title>\n`;
rss += `<link>${item.url}</link>\n`;
rss += `<guid isPermaLink="true">${item.id || item.url}</guid>\n`;
rss += `<pubDate>${this.formatRfc822Date(new Date(item.timestamp))}</pubDate>\n`;
rss += `<description>${this.escapeXml(item.content)}</description>\n`;
rss += `<author>${this.options.companyEmail} (${this.escapeXml(item.authorName)})</author>\n`;
rss += `<enclosure url="${item.imageUrl}" type="image/jpeg" length="0" />\n`;
rss += '</item>\n';
}
rss += '</channel>\n';
rss += '</rss>';
return rss;
}
/**
* Generates Atom 1.0 feed
* @private
* @returns Atom 1.0 XML string
*/
private generateAtom1(): string {
let atom = '<?xml version="1.0" encoding="utf-8"?>\n';
atom += '<feed xmlns="http://www.w3.org/2005/Atom">\n';
// Feed metadata
atom += `<id>https://${this.options.domain}</id>\n`;
atom += `<title>${this.escapeXml(this.options.title)}</title>\n`;
atom += `<subtitle>${this.escapeXml(this.options.description)}</subtitle>\n`;
atom += `<link href="https://${this.options.domain}" />\n`;
const selfUrl = this.options.feedUrl || `https://${this.options.domain}/feed.xml`;
atom += `<link href="${selfUrl}" rel="self" />\n`;
atom += `<updated>${this.formatIso8601Date(new Date())}</updated>\n`;
atom += `<generator>@push.rocks/smartfeed</generator>\n`;
atom += '<author>\n';
atom += `<name>${this.escapeXml(this.options.company)}</name>\n`;
atom += `<email>${this.options.companyEmail}</email>\n`;
atom += `<uri>${this.options.companyDomain}</uri>\n`;
atom += '</author>\n';
atom += '<category>\n';
atom += `<term>${this.escapeXml(this.options.category)}</term>\n`;
atom += '</category>\n';
// Entries
for (const item of this.items) {
atom += '<entry>\n';
atom += `<id>${item.id || item.url}</id>\n`;
atom += `<title>${this.escapeXml(item.title)}</title>\n`;
atom += `<link href="${item.url}" />\n`;
atom += `<updated>${this.formatIso8601Date(new Date(item.timestamp))}</updated>\n`;
atom += '<author>\n';
atom += `<name>${this.escapeXml(item.authorName)}</name>\n`;
atom += '</author>\n';
atom += '<content type="html">\n';
atom += this.escapeXml(item.content);
atom += '\n</content>\n';
atom += `<link rel="enclosure" href="${item.imageUrl}" type="image/jpeg" />\n`;
atom += '</entry>\n';
}
atom += '</feed>';
return atom;
}
/**
* Generates JSON Feed 1.0
* @private
* @returns JSON Feed 1.0 string
*/
private generateJsonFeed(): string {
const jsonFeed = {
version: 'https://jsonfeed.org/version/1',
title: this.options.title,
home_page_url: `https://${this.options.domain}`,
feed_url: this.options.feedUrl || `https://${this.options.domain}/feed.json`,
description: this.options.description,
icon: '',
favicon: '',
author: {
name: this.options.company,
email: this.options.companyEmail,
link: this.options.companyDomain,
url: this.options.companyDomain,
},
description: this.options.description,
generator: '@push.rocks/smartfeed',
language: 'en',
});
items: this.items.map(item => ({
id: item.id || item.url,
url: item.url,
title: item.title,
content_html: item.content,
image: item.imageUrl,
date_published: this.formatIso8601Date(new Date(item.timestamp)),
author: {
name: item.authorName,
},
})),
};
feed.addCategory(this.options.category);
for (const itemArg of this.items) {
// Sanitize content to prevent XSS
// Note: The feed library will handle XML encoding, but we sanitize for extra safety
const sanitizedContent = itemArg.content;
feed.addItem({
title: itemArg.title,
date: new Date(itemArg.timestamp),
link: itemArg.url.replace(/&/gm, '&amp;'),
image: itemArg.imageUrl.replace(/&/gm, '&amp;'),
content: sanitizedContent,
id: itemArg.id || itemArg.url,
author: [
{
name: itemArg.authorName,
},
],
});
}
return feed;
return JSON.stringify(jsonFeed, null, 2);
}
/**
@@ -185,7 +303,7 @@ export class Feed {
* ```
*/
public exportRssFeedString(): string {
return this.getFeedObject().rss2();
return this.generateRss2();
}
/**
@@ -197,7 +315,7 @@ export class Feed {
* ```
*/
public exportAtomFeed(): string {
return this.getFeedObject().atom1();
return this.generateAtom1();
}
/**
@@ -210,6 +328,6 @@ export class Feed {
* ```
*/
public exportJsonFeed(): string {
return this.getFeedObject().json1();
return this.generateJsonFeed();
}
}

View File

@@ -195,6 +195,36 @@ export class PodcastFeed extends Feed {
throw new Error('iTunes type must be either "episodic" or "serial"');
}
// Validate Podcast 2.0 fields
// Validate podcast GUID (required for Podcast 2.0 compatibility)
validation.validateRequiredFields(
optionsArg,
['podcastGuid'],
'Podcast feed options'
);
if (!optionsArg.podcastGuid || typeof optionsArg.podcastGuid !== 'string' || optionsArg.podcastGuid.trim() === '') {
throw new Error('Podcast GUID is required and must be a non-empty string');
}
// Validate podcast medium if provided
if (optionsArg.podcastMedium) {
const validMediums = ['podcast', 'music', 'video', 'film', 'audiobook', 'newsletter', 'blog'];
if (!validMediums.includes(optionsArg.podcastMedium)) {
throw new Error(`Podcast medium must be one of: ${validMediums.join(', ')}`);
}
}
// Validate podcast locked and owner
if (optionsArg.podcastLocked && !optionsArg.podcastLockOwner) {
throw new Error('Podcast lock owner (email or contact) is required when podcast is locked');
}
if (optionsArg.podcastLockOwner) {
// Validate it's a valid email
validation.validateEmail(optionsArg.podcastLockOwner);
}
this.podcastOptions = optionsArg;
}
@@ -339,7 +369,8 @@ export class PodcastFeed extends Feed {
rss += `<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>\n`;
// Atom self link
rss += `<atom:link href="https://${this.podcastOptions.domain}/feed.xml" rel="self" type="application/rss+xml" />\n`;
const selfUrl = this.podcastOptions.feedUrl || `https://${this.podcastOptions.domain}/feed.xml`;
rss += `<atom:link href="${selfUrl}" rel="self" type="application/rss+xml" />\n`;
// iTunes channel tags
rss += `<itunes:author>${this.escapeXml(this.podcastOptions.itunesAuthor)}</itunes:author>\n`;
@@ -361,6 +392,25 @@ export class PodcastFeed extends Feed {
rss += `<itunes:type>${this.podcastOptions.itunesType}</itunes:type>\n`;
}
// Podcast 2.0 namespace tags
rss += `<podcast:guid>${this.escapeXml(this.podcastOptions.podcastGuid)}</podcast:guid>\n`;
if (this.podcastOptions.podcastMedium) {
rss += `<podcast:medium>${this.podcastOptions.podcastMedium}</podcast:medium>\n`;
} else {
// Default to 'podcast' if not specified
rss += `<podcast:medium>podcast</podcast:medium>\n`;
}
if (this.podcastOptions.podcastLocked !== undefined) {
const lockedValue = this.podcastOptions.podcastLocked ? 'yes' : 'no';
if (this.podcastOptions.podcastLockOwner) {
rss += `<podcast:locked owner="${this.escapeXml(this.podcastOptions.podcastLockOwner)}">${lockedValue}</podcast:locked>\n`;
} else {
rss += `<podcast:locked>${lockedValue}</podcast:locked>\n`;
}
}
// Episodes
for (const episode of this.episodes) {
rss += '<item>\n';
@@ -434,18 +484,4 @@ export class PodcastFeed extends Feed {
return rss;
}
/**
* Escapes XML special characters
* @param str - String to escape
* @returns Escaped string
*/
private escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}

View File

@@ -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
View 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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');
}
}

View File

@@ -4,7 +4,6 @@ import * as tsclass from '@tsclass/tsclass';
export { tsclass };
// third party scope
import rssParser from 'rss-parser';
import * as feed from 'feed';
import { XMLParser } from 'fast-xml-parser';
export { rssParser, feed };
export { XMLParser };