alitu-podparse
Version:
Clone of the popular package podparse, with some bugfixes and improvements. Credits go to the original author Thomas Barrasso <tom@barrasso.me>
811 lines (701 loc) • 24 kB
JavaScript
/* eslint-env node */
const parseXml = require('@rgrove/parse-xml');
// === Utilities ===
const SERIAL = 'serial';
// Five predefined XML entities supported by parse-xml,
// see: https://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references#Predefined_entities_in_XML
// Additional entities determined empirically.
const entityMap = new Map([
[' ', ' '],
['â', 'Â'],
['—', '—'],
['–', '–'],
['‐', '‐'],
['‐', '‐'],
['…', '…'],
['“', '“'],
['”', '”'],
['‹', '‹'],
['›', '›'],
['»', '»'],
['«', '«'],
['•', '•'],
['Ã', 'Ã'],
['à', 'à'],
['á', 'á'],
['™', '™'],
['←', '←'],
['¢', '¢'],
['£', '£'],
['’', '’'],
['‘', '‘'],
['‌', String.fromCodePoint(8204)],
['‍', String.fromCodePoint(8205)],
['&wj;', String.fromCodePoint(8288)],
['→', '→'],
['©', '©'],
['℗', '℗'],
['é', 'é'],
['Ç', 'Ç'],
['ç', 'ç'],
['Â', 'Â'],
['€', '€'],
['Ø', 'Ø'],
['ø', 'ø'],
['·', '·'],
['Ä', 'Ä'],
['ä', 'ä'],
]);
function fromEntries(entries) {
return entries.reduce((main, [key, value]) => ({ ...main, [key]: value }), {});
}
function isEmptyString(str) {
return (typeof str !== 'string' || str.length === 0);
}
const isNotEmptyString = (str) => !isEmptyString(str);
// String#replaceAll introduced Node 15+
function replaceAll(str, find, replace) {
return str.replace(new RegExp(find, 'g'), replace);
}
const removeEmpties = (obj) => Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== null
&& v !== undefined
&& !Number.isNaN(v)
&& v !== ''
&& !(Array.isArray(v) && v.length === 0)),
);
function isEmptyValue(v) {
return (
(v === null || v === undefined)
|| Number.isNaN(v)
|| (typeof v === 'string' && v.trim().length === 0)
|| (Array.isArray(v) && v.length === 0)
);
}
const isTrue = (value) => ['TRUE', 'true', true, 1].includes(value);
const isValidLiveStatus = (value) => ['pending', 'live', 'ended'].includes(value);
const isValidLiveItem = (liveItem) => (
liveItem
&& isNotEmptyString(liveItem.status)
&& isValidLiveStatus(liveItem.status)
&& isNotEmptyString(liveItem.start)
&& isNotEmptyString(liveItem.end)
);
const uniq = (arr) => Array.from(new Set(arr));
// === Document Navigation ===
// Get text content for a given node
function getTextForNode({ children }) {
if (!children || children.length === 0) { return undefined; }
const textNode = children.find(({ type }) => type === 'text');
return (textNode) ? textNode.text.trim() : textNode;
}
// Get text content for a the first node
function getText([node]) {
if (!node) { return undefined; }
return getTextForNode(node);
}
// Get attribute from first node
function getAttribute([node], attrName) {
if (!node) { return undefined; }
return node.attributes[attrName];
}
// Get text content and parse as ISO date
function getDate(nodes) {
const dateString = getText(nodes);
try {
return new Date(dateString).toISOString();
} catch (error) { // RangeError
return dateString;
}
}
// Get text content and parse as a Number
const getInteger = (nodes) => Number.parseInt(getText(nodes), 10);
const getFloat = (nodes) => Number.parseFloat(getText(nodes), 10);
function isYes(nodes) {
const string = getText(nodes);
if (isEmptyString(string)) { return undefined; }
return (string.toLowerCase() === 'yes');
}
// Find the first node with a given name
const findNode = (node, elName) => node.children.find(({ name }) => elName === name);
// Like findNode but throws an error if element is missing
const findNodeOrThrow = (node, elName) => {
const child = findNode(node, elName);
if (!child) {
throw new Error(`Missing required <${elName}> element.`);
}
return child;
};
// Find all nodes with a given name
const findAllNodes = (node, elName) => node.children.filter(({ name }) => elName === name);
// Case-insensitive match checks both prefixes and suffixes.
// This is to get around XML namespace concerns.
function nodeNameLike(aName, bName) {
return (
aName === bName
|| aName.startsWith(`${bName}:`)
|| aName.endsWith(`:${bName}`)
);
}
// Find all nodes with a name similar to a given name.
function findNodesLike(node, elName) {
const nameUpper = elName.toUpperCase();
return node.children.filter(({ name }) => {
if (typeof name !== 'string') { return false; }
return nodeNameLike(name.toUpperCase(), nameUpper);
});
}
// PodLove Simple Chapters
function getChapterElements(chapterNode) {
const chapter = {
start: getAttribute([chapterNode], 'start'),
};
const title = getAttribute([chapterNode], 'title');
const href = getAttribute([chapterNode], 'href');
const image = getAttribute([chapterNode], 'image');
if (!isEmptyString(title)) { chapter.title = title; }
if (!isEmptyString(href)) { chapter.href = href; }
if (!isEmptyString(image)) { chapter.image = image; }
return chapter;
}
// Sorts episodes by order first, then sorts by date.
// If multiple episodes were published at the same time,
// they are then sorted by title.
function episodeComparator(a, b) {
if (a.order === b.order) {
if (a.pubDate === b.pubDate) {
return (a.title > b.title) ? -1 : 1;
}
return (b.pubDate > a.pubDate) ? 1 : -1;
}
if (a.order && !b.order) { return 1; }
if (b.order && !a.order) { return -1; }
return (a.order > b.order) ? -1 : 1;
}
// For itunes:type = "serial", use reverse chronological order
// to keep seasons in order
function serialComparator(a, b) {
// Same season, sort by episode
if (a.season === b.season) {
if (!(a.episode && b.episode)) {
if (a.pubDate === b.pubDate) {
return (a.title > b.title) ? -1 : 1;
}
return (b.pubDate > a.pubDate) ? -1 : 1;
}
return (a.episode > b.episode) ? 1 : -1;
}
// Default to reverse chronological
if (!(a.season && b.season)) {
if (a.pubDate === b.pubDate) {
return (a.title > b.title) ? -1 : 1;
}
return (b.pubDate > a.pubDate) ? -1 : 1;
}
return (a.season > b.season) ? 1 : -1;
}
// === RSS Transformation ===
const rssElements = Object.freeze({
title: getText,
description: getText,
subtitle: getText,
language: getText,
author: getText,
summary: getText,
copyright: getText,
managingEditor: getText,
webMaster: getText,
episodeType: getText, // full, trailer, or bonus
type: getText, // episodic or serial
guid: getText,
generator: getText,
countryOfOrigin: getText, // spotify
txt: getText,
limit: ([node]) => { // spotify
if (!(node && node.children)) { return undefined; }
return Number.parseInt(getAttribute([node], 'recentCount'), 10);
},
chapters: ([node]) => {
if (!node) { return undefined; }
// PodLove Chapters
if (node.children && node.children.length > 0) {
const chapterNodes = findNodesLike(node, 'chapter');
return chapterNodes.map(getChapterElements);
}
// Podcast Namespace Chapters
const url = getAttribute([node], 'url');
const type = getAttribute([node], 'type');
// Both url and type are required
if (isEmptyString(url) || isEmptyString(type)) {
return undefined;
}
return removeEmpties({
url,
type,
});
},
thumbnail: (nodes) => (getAttribute(nodes, 'url') || getAttribute(nodes, 'href')),
coverart: (nodes) => getAttribute(nodes, 'href'),
keywords: (nodes) => uniq(
nodes.map(getTextForNode)
.filter(isNotEmptyString)
.map((keywords) => keywords.split(','))
.flat()
.map((keyword) => keyword.trim())
.sort(),
),
category: (nodes) => {
const categories = nodes.map((node) => getAttribute([node], 'text'));
const subcategories = nodes.map((node) => getAttribute(findNodesLike(node, 'category'), 'text'));
const formattedCategories = categories.map((category, i) => {
if (subcategories[i]) {
return `${category} > ${subcategories[i]}`;
}
return category;
});
return uniq(formattedCategories.filter(isNotEmptyString)).sort();
},
owner: ([node]) => {
if (!(node && node.children && node.children.length)) { return undefined; }
return removeEmpties({
name: getText(findNodesLike(node, 'name')),
email: getText(findNodesLike(node, 'email')),
});
},
image: ([node]) => {
if (!(node && node.children)) { return undefined; }
// i.e. <image><url>http://cdn.example.org/mylogo.png</url></image>
const urlNode = findNode(node, 'url');
if (urlNode) {
return removeEmpties({
url: getText([urlNode]),
link: getText([findNode(node, 'link')]),
title: getText([findNode(node, 'title')]),
});
}
// i.e. <itunes:image href="http://cdn.example.org/mylogo.png" />
if (isNotEmptyString(node.attributes.href)) {
return {
url: node.attributes.href,
};
}
// i.e. <image>http://cdn.example.org/mylogo.png</image>
return {
url: getText([node]),
};
},
explicit: (nodes) => {
const nodeStr = getText(nodes);
if (isEmptyString(nodeStr)) { return undefined; }
const explicitString = nodeStr.toLowerCase();
// Values meaning explicit
if (['yes', 'explicit', 'true'].includes(explicitString)) {
return true;
}
// Values meaning not explicit
if (['clean', 'no', 'false'].includes(explicitString)) {
return false;
}
return undefined;
},
complete: isYes,
blocked: isYes,
isClosedCaptioned: isYes,
duration: ([node]) => {
if (!node) { return undefined; }
const dur = getText([node]);
if (isEmptyString(dur)) { return undefined; }
const times = dur.split(':').map(Number);
const [h, m, s] = times;
// Standardize all formats into an amount in seconds
switch (times.length) {
case 3: // i.e. 01:24:13
return (60 * 60 * h) + (60 * m) + s;
case 2: // i.e. 24:13
return (60 * h) + m;
default: // 1399
return h;
}
},
enclosure: ([node]) => {
if (!(node && node.children)) { return undefined; }
// i.e. <enclosure length="28882931" type="audio/mpeg" url="http://cdn.example.org/episode-1.mp3" />
const url = getAttribute([node], 'url');
if (isNotEmptyString(url)) {
return removeEmpties({
length: Number.parseInt(getAttribute([node], 'length'), 10),
type: getAttribute([node], 'type'),
url,
});
}
return undefined;
},
content: ([node]) => {
if (!(node && node.children)) { return undefined; }
// i.e. <media:content url="https://cdn.example.org/episode-1.mp3" fileSize="19745645" type="audio/mpeg" />
const url = getAttribute([node], 'url');
if (isNotEmptyString(url)) {
return removeEmpties({
fileSize: Number.parseInt(getAttribute([node], 'fileSize'), 10),
type: getAttribute([node], 'type'),
url,
});
}
return undefined;
},
order: getInteger,
season: getInteger,
episode: getInteger,
ttl: getInteger,
lastBuildDate: getDate,
pubDate: getDate,
// Omny
clipId: getText,
// Acast (excluding settings, signature)
// See https://learn.acast.com/en/articles/4465740-flex-advanced-encrypted-xml
showId: getText,
showUrl: getText,
episodeUrl: getText,
episodeId: getText,
importedFeed: getText,
// Acast or BBC
network: ([node]) => {
if (!node) { return undefined; }
const nameAttr = getAttribute([node], 'name');
const text = getText([node]);
if (isEmptyString(nameAttr) && isEmptyString(text)) { return undefined; }
const name = (nameAttr || text);
const slug = getAttribute([node], 'slug');
const id = getAttribute([node], 'id');
return removeEmpties({
name,
slug: (isEmptyString(slug) ? undefined : slug),
id: (isEmptyString(id) ? undefined : id),
});
},
// BBC (excluding canonical, enclosureSecure, enclosureLegacy, systemRef)
seriesDetails: ([node]) => {
if (!node) { return undefined; }
const frequency = getAttribute([node], 'frequency');
const daysLive = Number.parseInt(getAttribute([node], 'daysLive'), 10);
return removeEmpties({
frequency: (isEmptyString(frequency) ? undefined : frequency),
daysLive: (Number.isNaN(daysLive) ? undefined : daysLive),
});
},
// SoundOn
importFeedUrl: getText,
updatedAt: getDate,
createdAt: getDate,
deleted: isYes,
exclusive: getText,
facebookUrl: getText,
youtubeUrl: getText,
instagramUrl: getText,
// RadioPublic
cta: (nodes) => nodes.map((node) => {
const headline = getAttribute([node], 'headline');
const subtitle = getAttribute([node], 'subtitle');
const actions = findNodesLike(node, 'action');
if (isEmptyString(headline) && isEmptyString(subtitle)) {
return undefined;
}
return removeEmpties({
headline,
subtitle,
actions: actions.map((action) => removeEmpties({
class: getAttribute([action], 'class'),
disposition: getAttribute([action], 'disposition'),
href: getAttribute([action], 'href'),
label: getAttribute([action], 'label'),
})),
});
}),
// Pingback
receiver: getText,
// GeoRSS
lat: getFloat,
long: getFloat,
point: ([node]) => {
if (!node) { return undefined; }
const pt = getText([node]);
if (isEmptyString(pt)) { return undefined; }
// i.e. '45.256 -71.92'
return pt.split(' ').map(Number); // [45.256, -71.92]
},
// === Podcast 2.0 Namespace ===
locked: isYes, // count = single
// Location
location: ([node]) => {
if (!node) { return undefined; } // count = single
const name = getText([node]); // i.e. Jacksonville, FL, USA
const geoAttr = getAttribute([node], 'geo');
const osm = getAttribute([node], 'osm');
const rel = getAttribute([node], 'rel');
const country = getAttribute([node], 'country');
// Parse geo, i.e. geo:30.3321838,-81.65565099999999
let coords = null;
if (!isEmptyString(geoAttr)) {
if (geoAttr.startsWith('geo:')) {
coords = geoAttr
.substring(4)
.split(',')
.map(Number);
}
}
return removeEmpties({
name, // Jacksonville, FL, USA
geo: coords, // [ 30.3321838, -81.65565099999999 ]
osm, // R113314
rel, // i.e. 'subject'
country, // i.e. 'US'
});
},
// Soundbites
soundbite: (nodes) => nodes.map((node) => removeEmpties({
name: getText([node]), // i.e. Why the Podcast Namespace Matters
startTime: Number.parseFloat(getAttribute([node], 'startTime'), 10), // i.e. 73.0
duration: Number.parseFloat(getAttribute([node], 'duration'), 10), // i.e. 60.0
})).filter(({ startTime, duration }) => !(isEmptyValue(startTime) || isEmptyValue(duration))),
// People
person: (nodes) => nodes.map((node) => removeEmpties({
name: getText([node]), // i.e. Jane Doe
role: getAttribute([node], 'role') || 'host',
group: getAttribute([node], 'group') || 'cast',
img: getAttribute([node], 'img'),
href: getAttribute([node], 'href'),
})).filter(({ name }) => !isEmptyString(name)),
// Transcripts
transcript: (nodes) => nodes.map((node) => removeEmpties({
url: getAttribute([node], 'url'),
type: getAttribute([node], 'type'),
language: getAttribute([node], 'language'),
rel: getAttribute([node], 'rel'),
})).filter(({ url, type }) => !(isEmptyString(url) || isEmptyString(type))),
// Funding
funding: (nodes) => nodes.map((node) => removeEmpties({
name: getText([node]),
url: getAttribute([node], 'url'),
})).filter(({ url, name }) => !(isEmptyString(name) || isEmptyString(url))),
// Host Identifier
id: ([node]) => {
if (!node) { return undefined; } // count = single
// See list of service slugs: https://github.com/Podcastindex-org/podcast-namespace/blob/main/serviceslugs.txt
const platform = getAttribute([node], 'platform');
const id = getAttribute([node], 'id');
const url = getAttribute([node], 'url');
if (isEmptyString(platform) || isEmptyString(id)) {
return undefined;
}
return removeEmpties({
platform,
id,
url,
});
},
// License
license: ([node]) => {
if (!node) { return undefined; } // count = single
const slug = getText([node]);
const url = getAttribute([node], 'url');
if (isEmptyString(slug) && isEmptyString(url)) {
return undefined;
}
return removeEmpties({
slug, url,
});
},
// Medium
medium: ([node]) => {
if (!node) { return undefined; } // count = single
const medium = getText([node]);
if (isEmptyString(medium)) {
return undefined;
}
return medium.toLowerCase();
},
// Gateway
gateway: ([node]) => {
if (!node) { return undefined; } // count = single
const text = getText([node]);
const order = Number.parseInt(getAttribute([node], 'order'), 10);
if (isEmptyString(text)) {
return undefined;
}
if (Number.isNaN(order)) {
return { text };
}
return {
text, order,
};
},
images: ([node]) => {
if (!node) { return undefined; } // count = single
const srcset = getAttribute([node], 'srcset');
if (!srcset || isEmptyString(srcset)) {
return undefined;
}
const normalizedSrcset = replaceAll(srcset, /,(\s+)?/gui, ',\n');
return {
srcset: normalizedSrcset,
};
},
// See https://github.com/Podcastindex-org/podcast-namespace/blob/main/proposal-docs/alternateEnclosure/alternateEnclosure.md
alternateEnclosure: (nodes) => nodes.map((node) => {
// Required attributes
const type = getAttribute([node], 'type');
const length = Number.parseInt(getAttribute([node], 'length'), 10);
// Optional attributes
const bitrate = Number.parseInt(getAttribute([node], 'bitrate'), 10);
const height = Number.parseInt(getAttribute([node], 'height'), 10);
const lang = getAttribute([node], 'lang');
const title = getAttribute([node], 'title');
const rel = getAttribute([node], 'rel');
const codecs = getAttribute([node], 'codecs');
const isDefault = ['TRUE', 'true', true]
.includes(getAttribute([node], 'default'));
// Source elements
const sourceEls = findNodesLike(node, 'source');
const sources = sourceEls.map((source) => removeEmpties({
uri: getAttribute([source], 'uri'),
contentType: getAttribute([source], 'contentType'),
}));
// Integrity elements
const integrityEls = findNodesLike(node, 'integrity');
const integrities = integrityEls.map((integrity) => ({
type: getAttribute([integrity], 'type'),
value: getAttribute([integrity], 'value'),
}));
return removeEmpties({
type,
length,
bitrate,
height,
lang,
title,
rel,
codecs,
default: isDefault,
source: sources,
integrity: integrities,
});
}),
// Trailer
trailer: (nodes) => nodes.map((node) => removeEmpties({
title: getText([node]),
url: getAttribute([node], 'url'),
pubDate: getAttribute([node], 'pubdate'),
length: Number.parseInt(getAttribute([node], 'length'), 10),
type: getAttribute([node], 'type'),
season: Number.parseInt(getAttribute([node], 'season'), 10),
}))
.filter(({ url, pubDate }) => !(isEmptyString(pubDate) || isEmptyString(url)))
.sort(episodeComparator).reverse(),
// Value4Value
value: (nodes) => nodes.map((node) => removeEmpties({
// Required
type: getAttribute([node], 'type'),
method: getAttribute([node], 'method'),
suggested: Number.parseFloat(getAttribute([node], 'suggested'), 10),
valueRecipient: findNodesLike(node, 'valueRecipient')
.map((recipientNode) => removeEmpties({
name: getAttribute([recipientNode], 'name'),
type: getAttribute([recipientNode], 'type'),
address: getAttribute([recipientNode], 'address'),
customKey: getAttribute([recipientNode], 'customKey'),
customValue: getAttribute([recipientNode], 'customValue'),
split: Number.parseFloat(getAttribute([recipientNode], 'split'), 10),
fee: isTrue(getAttribute([recipientNode], 'fee')) || false,
}))
.filter(({ type, address }) => (
isNotEmptyString(type)
&& isNotEmptyString(address)
)),
}))
.filter(({
type, method, valueRecipient,
}) => (
isNotEmptyString(type)
&& isNotEmptyString(method)
&& Array.isArray(valueRecipient)
&& valueRecipient.length > 0)),
// Content Link
contentLink: (nodes) => nodes.map((node) => removeEmpties({
text: getText([node]),
href: getAttribute([node], 'href'),
})).filter(({ text, href }) => isNotEmptyString(text) && isNotEmptyString(href)),
});
// List of supported element names
const supportedElements = Object.keys(rssElements);
// Get an array of links
function getLinksFromChannel(channel) {
return findNodesLike(channel, 'link')
.filter(({ attributes }) => attributes.rel && attributes.href)
.map(({ attributes: { rel, href, type } }) => ({ rel, href, type }));
}
// Parse an individual element
function parseElement(element) {
const parsedValues = supportedElements
.map((elName) => [elName, rssElements[elName](findNodesLike(element, elName))])
.filter(([, v]) => !isEmptyValue(v));
const parsedElement = fromEntries(parsedValues);
// Add <link> if one exact match exists
const mainLink = getText(findAllNodes(element, 'link'));
if (isNotEmptyString(mainLink)) {
parsedElement.link = mainLink;
}
return parsedElement;
}
// Parse an individual liveItem element
function parseLiveElement(element) {
const liveItem = parseElement(element);
liveItem.status = getAttribute([element], 'status');
liveItem.start = getAttribute([element], 'start');
liveItem.end = getAttribute([element], 'end');
return liveItem;
}
// Parse main channel element
function createMetaFromChannel(channel) {
const metaObject = parseElement(channel);
metaObject.links = getLinksFromChannel(channel);
return metaObject;
}
// Parse item elements
const createEpisodesFromItems = (items) => items.map(parseElement).sort(episodeComparator);
const createSeasonsFromItems = (items) => items.map(parseElement).sort(serialComparator);
const createLiveEpisodesFromItems = (items) => items.map(parseLiveElement).sort(episodeComparator);
// Resolve undefined entities
const entityResolver = entityMap.get.bind(entityMap);
// === Main Method ===
const DEFAULT_OPTIONS = Object.freeze({
includeEpisodes: true,
});
module.exports = function getPodcastFromFeed(
feed,
{ includeEpisodes } = DEFAULT_OPTIONS,
) {
const feedObject = parseXml(feed.trim(), {
resolveUndefinedEntity: entityResolver,
});
const rss = findNodeOrThrow(feedObject, 'rss');
const channel = findNodeOrThrow(rss, 'channel');
const meta = createMetaFromChannel(channel);
if (includeEpisodes) {
const isSerial = (meta && meta.type && meta.type.toLowerCase() === SERIAL);
const items = findAllNodes(channel, 'item');
const episodes = (isSerial) ? createSeasonsFromItems(items) : createEpisodesFromItems(items);
// Podcast Index liveItem
const liveItems = findNodesLike(channel, 'liveItem');
const hasLiveItems = (Array.isArray(liveItems) && liveItems.length > 0);
if (hasLiveItems) {
// Parse LiveItems like Items
const liveEpisodes = createLiveEpisodesFromItems(liveItems).filter(isValidLiveItem);
const hasLiveEpisodes = (Array.isArray(liveEpisodes) && liveEpisodes.length > 0);
if (hasLiveEpisodes) {
return { meta, episodes, liveEpisodes };
}
}
return { meta, episodes };
}
return { meta };
};