298 lines
8.5 KiB
TypeScript
298 lines
8.5 KiB
TypeScript
import browser from 'webextension-polyfill';
|
|
|
|
// Parse a feed, given as a DOM element
|
|
const parseFeed = (feed: Element, url: string | null) => {
|
|
if (feed.tagName.toLowerCase() == 'channel')
|
|
return parseRSSFeed(feed, url)
|
|
|
|
return parseAtomFeed(feed, url)
|
|
}
|
|
|
|
// Parse an RSS feed
|
|
const parseRSSFeed = (feed: Element, url: string | null) => {
|
|
const imageElement = feed.getElementsByTagName('image')[0]
|
|
return {
|
|
feedData: {
|
|
title: getNodeContent(feed, 'title'),
|
|
description: getNodeContent(feed, 'description'),
|
|
feedUrl: url?.length ? url : window.location.href,
|
|
homeUrl: getNodeContent(feed, 'link'),
|
|
image: imageElement ? {
|
|
title: getNodeContent(imageElement, 'title'),
|
|
imageUrl: getNodeContent(imageElement, 'url'),
|
|
targetUrl: getNodeContent(imageElement, 'link'),
|
|
} : null,
|
|
html: {
|
|
title: false,
|
|
description: false,
|
|
},
|
|
|
|
items: Array.from(feed.getElementsByTagName('item')).map((item) => {
|
|
return {
|
|
title: getNodeContent(item, 'title'),
|
|
description: getNodeContent(item, 'description'),
|
|
url: getNodeContent(item, 'link'),
|
|
image: parseRSSItemImage(item),
|
|
pubDate: getNodeContent(item, 'pubDate'),
|
|
age: pubDateToInterval(getNodeContent(item, 'pubDate'),),
|
|
categories: Array.from(item.getElementsByTagName('category')).map((cat) =>
|
|
cat.firstChild?.textContent
|
|
),
|
|
html: {
|
|
title: false,
|
|
},
|
|
}
|
|
}).sort((a, b) => itemTime(b) - itemTime(a))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse an Atom feed
|
|
const parseAtomFeed = (feed: Element, url: string | null) => {
|
|
const homeURL = getAtomLinksByType(feed, 'text/html')?.[0]
|
|
const logoURL = toAbsoluteURL(getNodeContent(feed, 'logo'))
|
|
const iconURL = toAbsoluteURL(getNodeContent(feed, 'icon'))
|
|
|
|
return {
|
|
feedData: {
|
|
title: getNodeContent(feed, 'title'),
|
|
description: getNodeContent(feed, 'subtitle'),
|
|
feedUrl: url?.length ? url : window.location.href,
|
|
homeUrl: homeURL,
|
|
iconUrl: iconURL,
|
|
image: {
|
|
imageUrl: logoURL || iconURL,
|
|
targetUrl: homeURL,
|
|
},
|
|
html: {
|
|
title: feed.getElementsByTagName('title')[0]?.getAttribute('type') === 'html',
|
|
description: feed.getElementsByTagName('subtitle')[0]?.getAttribute('type') === 'html',
|
|
},
|
|
items: Array.from(feed.getElementsByTagName('entry')).map((item) => {
|
|
return {
|
|
title: getNodeContent(item, 'title'),
|
|
description: getNodeContent(item, 'content') || getNodeContent(item, 'summary'),
|
|
url: getAtomLinksByType(item, 'text/html')?.[0],
|
|
image: getAtomLinksByType(item, 'image/')?.[0],
|
|
pubDate: getNodeContent(item, 'updated'),
|
|
age: pubDateToInterval(getNodeContent(item, 'updated')),
|
|
categories: Array.from(item.getElementsByTagName('category')).map((cat) =>
|
|
cat.firstChild?.textContent
|
|
),
|
|
html: {
|
|
title: item.getElementsByTagName('title')[0]?.getAttribute('type') === 'html',
|
|
},
|
|
}
|
|
}).sort((a, b) => itemTime(b) - itemTime(a))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert relative URLs to absolute
|
|
const toAbsoluteURL = (link: string | null): string | null => {
|
|
if (link?.length && !link.match(/^https?:\/\//)) {
|
|
let port = window.location.port
|
|
if (port.length)
|
|
port = `:${port}`
|
|
link = `${window.location.protocol}//${window.location.hostname}${port}${link}`
|
|
}
|
|
|
|
return link
|
|
}
|
|
|
|
// Get the raw text content of an XML node
|
|
const getNodeContent = (parent: Element, tagName: string) =>
|
|
// @ts-ignore
|
|
parent.getElementsByTagName(tagName)[0]?.firstChild?.wholeText
|
|
|
|
// Extract the publication time of an item as a timestamp
|
|
const itemTime = (item: {pubDate: string}) => {
|
|
const dateStr = item.pubDate
|
|
if (!dateStr?.length)
|
|
return 0
|
|
return (new Date(dateStr)).getTime()
|
|
}
|
|
|
|
// Convert the publication date to an age string
|
|
const pubDateToInterval = (dateStr: string) => {
|
|
if (!dateStr?.length)
|
|
return
|
|
|
|
// @ts-ignore
|
|
let interval = ((new Date()) - (new Date(dateStr))) / 1000
|
|
let unit = 'seconds'
|
|
|
|
if (interval >= 60) {
|
|
interval /= 60
|
|
unit = 'minutes'
|
|
}
|
|
|
|
if (unit == 'minutes' && interval >= 60) {
|
|
interval /= 60
|
|
unit = 'hours'
|
|
}
|
|
|
|
if (unit == 'hours' && interval >= 24) {
|
|
interval /= 24
|
|
unit = 'days'
|
|
}
|
|
|
|
if (unit == 'days' && interval >= 30) {
|
|
interval /= 30
|
|
unit = 'months'
|
|
}
|
|
|
|
return `${interval.toFixed(0)} ${unit}`
|
|
}
|
|
|
|
// Extract the main image of an RSS item
|
|
const parseRSSItemImage = (item: Element) => {
|
|
const images =
|
|
Array.from(item.getElementsByTagName('media:content'))
|
|
.filter((content) =>
|
|
(content.getAttribute('type') || '').startsWith('image/') ||
|
|
content.getAttribute('medium') === 'image'
|
|
)
|
|
|
|
if (!images.length)
|
|
return
|
|
|
|
const { url } = images.reduce((maxImage, content) => {
|
|
const width = parseFloat(content.getAttribute('width') || '0')
|
|
if (width > maxImage.width) {
|
|
maxImage.url = content.getAttribute('url') || ''
|
|
maxImage.width = width
|
|
}
|
|
|
|
return maxImage
|
|
}, {
|
|
width: parseFloat(images[0].getAttribute('width') || '0'),
|
|
url: images[0].getAttribute('url'),
|
|
})
|
|
|
|
return {
|
|
url: url
|
|
}
|
|
}
|
|
|
|
// Get the <link> HREFs of an Atom element
|
|
const getAtomLinksByType = (parent: Element, type: string): Array<string> => {
|
|
// @ts-ignore
|
|
return Array.from(parent.children).
|
|
filter(
|
|
(e) =>
|
|
e.tagName.toLowerCase() == 'link' &&
|
|
(e.getAttribute('type') || '').toLowerCase().startsWith(type.toLowerCase()) &&
|
|
(e.getAttribute('href') || '').length > 0
|
|
).
|
|
map((e) => toAbsoluteURL(e.getAttribute('href'))).
|
|
filter((l) => l != null)
|
|
}
|
|
|
|
// Get the RSS/Atom root element of the current page, if available
|
|
const getFeedRoot = (): HTMLElement | null => {
|
|
const xmlDoc = document.documentElement
|
|
|
|
// Check if it's an RSS feed
|
|
if (xmlDoc.tagName.toLowerCase() === 'rss')
|
|
return xmlDoc
|
|
|
|
// Check if it's an Atom feed
|
|
if (xmlDoc.tagName.toLowerCase() === 'feed')
|
|
return xmlDoc
|
|
|
|
// Chrome-based browsers may wrap the XML into an HTML view
|
|
const webkitSource = document.getElementById('webkit-xml-viewer-source-xml')
|
|
if (webkitSource)
|
|
return webkitSource
|
|
|
|
// For some ugly reasons, some RSS feeds are rendered inside of a <pre> in a normal HTML DOM
|
|
const preElements = document.getElementsByTagName('pre')
|
|
if (preElements.length !== 1)
|
|
return null
|
|
|
|
return preElements[0]
|
|
}
|
|
|
|
// Convert an XML string to a DOM object if it's a valid feed
|
|
const textToDOM = (text: string) => {
|
|
const parser = new DOMParser()
|
|
let xmlDoc = null
|
|
|
|
try {
|
|
// @ts-ignore
|
|
xmlDoc = parser.parseFromString(text, 'text/xml')
|
|
} catch (e) { }
|
|
|
|
if (!xmlDoc)
|
|
return
|
|
|
|
// @ts-ignore
|
|
const root = xmlDoc.documentElement
|
|
if (
|
|
root.tagName.toLowerCase() === 'rss' ||
|
|
root.tagName.toLowerCase() === 'feed'
|
|
)
|
|
return root
|
|
}
|
|
|
|
// Render a feed. It accepts an XML string as an argument.
|
|
// If not passed, it will try to render any feeds on the current page.
|
|
const renderFeed = (text: string, url: string | null) => {
|
|
const xmlDoc = text?.length ? textToDOM(text) : getFeedRoot()
|
|
if (!xmlDoc)
|
|
// Not a feed
|
|
return
|
|
|
|
// Check if it's an RSS feed
|
|
let feed = xmlDoc.getElementsByTagName('channel')[0]
|
|
|
|
if (!feed) {
|
|
// Check if it's an Atom feed
|
|
if (xmlDoc.tagName.toLowerCase() !== 'feed')
|
|
return
|
|
|
|
feed = xmlDoc
|
|
}
|
|
|
|
// Save the parsed feed to the storage and redirect to the viewer
|
|
browser.storage.local.set(parseFeed(feed, url))
|
|
window.location.href = browser.runtime.getURL('viewer/index.html')
|
|
}
|
|
|
|
// Extract any feed URL published on the page
|
|
const extractFeedUrl = () => {
|
|
const links = Array.from(document.getElementsByTagName('link'))
|
|
.filter((link) =>
|
|
link.getAttribute('rel') === 'alternate' &&
|
|
(
|
|
link.getAttribute('type')?.startsWith('application/rss+xml') ||
|
|
link.getAttribute('type')?.startsWith('application/atom+xml')
|
|
)
|
|
)
|
|
|
|
if (!links.length)
|
|
return
|
|
|
|
return toAbsoluteURL(links[0].getAttribute('href'))
|
|
}
|
|
|
|
// Main message listener
|
|
browser.runtime.onMessage.addListener(
|
|
async (
|
|
message: {
|
|
type: string,
|
|
url: string | null,
|
|
document: string,
|
|
}
|
|
) => {
|
|
if (message.type === 'renderFeed')
|
|
return renderFeed(message.document, message.url)
|
|
if (message.type === 'extractFeedUrl')
|
|
return extractFeedUrl()
|
|
|
|
console.warn(`Received unknown message type: ${message.type}`)
|
|
}
|
|
)
|
|
|