@molecule/api-feed-rss-parser 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +115 -0
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.d.ts.map +1 -0
- package/dist/browser-guard.js +18 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/parseAtom.d.ts +18 -0
- package/dist/parseAtom.d.ts.map +1 -0
- package/dist/parseAtom.js +151 -0
- package/dist/parseFeed.d.ts +43 -0
- package/dist/parseFeed.d.ts.map +1 -0
- package/dist/parseFeed.js +113 -0
- package/dist/parseJsonFeed.d.ts +19 -0
- package/dist/parseJsonFeed.d.ts.map +1 -0
- package/dist/parseJsonFeed.js +131 -0
- package/dist/parseRdf.d.ts +18 -0
- package/dist/parseRdf.d.ts.map +1 -0
- package/dist/parseRdf.js +65 -0
- package/dist/parseRss.d.ts +18 -0
- package/dist/parseRss.d.ts.map +1 -0
- package/dist/parseRss.js +151 -0
- package/dist/sanitize.d.ts +27 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +47 -0
- package/dist/types.d.ts +129 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/utilities.d.ts +77 -0
- package/dist/utilities.d.ts.map +1 -0
- package/dist/utilities.js +162 -0
- package/package.json +40 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { sanitizeHtml } from './sanitize.js';
|
|
2
|
+
import { FeedParseError } from './types.js';
|
|
3
|
+
import { synthesizeItemId, toIsoDate } from './utilities.js';
|
|
4
|
+
/**
|
|
5
|
+
* Parse a JSON Feed 1.0 / 1.1 body into the normalized {@link ParsedFeed} shape.
|
|
6
|
+
*
|
|
7
|
+
* @param args - Raw body plus options.
|
|
8
|
+
* @param args.body
|
|
9
|
+
* @param args.sanitize
|
|
10
|
+
* @returns Normalized feed + items.
|
|
11
|
+
* @throws {FeedParseError} when the body is not valid JSON or lacks a `feed` shape.
|
|
12
|
+
*/
|
|
13
|
+
export function parseJsonFeed({ body, sanitize }) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(body);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
throw new FeedParseError(`Invalid JSON Feed body: ${err.message}`, 'json-feed');
|
|
20
|
+
}
|
|
21
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
22
|
+
throw new FeedParseError('JSON Feed body is not an object', 'json-feed');
|
|
23
|
+
}
|
|
24
|
+
const obj = parsed;
|
|
25
|
+
const feed = {
|
|
26
|
+
title: typeof obj['title'] === 'string' ? obj['title'] : '',
|
|
27
|
+
description: stringOrUndefined(obj['description']),
|
|
28
|
+
link: stringOrUndefined(obj['home_page_url']),
|
|
29
|
+
feedUrl: stringOrUndefined(obj['feed_url']),
|
|
30
|
+
language: stringOrUndefined(obj['language']),
|
|
31
|
+
author: extractJsonAuthor(obj['author'] ?? obj['authors']),
|
|
32
|
+
imageUrl: stringOrUndefined(obj['icon']) ?? stringOrUndefined(obj['favicon']),
|
|
33
|
+
format: 'json-feed',
|
|
34
|
+
};
|
|
35
|
+
const rawItems = Array.isArray(obj['items']) ? obj['items'] : [];
|
|
36
|
+
const items = rawItems
|
|
37
|
+
.filter((it) => !!it && typeof it === 'object')
|
|
38
|
+
.map((raw) => buildJsonFeedItem(raw, sanitize));
|
|
39
|
+
return { feed, items };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns the trimmed string value, or undefined if the value is not a non-empty string.
|
|
43
|
+
* @param value
|
|
44
|
+
*/
|
|
45
|
+
function stringOrUndefined(value) {
|
|
46
|
+
if (typeof value !== 'string')
|
|
47
|
+
return undefined;
|
|
48
|
+
const trimmed = value.trim();
|
|
49
|
+
return trimmed === '' ? undefined : trimmed;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Extracts a displayable author name from a JSON Feed author object, array, or string.
|
|
53
|
+
* @param value
|
|
54
|
+
*/
|
|
55
|
+
function extractJsonAuthor(value) {
|
|
56
|
+
if (!value)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (Array.isArray(value))
|
|
59
|
+
return extractJsonAuthor(value[0]);
|
|
60
|
+
if (typeof value === 'object') {
|
|
61
|
+
const obj = value;
|
|
62
|
+
return stringOrUndefined(obj['name']) ?? stringOrUndefined(obj['url']);
|
|
63
|
+
}
|
|
64
|
+
return stringOrUndefined(value);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Maps a raw JSON Feed item object to a normalized {@link NormalizedFeedItem}.
|
|
68
|
+
* @param raw
|
|
69
|
+
* @param sanitize
|
|
70
|
+
*/
|
|
71
|
+
function buildJsonFeedItem(raw, sanitize) {
|
|
72
|
+
const title = stringOrUndefined(raw['title']) ?? '';
|
|
73
|
+
const link = stringOrUndefined(raw['url']) ?? stringOrUndefined(raw['external_url']);
|
|
74
|
+
const guid = stringOrUndefined(raw['id']);
|
|
75
|
+
const publishedAt = toIsoDate(stringOrUndefined(raw['date_published']));
|
|
76
|
+
const updatedAt = toIsoDate(stringOrUndefined(raw['date_modified']));
|
|
77
|
+
const rawSummary = stringOrUndefined(raw['summary']);
|
|
78
|
+
const rawContent = stringOrUndefined(raw['content_html']) ?? stringOrUndefined(raw['content_text']);
|
|
79
|
+
const summary = sanitize ? sanitizeHtml(rawSummary) : rawSummary;
|
|
80
|
+
const content = sanitize ? sanitizeHtml(rawContent) : rawContent;
|
|
81
|
+
const author = extractJsonAuthor(raw['author'] ?? raw['authors']);
|
|
82
|
+
const tags = Array.isArray(raw['tags'])
|
|
83
|
+
? raw['tags'].filter((t) => typeof t === 'string' && t !== '')
|
|
84
|
+
: [];
|
|
85
|
+
const enclosures = [];
|
|
86
|
+
if (Array.isArray(raw['attachments'])) {
|
|
87
|
+
for (const att of raw['attachments']) {
|
|
88
|
+
if (!att || typeof att !== 'object')
|
|
89
|
+
continue;
|
|
90
|
+
const obj = att;
|
|
91
|
+
const url = stringOrUndefined(obj['url']);
|
|
92
|
+
if (!url)
|
|
93
|
+
continue;
|
|
94
|
+
const enclosure = { url };
|
|
95
|
+
const mime = stringOrUndefined(obj['mime_type']);
|
|
96
|
+
if (mime)
|
|
97
|
+
enclosure.type = mime;
|
|
98
|
+
const sizeBytes = obj['size_in_bytes'];
|
|
99
|
+
if (typeof sizeBytes === 'number' && Number.isFinite(sizeBytes) && sizeBytes >= 0) {
|
|
100
|
+
enclosure.length = sizeBytes;
|
|
101
|
+
}
|
|
102
|
+
const title = stringOrUndefined(obj['title']);
|
|
103
|
+
if (title)
|
|
104
|
+
enclosure.title = title;
|
|
105
|
+
const duration = obj['duration_in_seconds'];
|
|
106
|
+
if (typeof duration === 'number' && Number.isFinite(duration) && duration >= 0) {
|
|
107
|
+
enclosure.durationSeconds = duration;
|
|
108
|
+
}
|
|
109
|
+
enclosures.push(enclosure);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const id = guid ?? link ?? synthesizeItemId(title, publishedAt);
|
|
113
|
+
const item = { id, title };
|
|
114
|
+
if (link)
|
|
115
|
+
item.link = link;
|
|
116
|
+
if (summary)
|
|
117
|
+
item.summary = summary;
|
|
118
|
+
if (content)
|
|
119
|
+
item.content = content;
|
|
120
|
+
if (author)
|
|
121
|
+
item.author = author;
|
|
122
|
+
if (publishedAt)
|
|
123
|
+
item.publishedAt = publishedAt;
|
|
124
|
+
if (updatedAt)
|
|
125
|
+
item.updatedAt = updatedAt;
|
|
126
|
+
if (tags.length > 0)
|
|
127
|
+
item.categories = tags;
|
|
128
|
+
if (enclosures.length > 0)
|
|
129
|
+
item.enclosures = enclosures;
|
|
130
|
+
return item;
|
|
131
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ParsedFeed } from './types.js';
|
|
2
|
+
interface ParseRdfArgs {
|
|
3
|
+
/** The fast-xml-parser document — already known to contain a `<rdf:RDF>` root. */
|
|
4
|
+
doc: Record<string, unknown>;
|
|
5
|
+
/** Whether to run {@link sanitizeHtml} on `summary` / `content` fields. */
|
|
6
|
+
sanitize: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Parse an RSS 1.0 / RDF document into the normalized {@link ParsedFeed} shape.
|
|
10
|
+
*
|
|
11
|
+
* @param args - Parsed XML document plus options.
|
|
12
|
+
* @param args.doc
|
|
13
|
+
* @param args.sanitize
|
|
14
|
+
* @returns Normalized feed + items.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseRdf({ doc, sanitize }: ParseRdfArgs): ParsedFeed;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=parseRdf.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parseRdf.d.ts","sourceRoot":"","sources":["../src/parseRdf.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAsC,UAAU,EAAE,MAAM,YAAY,CAAA;AAGhF,UAAU,YAAY;IACpB,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC5B,2EAA2E;IAC3E,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,EAAE,YAAY,GAAG,UAAU,CAoDpE"}
|
package/dist/parseRdf.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { sanitizeHtml } from './sanitize.js';
|
|
2
|
+
import { firstText, synthesizeItemId, textOf, toArray, toIsoDate } from './utilities.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parse an RSS 1.0 / RDF document into the normalized {@link ParsedFeed} shape.
|
|
5
|
+
*
|
|
6
|
+
* @param args - Parsed XML document plus options.
|
|
7
|
+
* @param args.doc
|
|
8
|
+
* @param args.sanitize
|
|
9
|
+
* @returns Normalized feed + items.
|
|
10
|
+
*/
|
|
11
|
+
export function parseRdf({ doc, sanitize }) {
|
|
12
|
+
// fast-xml-parser strips the `rdf:` prefix when `removeNSPrefix: true` is
|
|
13
|
+
// set; we run with prefixes preserved so detection is unambiguous, then
|
|
14
|
+
// index either form here.
|
|
15
|
+
const rdf = (doc['rdf:RDF'] ?? doc['RDF'] ?? {});
|
|
16
|
+
const channel = (rdf['channel'] ?? {});
|
|
17
|
+
const feed = {
|
|
18
|
+
title: textOf(channel['title']) ?? '',
|
|
19
|
+
description: textOf(channel['description']),
|
|
20
|
+
link: firstText(channel['link']),
|
|
21
|
+
language: textOf(channel['dc:language']) ?? textOf(channel['language']),
|
|
22
|
+
updatedAt: toIsoDate(textOf(channel['dc:date']) ?? textOf(channel['lastBuildDate'])),
|
|
23
|
+
author: textOf(channel['dc:creator']) ?? textOf(channel['managingEditor']),
|
|
24
|
+
copyright: textOf(channel['dc:rights']) ?? textOf(channel['copyright']),
|
|
25
|
+
format: 'rdf-1.0',
|
|
26
|
+
};
|
|
27
|
+
const rawItems = toArray(rdf['item']);
|
|
28
|
+
const items = rawItems.map((raw) => {
|
|
29
|
+
const title = textOf(raw['title']) ?? '';
|
|
30
|
+
const link = firstText(raw['link']);
|
|
31
|
+
const publishedAt = toIsoDate(textOf(raw['dc:date']) ?? textOf(raw['pubDate']));
|
|
32
|
+
const rawSummary = textOf(raw['description']);
|
|
33
|
+
const rawContent = textOf(raw['content:encoded']);
|
|
34
|
+
const summary = sanitize ? sanitizeHtml(rawSummary) : rawSummary;
|
|
35
|
+
const content = sanitize ? sanitizeHtml(rawContent) : rawContent;
|
|
36
|
+
const author = textOf(raw['dc:creator']);
|
|
37
|
+
const categories = [];
|
|
38
|
+
for (const cat of toArray(raw['dc:subject'])) {
|
|
39
|
+
const text = textOf(cat);
|
|
40
|
+
if (text)
|
|
41
|
+
categories.push(text);
|
|
42
|
+
}
|
|
43
|
+
for (const cat of toArray(raw['category'])) {
|
|
44
|
+
const text = textOf(cat);
|
|
45
|
+
if (text)
|
|
46
|
+
categories.push(text);
|
|
47
|
+
}
|
|
48
|
+
const id = link ?? synthesizeItemId(title, publishedAt);
|
|
49
|
+
const item = { id, title };
|
|
50
|
+
if (link)
|
|
51
|
+
item.link = link;
|
|
52
|
+
if (summary)
|
|
53
|
+
item.summary = summary;
|
|
54
|
+
if (content)
|
|
55
|
+
item.content = content;
|
|
56
|
+
if (author)
|
|
57
|
+
item.author = author;
|
|
58
|
+
if (publishedAt)
|
|
59
|
+
item.publishedAt = publishedAt;
|
|
60
|
+
if (categories.length > 0)
|
|
61
|
+
item.categories = categories;
|
|
62
|
+
return item;
|
|
63
|
+
});
|
|
64
|
+
return { feed, items };
|
|
65
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ParsedFeed } from './types.js';
|
|
2
|
+
interface ParseRssArgs {
|
|
3
|
+
/** The fast-xml-parser document — already known to contain an `<rss>` root. */
|
|
4
|
+
doc: Record<string, unknown>;
|
|
5
|
+
/** Whether to run {@link sanitizeHtml} on `summary` / `content` fields. */
|
|
6
|
+
sanitize: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Parse an RSS 2.0 document into the normalized {@link ParsedFeed} shape.
|
|
10
|
+
*
|
|
11
|
+
* @param args - Parsed XML document plus options.
|
|
12
|
+
* @param args.doc
|
|
13
|
+
* @param args.sanitize
|
|
14
|
+
* @returns Normalized feed + items.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseRss({ doc, sanitize }: ParseRssArgs): ParsedFeed;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=parseRss.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parseRss.d.ts","sourceRoot":"","sources":["../src/parseRss.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAqD,UAAU,EAAE,MAAM,YAAY,CAAA;AAU/F,UAAU,YAAY;IACpB,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC5B,2EAA2E;IAC3E,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,EAAE,YAAY,GAAG,UAAU,CAwBpE"}
|
package/dist/parseRss.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { sanitizeHtml } from './sanitize.js';
|
|
2
|
+
import { attrOf, parseItunesDuration, synthesizeItemId, textOf, toArray, toIsoDate, } from './utilities.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parse an RSS 2.0 document into the normalized {@link ParsedFeed} shape.
|
|
5
|
+
*
|
|
6
|
+
* @param args - Parsed XML document plus options.
|
|
7
|
+
* @param args.doc
|
|
8
|
+
* @param args.sanitize
|
|
9
|
+
* @returns Normalized feed + items.
|
|
10
|
+
*/
|
|
11
|
+
export function parseRss({ doc, sanitize }) {
|
|
12
|
+
const rss = (doc['rss'] ?? {});
|
|
13
|
+
const channel = (rss['channel'] ?? {});
|
|
14
|
+
const feed = {
|
|
15
|
+
title: textOf(channel['title']) ?? '',
|
|
16
|
+
description: textOf(channel['description']),
|
|
17
|
+
link: extractRssLink(channel),
|
|
18
|
+
language: textOf(channel['language']),
|
|
19
|
+
updatedAt: toIsoDate(textOf(channel['lastBuildDate'])) ?? toIsoDate(textOf(channel['pubDate'])),
|
|
20
|
+
author: textOf(channel['managingEditor']) ?? textOf(channel['itunes:author']),
|
|
21
|
+
copyright: textOf(channel['copyright']),
|
|
22
|
+
imageUrl: extractRssImage(channel),
|
|
23
|
+
feedUrl: attrOf((channel['atom:link'] ?? channel['atom10:link']), 'href'),
|
|
24
|
+
format: 'rss-2.0',
|
|
25
|
+
};
|
|
26
|
+
const rawItems = toArray(channel['item']);
|
|
27
|
+
const items = rawItems.map((raw) => buildRssItem(raw, sanitize));
|
|
28
|
+
return { feed, items };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Extract the primary HTML link URL from an RSS channel or item element.
|
|
32
|
+
* @param channel
|
|
33
|
+
*/
|
|
34
|
+
function extractRssLink(channel) {
|
|
35
|
+
const link = channel['link'];
|
|
36
|
+
if (Array.isArray(link)) {
|
|
37
|
+
for (const candidate of link) {
|
|
38
|
+
const text = textOf(candidate);
|
|
39
|
+
if (text)
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
if (typeof link === 'string')
|
|
45
|
+
return link.trim() || undefined;
|
|
46
|
+
return textOf(link);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Extract the feed image URL from RSS `<image>` or iTunes `<itunes:image>` elements.
|
|
50
|
+
* @param channel
|
|
51
|
+
*/
|
|
52
|
+
function extractRssImage(channel) {
|
|
53
|
+
const image = channel['image'];
|
|
54
|
+
if (image && typeof image === 'object') {
|
|
55
|
+
const url = textOf(image['url']);
|
|
56
|
+
if (url)
|
|
57
|
+
return url;
|
|
58
|
+
}
|
|
59
|
+
const itunesImage = channel['itunes:image'];
|
|
60
|
+
if (itunesImage) {
|
|
61
|
+
const href = attrOf(itunesImage, 'href');
|
|
62
|
+
if (href)
|
|
63
|
+
return href;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Map a raw RSS `<item>` element into a {@link NormalizedFeedItem}.
|
|
69
|
+
* @param raw
|
|
70
|
+
* @param sanitize
|
|
71
|
+
*/
|
|
72
|
+
function buildRssItem(raw, sanitize) {
|
|
73
|
+
const title = textOf(raw['title']) ?? '';
|
|
74
|
+
const link = extractRssLink(raw);
|
|
75
|
+
const guid = textOf(raw['guid']);
|
|
76
|
+
const publishedAt = toIsoDate(textOf(raw['pubDate']));
|
|
77
|
+
const updatedAt = toIsoDate(textOf(raw['atom:updated']) ?? textOf(raw['dc:date']) ?? textOf(raw['updated']));
|
|
78
|
+
const rawSummary = textOf(raw['description']);
|
|
79
|
+
const rawContent = textOf(raw['content:encoded']) ?? textOf(raw['content']) ?? textOf(raw['xhtml:body']);
|
|
80
|
+
const summary = sanitize ? sanitizeHtml(rawSummary) : rawSummary;
|
|
81
|
+
const content = sanitize ? sanitizeHtml(rawContent) : rawContent;
|
|
82
|
+
const author = textOf(raw['author']) ?? textOf(raw['dc:creator']) ?? textOf(raw['itunes:author']);
|
|
83
|
+
const categories = extractRssCategories(raw);
|
|
84
|
+
const enclosures = extractRssEnclosures(raw);
|
|
85
|
+
const id = guid ?? link ?? synthesizeItemId(title, publishedAt);
|
|
86
|
+
const item = { id, title };
|
|
87
|
+
if (link)
|
|
88
|
+
item.link = link;
|
|
89
|
+
if (summary)
|
|
90
|
+
item.summary = summary;
|
|
91
|
+
if (content)
|
|
92
|
+
item.content = content;
|
|
93
|
+
if (author)
|
|
94
|
+
item.author = author;
|
|
95
|
+
if (publishedAt)
|
|
96
|
+
item.publishedAt = publishedAt;
|
|
97
|
+
if (updatedAt)
|
|
98
|
+
item.updatedAt = updatedAt;
|
|
99
|
+
if (categories.length > 0)
|
|
100
|
+
item.categories = categories;
|
|
101
|
+
if (enclosures.length > 0)
|
|
102
|
+
item.enclosures = enclosures;
|
|
103
|
+
return item;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Collect all category strings from RSS `<category>` and iTunes `<itunes:category>` elements.
|
|
107
|
+
* @param raw
|
|
108
|
+
*/
|
|
109
|
+
function extractRssCategories(raw) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const cat of toArray(raw['category'])) {
|
|
112
|
+
const text = textOf(cat);
|
|
113
|
+
if (text)
|
|
114
|
+
out.push(text);
|
|
115
|
+
}
|
|
116
|
+
// iTunes categories live under <itunes:category text="...">; collect
|
|
117
|
+
// the top-level text attributes for podcast feeds.
|
|
118
|
+
for (const cat of toArray(raw['itunes:category'])) {
|
|
119
|
+
const text = attrOf(cat, 'text');
|
|
120
|
+
if (text)
|
|
121
|
+
out.push(text);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Collect all enclosures from RSS `<enclosure>` elements, enriched with iTunes duration.
|
|
127
|
+
* @param raw
|
|
128
|
+
*/
|
|
129
|
+
function extractRssEnclosures(raw) {
|
|
130
|
+
const out = [];
|
|
131
|
+
const itunesDuration = parseItunesDuration(textOf(raw['itunes:duration']));
|
|
132
|
+
for (const enc of toArray(raw['enclosure'])) {
|
|
133
|
+
const url = attrOf(enc, 'url');
|
|
134
|
+
if (!url)
|
|
135
|
+
continue;
|
|
136
|
+
const length = attrOf(enc, 'length');
|
|
137
|
+
const type = attrOf(enc, 'type');
|
|
138
|
+
const enclosure = { url };
|
|
139
|
+
if (type)
|
|
140
|
+
enclosure.type = type;
|
|
141
|
+
if (length) {
|
|
142
|
+
const n = Number(length);
|
|
143
|
+
if (Number.isFinite(n) && n >= 0)
|
|
144
|
+
enclosure.length = n;
|
|
145
|
+
}
|
|
146
|
+
if (itunesDuration !== undefined)
|
|
147
|
+
enclosure.durationSeconds = itunesDuration;
|
|
148
|
+
out.push(enclosure);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight HTML sanitization helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* This module is intentionally minimal — strip `<script>`, `<style>`,
|
|
6
|
+
* inline `on*` event handlers, and `javascript:` URLs. Callers wiring this
|
|
7
|
+
* into a full UI should still run a proper sanitizer (e.g. DOMPurify) on
|
|
8
|
+
* the output. The goal here is "feed parser cannot smuggle a `<script>`
|
|
9
|
+
* tag through to a downstream consumer that forgot to sanitize."
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Strip dangerous content from an HTML string. Returns plain text unchanged.
|
|
13
|
+
*
|
|
14
|
+
* Removes:
|
|
15
|
+
* - Complete `<script>...</script>` and `<style>...</style>` blocks.
|
|
16
|
+
* - Stray opening/closing tags of those elements (in malformed HTML).
|
|
17
|
+
* - Inline event handlers (`onclick="..."`, `onerror="..."`, etc.).
|
|
18
|
+
* - `javascript:` URLs in `href` / `src` / `xlink:href` / `(form)action`.
|
|
19
|
+
*
|
|
20
|
+
* Idempotent: `sanitizeHtml(sanitizeHtml(x)) === sanitizeHtml(x)`.
|
|
21
|
+
*
|
|
22
|
+
* @param html - HTML or plain-text string. `undefined` and `null` short-circuit
|
|
23
|
+
* to `undefined` so callers can chain safely.
|
|
24
|
+
* @returns Sanitized HTML string, or `undefined` when the input was nullish.
|
|
25
|
+
*/
|
|
26
|
+
export declare function sanitizeHtml(html: string | undefined | null): string | undefined;
|
|
27
|
+
//# sourceMappingURL=sanitize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAWH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAYhF"}
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight HTML sanitization helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* This module is intentionally minimal — strip `<script>`, `<style>`,
|
|
6
|
+
* inline `on*` event handlers, and `javascript:` URLs. Callers wiring this
|
|
7
|
+
* into a full UI should still run a proper sanitizer (e.g. DOMPurify) on
|
|
8
|
+
* the output. The goal here is "feed parser cannot smuggle a `<script>`
|
|
9
|
+
* tag through to a downstream consumer that forgot to sanitize."
|
|
10
|
+
*/
|
|
11
|
+
const SCRIPT_BLOCK = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
|
|
12
|
+
const SCRIPT_OPEN = /<script\b[^>]*>/gi;
|
|
13
|
+
const SCRIPT_CLOSE = /<\/script\s*>/gi;
|
|
14
|
+
const STYLE_BLOCK = /<style\b[^>]*>[\s\S]*?<\/style\s*>/gi;
|
|
15
|
+
const STYLE_OPEN = /<style\b[^>]*>/gi;
|
|
16
|
+
const STYLE_CLOSE = /<\/style\s*>/gi;
|
|
17
|
+
const EVENT_HANDLER = /\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi;
|
|
18
|
+
const JS_URL = /(\s(?:href|src|xlink:href|action|formaction))\s*=\s*("|')\s*javascript:[^"']*\2/gi;
|
|
19
|
+
/**
|
|
20
|
+
* Strip dangerous content from an HTML string. Returns plain text unchanged.
|
|
21
|
+
*
|
|
22
|
+
* Removes:
|
|
23
|
+
* - Complete `<script>...</script>` and `<style>...</style>` blocks.
|
|
24
|
+
* - Stray opening/closing tags of those elements (in malformed HTML).
|
|
25
|
+
* - Inline event handlers (`onclick="..."`, `onerror="..."`, etc.).
|
|
26
|
+
* - `javascript:` URLs in `href` / `src` / `xlink:href` / `(form)action`.
|
|
27
|
+
*
|
|
28
|
+
* Idempotent: `sanitizeHtml(sanitizeHtml(x)) === sanitizeHtml(x)`.
|
|
29
|
+
*
|
|
30
|
+
* @param html - HTML or plain-text string. `undefined` and `null` short-circuit
|
|
31
|
+
* to `undefined` so callers can chain safely.
|
|
32
|
+
* @returns Sanitized HTML string, or `undefined` when the input was nullish.
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeHtml(html) {
|
|
35
|
+
if (html == null)
|
|
36
|
+
return undefined;
|
|
37
|
+
let out = html;
|
|
38
|
+
out = out.replace(SCRIPT_BLOCK, '');
|
|
39
|
+
out = out.replace(SCRIPT_OPEN, '');
|
|
40
|
+
out = out.replace(SCRIPT_CLOSE, '');
|
|
41
|
+
out = out.replace(STYLE_BLOCK, '');
|
|
42
|
+
out = out.replace(STYLE_OPEN, '');
|
|
43
|
+
out = out.replace(STYLE_CLOSE, '');
|
|
44
|
+
out = out.replace(EVENT_HANDLER, '');
|
|
45
|
+
out = out.replace(JS_URL, '$1=$2#$2');
|
|
46
|
+
return out;
|
|
47
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discriminator for the source feed format detected by {@link parseFeed}.
|
|
3
|
+
*
|
|
4
|
+
* - `rss-2.0` — RSS 2.0 (`<rss version="2.0"><channel>...`)
|
|
5
|
+
* - `atom-1.0` — Atom 1.0 (`<feed xmlns="http://www.w3.org/2005/Atom">...`)
|
|
6
|
+
* - `rdf-1.0` — RSS 1.0 / RDF (`<rdf:RDF ...><channel>...<item>`)
|
|
7
|
+
* - `json-feed` — JSON Feed 1.0 / 1.1 (https://www.jsonfeed.org/version/1.1/)
|
|
8
|
+
*/
|
|
9
|
+
export type FeedFormat = 'rss-2.0' | 'atom-1.0' | 'rdf-1.0' | 'json-feed';
|
|
10
|
+
/**
|
|
11
|
+
* A normalized media enclosure attached to a feed item (podcast audio,
|
|
12
|
+
* video, image, etc.). Source: RSS `<enclosure>`, Atom `<link rel="enclosure">`,
|
|
13
|
+
* JSON Feed `attachments[]`.
|
|
14
|
+
*/
|
|
15
|
+
export interface FeedEnclosure {
|
|
16
|
+
/** Direct URL to the media resource. Required. */
|
|
17
|
+
url: string;
|
|
18
|
+
/** MIME type (e.g. `audio/mpeg`, `video/mp4`, `image/png`) when known. */
|
|
19
|
+
type?: string;
|
|
20
|
+
/** Length in bytes when reported by the source feed. */
|
|
21
|
+
length?: number;
|
|
22
|
+
/** Display title or alt text for the enclosure when supplied. */
|
|
23
|
+
title?: string;
|
|
24
|
+
/** Duration in seconds — derived from iTunes `<itunes:duration>` for podcasts. */
|
|
25
|
+
durationSeconds?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Normalized feed-level metadata. The exact subset of fields populated
|
|
29
|
+
* depends on what the source feed publishes; only `title` is guaranteed.
|
|
30
|
+
*/
|
|
31
|
+
export interface NormalizedFeed {
|
|
32
|
+
/** Feed/channel title. Empty string if the source omitted a title. */
|
|
33
|
+
title: string;
|
|
34
|
+
/** Long-form description / subtitle / tagline. */
|
|
35
|
+
description?: string;
|
|
36
|
+
/** Canonical website URL (NOT the feed self-link). */
|
|
37
|
+
link?: string;
|
|
38
|
+
/** Self-referential URL of the feed document, when supplied. */
|
|
39
|
+
feedUrl?: string;
|
|
40
|
+
/** RFC 5646 / BCP 47 language tag (e.g. `en`, `en-US`). */
|
|
41
|
+
language?: string;
|
|
42
|
+
/** ISO 8601 timestamp of the feed's last update. */
|
|
43
|
+
updatedAt?: string;
|
|
44
|
+
/** Author / managing editor name reported by the feed. */
|
|
45
|
+
author?: string;
|
|
46
|
+
/** Copyright notice / rights statement. */
|
|
47
|
+
copyright?: string;
|
|
48
|
+
/** Feed-level image / logo / icon URL. */
|
|
49
|
+
imageUrl?: string;
|
|
50
|
+
/** Detected source format. */
|
|
51
|
+
format: FeedFormat;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Normalized feed item. The exact subset of fields populated depends on
|
|
55
|
+
* what the source feed publishes; only `id` is guaranteed (synthesized from
|
|
56
|
+
* `link` or content hash if no GUID is present).
|
|
57
|
+
*/
|
|
58
|
+
export interface NormalizedFeedItem {
|
|
59
|
+
/**
|
|
60
|
+
* Stable, opaque identifier for deduplication.
|
|
61
|
+
* Source-of-truth order: `<guid>` / `<id>` / JSON Feed `id` →
|
|
62
|
+
* `link` URL → SHA-1 hash of `title + publishedAt`. Always non-empty.
|
|
63
|
+
*/
|
|
64
|
+
id: string;
|
|
65
|
+
/** Item title. Empty string if the source omitted a title. */
|
|
66
|
+
title: string;
|
|
67
|
+
/** Canonical URL the item points to. */
|
|
68
|
+
link?: string;
|
|
69
|
+
/** Short summary / description / excerpt — sanitized HTML or plain text. */
|
|
70
|
+
summary?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Full content body — sanitized HTML (`<script>` tags stripped at minimum).
|
|
73
|
+
* Source: `<content:encoded>`, Atom `<content>`, JSON Feed `content_html`/`content_text`.
|
|
74
|
+
*/
|
|
75
|
+
content?: string;
|
|
76
|
+
/** Author display name reported by the item. */
|
|
77
|
+
author?: string;
|
|
78
|
+
/** ISO 8601 timestamp of publication. */
|
|
79
|
+
publishedAt?: string;
|
|
80
|
+
/** ISO 8601 timestamp of the last update / modification. */
|
|
81
|
+
updatedAt?: string;
|
|
82
|
+
/** Tags / categories / keywords assigned to the item. */
|
|
83
|
+
categories?: string[];
|
|
84
|
+
/** Attached media (podcast audio, embedded images, etc.). */
|
|
85
|
+
enclosures?: FeedEnclosure[];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Parsed feed result returned by {@link parseFeed}.
|
|
89
|
+
*/
|
|
90
|
+
export interface ParsedFeed {
|
|
91
|
+
/** Normalized feed-level metadata. */
|
|
92
|
+
feed: NormalizedFeed;
|
|
93
|
+
/** Items in source order (NOT re-sorted by date). */
|
|
94
|
+
items: NormalizedFeedItem[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Options controlling {@link parseFeed} behavior.
|
|
98
|
+
*/
|
|
99
|
+
export interface ParseFeedOptions {
|
|
100
|
+
/**
|
|
101
|
+
* Hint for the source format. When omitted, format detection runs:
|
|
102
|
+
* 1. If `contentType` includes `json` or body trims to `{` / `[` → JSON Feed.
|
|
103
|
+
* 2. XML is parsed and the root element selects `rss-2.0` / `atom-1.0` / `rdf-1.0`.
|
|
104
|
+
*
|
|
105
|
+
* Pass `contentType` from the HTTP response when available; it short-circuits
|
|
106
|
+
* the JSON vs. XML guess on ambiguous payloads.
|
|
107
|
+
*/
|
|
108
|
+
contentType?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Optional explicit format override. Skips detection entirely.
|
|
111
|
+
*/
|
|
112
|
+
format?: FeedFormat;
|
|
113
|
+
/**
|
|
114
|
+
* When `true` (default), strips `<script>` blocks from any HTML content
|
|
115
|
+
* fields (`summary`, `content`). Set to `false` only when the caller
|
|
116
|
+
* performs sanitization downstream.
|
|
117
|
+
*/
|
|
118
|
+
sanitizeHtml?: boolean;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Error raised when {@link parseFeed} cannot determine the source format
|
|
122
|
+
* or the body is malformed.
|
|
123
|
+
*/
|
|
124
|
+
export declare class FeedParseError extends Error {
|
|
125
|
+
/** Detected or supplied format hint at the point of failure, if any. */
|
|
126
|
+
format?: FeedFormat;
|
|
127
|
+
constructor(message: string, format?: FeedFormat);
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,WAAW,CAAA;AAEzE;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAA;IAEX,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;IAEb,wDAAwD;IACxD,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd,kFAAkF;IAClF,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAA;IAEb,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAA;IAEpB,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAA;IAEb,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAA;IAEhB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB,0DAA0D;IAC1D,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,8BAA8B;IAC9B,MAAM,EAAE,UAAU,CAAA;CACnB;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAA;IAEV,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAA;IAEb,wCAAwC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAA;IAEb,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAA;IAEhB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAEhB,gDAAgD;IAChD,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf,yCAAyC;IACzC,WAAW,CAAC,EAAE,MAAM,CAAA;IAEpB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IAErB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,aAAa,EAAE,CAAA;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,sCAAsC;IACtC,IAAI,EAAE,cAAc,CAAA;IAEpB,qDAAqD;IACrD,KAAK,EAAE,kBAAkB,EAAE,CAAA;CAC5B;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IAEpB;;OAEG;IACH,MAAM,CAAC,EAAE,UAAU,CAAA;IAEnB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED;;;GAGG;AACH,qBAAa,cAAe,SAAQ,KAAK;IACvC,wEAAwE;IACxE,MAAM,CAAC,EAAE,UAAU,CAAA;gBAEP,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,UAAU;CAKjD"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error raised when {@link parseFeed} cannot determine the source format
|
|
3
|
+
* or the body is malformed.
|
|
4
|
+
*/
|
|
5
|
+
export class FeedParseError extends Error {
|
|
6
|
+
/** Detected or supplied format hint at the point of failure, if any. */
|
|
7
|
+
format;
|
|
8
|
+
constructor(message, format) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'FeedParseError';
|
|
11
|
+
this.format = format;
|
|
12
|
+
}
|
|
13
|
+
}
|