Support for Atom feeds.

Closes: #2
This commit is contained in:
Fabio Manganiello 2023-01-20 23:17:14 +01:00
parent 55397b10d7
commit 897d12ed4e
3 changed files with 221 additions and 94 deletions

View file

@ -38,13 +38,14 @@ const onFeedDownloaded = (req: XMLHttpRequest) => {
await browser.tabs.sendMessage( await browser.tabs.sendMessage(
tab.id, { tab.id, {
type: 'renderFeed', type: 'renderFeed',
url: req.responseURL,
document: req.responseText document: req.responseText
} }
) )
} }
} }
const renderFeed = (url: string) => { const downloadFeed = (url: string) => {
state.awaitingResponse = true state.awaitingResponse = true
const req = new XMLHttpRequest() const req = new XMLHttpRequest()
req.onload = onFeedDownloaded(req) req.onload = onFeedDownloaded(req)
@ -66,7 +67,7 @@ const updateFeedUrl = (tabId: number, feedUrl: string | null) => {
browser.pageAction.onClicked.addListener( browser.pageAction.onClicked.addListener(
async () => { async () => {
if (state.feedUrl?.length) if (state.feedUrl?.length)
renderFeed(state.feedUrl) downloadFeed(state.feedUrl)
} }
) )
@ -74,8 +75,8 @@ browser.webNavigation.onCompleted.addListener(
async (event: {tabId: number}) => { async (event: {tabId: number}) => {
const { tabId } = event const { tabId } = event
const feedUrl = await browser.tabs.sendMessage(tabId, {type: 'extractFeedUrl'}) const feedUrl = await browser.tabs.sendMessage(tabId, {type: 'extractFeedUrl'})
await browser.tabs.sendMessage(tabId, {type: 'renderFeed', url: feedUrl})
updateFeedUrl(tabId, feedUrl) updateFeedUrl(tabId, feedUrl)
await browser.tabs.sendMessage(tabId, {type: 'renderFeed'})
} }
) )
@ -92,8 +93,11 @@ browser.webRequest.onHeadersReceived.addListener(
h => h.name.toLowerCase() === 'content-type' h => h.name.toLowerCase() === 'content-type'
)?.value || '' )?.value || ''
if (contentType.startsWith('application/rss+xml')) if (
renderFeed(url) contentType.startsWith('application/rss+xml') ||
contentType.startsWith('application/atom+xml')
)
downloadFeed(url)
}, },
{urls: ['<all_urls>']}, {urls: ['<all_urls>']},
['blocking', 'responseHeaders'] ['blocking', 'responseHeaders']

View file

@ -1,36 +1,119 @@
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
const parseItemImage = (item: Element) => { // Parse a feed, given as a DOM element
const images = const parseFeed = (feed: Element, url: string | null) => {
Array.from(item.getElementsByTagName('media:content')) if (feed.tagName.toLowerCase() == 'channel')
.filter((content) => return parseRSSFeed(feed, url)
(content.getAttribute('type') || '').startsWith('image/') ||
content.getAttribute('medium') === 'image'
)
if (!images.length) return parseAtomFeed(feed, url)
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 // Parse an RSS feed
}, { const parseRSSFeed = (feed: Element, url: string | null) => {
width: parseFloat(images[0].getAttribute('width') || '0'), const imageElement = feed.getElementsByTagName('image')[0]
url: images[0].getAttribute('url'), 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 { return {
url: url feedData: {
title: getNodeContent(feed, 'title'),
description: getNodeContent(feed, 'subtitle'),
feedUrl: url?.length ? url : window.location.href,
homeUrl: homeURL,
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))
}
} }
} }
const pubDateToInterval = (item: Element) => { // Convert relative URLs to absolute
const dateStr = getNodeContent(item, 'pubDate') 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) if (!dateStr?.length)
return return
@ -61,53 +144,62 @@ const pubDateToInterval = (item: Element) => {
return `${interval.toFixed(0)} ${unit}` return `${interval.toFixed(0)} ${unit}`
} }
const getNodeContent = (parent: Element, tagName: string) => // 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 // @ts-ignore
parent.getElementsByTagName(tagName)[0]?.firstChild?.wholeText return Array.from(parent.children).
filter(
const parseFeed = (channel: Element) => { (e) =>
const imageElement = channel.getElementsByTagName('image')[0] e.tagName.toLowerCase() == 'link' &&
const itemTime = (item: {pubDate: string}) => { (e.getAttribute('type') || '').toLowerCase().startsWith(type.toLowerCase()) &&
const dateStr = item.pubDate (e.getAttribute('href') || '').length > 0
if (!dateStr?.length) ).
return 0 map((e) => toAbsoluteURL(e.getAttribute('href'))).
return (new Date(dateStr)).getTime() filter((l) => l != null)
}
return {
feedData: {
title: getNodeContent(channel, 'title'),
description: getNodeContent(channel, 'description'),
feedUrl: window.location.href,
homeUrl: getNodeContent(channel, 'link'),
image: imageElement ? {
title: getNodeContent(imageElement, 'title'),
imageUrl: getNodeContent(imageElement, 'url'),
targetUrl: getNodeContent(imageElement, 'link'),
} : null,
items: Array.from(channel.getElementsByTagName('item')).map((item) => {
return {
title: getNodeContent(item, 'title'),
description: getNodeContent(item, 'description'),
url: getNodeContent(item, 'link'),
image: parseItemImage(item),
pubDate: getNodeContent(item, 'pubDate'),
age: pubDateToInterval(item),
categories: Array.from(item.getElementsByTagName('category')).map((cat) =>
cat.firstChild?.textContent
),
}
}).sort((a, b) => itemTime(b) - itemTime(a))
}
}
} }
// Get the RSS/Atom root element of the current page, if available
const getFeedRoot = (): HTMLElement | null => { const getFeedRoot = (): HTMLElement | null => {
const xmlDoc = document.documentElement const xmlDoc = document.documentElement
// Check if it's an RSS feed
if (xmlDoc.tagName.toLowerCase() === 'rss') if (xmlDoc.tagName.toLowerCase() === 'rss')
return xmlDoc 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 // Chrome-based browsers may wrap the XML into an HTML view
const webkitSource = document.getElementById('webkit-xml-viewer-source-xml') const webkitSource = document.getElementById('webkit-xml-viewer-source-xml')
if (webkitSource) if (webkitSource)
@ -121,6 +213,7 @@ const getFeedRoot = (): HTMLElement | null => {
return preElements[0] return preElements[0]
} }
// Convert an XML string to a DOM object if it's a valid feed
const textToDOM = (text: string) => { const textToDOM = (text: string) => {
const parser = new DOMParser() const parser = new DOMParser()
let xmlDoc = null let xmlDoc = null
@ -135,55 +228,65 @@ const textToDOM = (text: string) => {
// @ts-ignore // @ts-ignore
const root = xmlDoc.documentElement const root = xmlDoc.documentElement
if (root.tagName.toLowerCase() === 'rss') if (
root.tagName.toLowerCase() === 'rss' ||
root.tagName.toLowerCase() === 'feed'
)
return root return root
} }
const renderFeed = (text: string) => { // 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() const xmlDoc = text?.length ? textToDOM(text) : getFeedRoot()
if (!xmlDoc) if (!xmlDoc)
// Not an RSS feed // Not a feed
return return
const channel = xmlDoc.getElementsByTagName('channel')[0] // Check if it's an RSS feed
if (!channel) let feed = xmlDoc.getElementsByTagName('channel')[0]
if (!feed) {
// Check if it's an Atom feed
if (xmlDoc.tagName.toLowerCase() !== 'feed')
return return
browser.storage.local.set(parseFeed(channel)) 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') window.location.href = browser.runtime.getURL('viewer/index.html')
} }
// Extract any feed URL published on the page
const extractFeedUrl = () => { const extractFeedUrl = () => {
const links = Array.from(document.getElementsByTagName('link')) const links = Array.from(document.getElementsByTagName('link'))
.filter((link) => .filter((link) =>
link.getAttribute('rel') === 'alternate' && link.getAttribute('rel') === 'alternate' &&
link.getAttribute('type')?.startsWith('application/rss+xml') (
link.getAttribute('type')?.startsWith('application/rss+xml') ||
link.getAttribute('type')?.startsWith('application/atom+xml')
)
) )
if (!links.length) if (!links.length)
return return
let link = links[0].getAttribute('href') || '' return toAbsoluteURL(links[0].getAttribute('href'))
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.length ? link : null
} }
// Main message listener
browser.runtime.onMessage.addListener( browser.runtime.onMessage.addListener(
async ( async (
message: { message: {
type: string, type: string,
url: string, url: string | null,
document: string, document: string,
} }
) => { ) => {
if (message.type === 'renderFeed') if (message.type === 'renderFeed')
return renderFeed(message.document) return renderFeed(message.document, message.url)
if (message.type === 'extractFeedUrl') if (message.type === 'extractFeedUrl')
return extractFeedUrl() return extractFeedUrl()

View file

@ -3,17 +3,21 @@
<header> <header>
<div class="top"> <div class="top">
<a :href="feed.homeUrl" target="_blank" v-if="feed.title?.length"> <a :href="feed.homeUrl" target="_blank" v-if="feed.title?.length">
<h1 v-text="feed.title" /> <h1 v-html="feed.title" v-if="feed.html.title" />
<h1 v-text="feed.title" v-else />
</a> </a>
<a :href="feed.feedUrl" class="feed-url" v-if="feed.feedUrl?.length"> <a :href="feed.feedUrl" class="feed-url" v-if="feed.feedUrl?.length">
(Feed URL) (Feed URL)
</a> </a>
<a :href="feed.image.targetUrl" class="image" target="_blank" v-if="feed.image"> <a :href="feed.image.targetUrl" class="image" target="_blank" v-if="feed.image?.imageUrl">
<img :src="feed.image.imageUrl" alt="feed.image.title"> <img :src="feed.image.imageUrl" :alt="feed.image.title">
</a> </a>
</div> </div>
<h2 v-if="feed.description?.length" v-text="feed.description" /> <div class="description" v-if="feed.description?.length">
<h2 v-if="feed.html.description" v-html="feed.description" />
<h2 v-else v-text="feed.description" />
</div>
</header> </header>
<main> <main>
@ -21,10 +25,11 @@
<div class="header" :class="{expanded: expandedItems[i]}" <div class="header" :class="{expanded: expandedItems[i]}"
@click="expandedItems[i] = !expandedItems[i]"> @click="expandedItems[i] = !expandedItems[i]">
<h2 v-if="item.title?.length"> <h2 v-if="item.title?.length">
<a :href="item.url" target="_blank" v-text="item.title" /> <a :href="item.url" target="_blank" v-html="item.title" v-if="item.html.title" />
<a :href="item.url" target="_blank" v-text="item.title" v-else />
</h2> </h2>
<div class="age" v-if="item.age"> <div class="age" v-if="item.age" :title="new Date(item.pubDate).toLocaleString()">
Published {{ item.age }} ago Published {{ item.age }} ago
</div> </div>
@ -65,6 +70,9 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.feed { .feed {
display: flex;
flex-direction: column;
height: 100vh;
font-family: -apple-system, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Open Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: -apple-system, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Open Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
} }
@ -106,6 +114,11 @@ main {
background: #f4f4f4; background: #f4f4f4;
margin-top: 0.25em; margin-top: 0.25em;
padding-top: 2em; padding-top: 2em;
display: flex;
flex-direction: column;
flex-grow: 1;
align-items: center;
overflow: auto;
.image-container { .image-container {
width: 100%; width: 100%;
@ -119,6 +132,8 @@ main {
} }
.item { .item {
width: 100%;
max-width: 70em;
background: white; background: white;
margin: 0 1em 1.5em 1em; margin: 0 1em 1.5em 1em;
border-radius: 1em; border-radius: 1em;
@ -141,6 +156,11 @@ main {
.age { .age {
font-size: 0.9em; font-size: 0.9em;
margin-top: -0.5em; margin-top: -0.5em;
cursor: pointer;
&:hover {
color: #186536;
}
} }
.categories { .categories {