parent
55397b10d7
commit
897d12ed4e
3 changed files with 221 additions and 94 deletions
|
@ -38,13 +38,14 @@ const onFeedDownloaded = (req: XMLHttpRequest) => {
|
|||
await browser.tabs.sendMessage(
|
||||
tab.id, {
|
||||
type: 'renderFeed',
|
||||
url: req.responseURL,
|
||||
document: req.responseText
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const renderFeed = (url: string) => {
|
||||
const downloadFeed = (url: string) => {
|
||||
state.awaitingResponse = true
|
||||
const req = new XMLHttpRequest()
|
||||
req.onload = onFeedDownloaded(req)
|
||||
|
@ -66,7 +67,7 @@ const updateFeedUrl = (tabId: number, feedUrl: string | null) => {
|
|||
browser.pageAction.onClicked.addListener(
|
||||
async () => {
|
||||
if (state.feedUrl?.length)
|
||||
renderFeed(state.feedUrl)
|
||||
downloadFeed(state.feedUrl)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -74,8 +75,8 @@ browser.webNavigation.onCompleted.addListener(
|
|||
async (event: {tabId: number}) => {
|
||||
const { tabId } = event
|
||||
const feedUrl = await browser.tabs.sendMessage(tabId, {type: 'extractFeedUrl'})
|
||||
await browser.tabs.sendMessage(tabId, {type: 'renderFeed', url: 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'
|
||||
)?.value || ''
|
||||
|
||||
if (contentType.startsWith('application/rss+xml'))
|
||||
renderFeed(url)
|
||||
if (
|
||||
contentType.startsWith('application/rss+xml') ||
|
||||
contentType.startsWith('application/atom+xml')
|
||||
)
|
||||
downloadFeed(url)
|
||||
},
|
||||
{urls: ['<all_urls>']},
|
||||
['blocking', 'responseHeaders']
|
||||
|
|
269
src/main.ts
269
src/main.ts
|
@ -1,36 +1,119 @@
|
|||
import browser from 'webextension-polyfill';
|
||||
|
||||
const parseItemImage = (item: Element) => {
|
||||
const images =
|
||||
Array.from(item.getElementsByTagName('media:content'))
|
||||
.filter((content) =>
|
||||
(content.getAttribute('type') || '').startsWith('image/') ||
|
||||
content.getAttribute('medium') === 'image'
|
||||
)
|
||||
// Parse a feed, given as a DOM element
|
||||
const parseFeed = (feed: Element, url: string | null) => {
|
||||
if (feed.tagName.toLowerCase() == 'channel')
|
||||
return parseRSSFeed(feed, url)
|
||||
|
||||
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
|
||||
}
|
||||
return parseAtomFeed(feed, url)
|
||||
}
|
||||
|
||||
const pubDateToInterval = (item: Element) => {
|
||||
const dateStr = getNodeContent(item, 'pubDate')
|
||||
// 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,
|
||||
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
|
||||
|
||||
|
@ -61,53 +144,62 @@ const pubDateToInterval = (item: Element) => {
|
|||
return `${interval.toFixed(0)} ${unit}`
|
||||
}
|
||||
|
||||
const getNodeContent = (parent: Element, tagName: string) =>
|
||||
// @ts-ignore
|
||||
parent.getElementsByTagName(tagName)[0]?.firstChild?.wholeText
|
||||
// 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'
|
||||
)
|
||||
|
||||
const parseFeed = (channel: Element) => {
|
||||
const imageElement = channel.getElementsByTagName('image')[0]
|
||||
const itemTime = (item: {pubDate: string}) => {
|
||||
const dateStr = item.pubDate
|
||||
if (!dateStr?.length)
|
||||
return 0
|
||||
return (new Date(dateStr)).getTime()
|
||||
}
|
||||
if (!images.length)
|
||||
return
|
||||
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
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)
|
||||
|
@ -121,6 +213,7 @@ const getFeedRoot = (): HTMLElement | 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
|
||||
|
@ -135,55 +228,65 @@ const textToDOM = (text: string) => {
|
|||
|
||||
// @ts-ignore
|
||||
const root = xmlDoc.documentElement
|
||||
if (root.tagName.toLowerCase() === 'rss')
|
||||
if (
|
||||
root.tagName.toLowerCase() === 'rss' ||
|
||||
root.tagName.toLowerCase() === 'feed'
|
||||
)
|
||||
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()
|
||||
if (!xmlDoc)
|
||||
// Not an RSS feed
|
||||
// Not a feed
|
||||
return
|
||||
|
||||
const channel = xmlDoc.getElementsByTagName('channel')[0]
|
||||
if (!channel)
|
||||
return
|
||||
// Check if it's an RSS feed
|
||||
let feed = xmlDoc.getElementsByTagName('channel')[0]
|
||||
|
||||
browser.storage.local.set(parseFeed(channel))
|
||||
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/rss+xml') ||
|
||||
link.getAttribute('type')?.startsWith('application/atom+xml')
|
||||
)
|
||||
)
|
||||
|
||||
if (!links.length)
|
||||
return
|
||||
|
||||
let link = 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
|
||||
return toAbsoluteURL(links[0].getAttribute('href'))
|
||||
}
|
||||
|
||||
// Main message listener
|
||||
browser.runtime.onMessage.addListener(
|
||||
async (
|
||||
message: {
|
||||
type: string,
|
||||
url: string,
|
||||
url: string | null,
|
||||
document: string,
|
||||
}
|
||||
) => {
|
||||
if (message.type === 'renderFeed')
|
||||
return renderFeed(message.document)
|
||||
return renderFeed(message.document, message.url)
|
||||
if (message.type === 'extractFeedUrl')
|
||||
return extractFeedUrl()
|
||||
|
||||
|
|
|
@ -3,17 +3,21 @@
|
|||
<header>
|
||||
<div class="top">
|
||||
<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 :href="feed.feedUrl" class="feed-url" v-if="feed.feedUrl?.length">
|
||||
(Feed URL)
|
||||
</a>
|
||||
<a :href="feed.image.targetUrl" class="image" target="_blank" v-if="feed.image">
|
||||
<img :src="feed.image.imageUrl" alt="feed.image.title">
|
||||
<a :href="feed.image.targetUrl" class="image" target="_blank" v-if="feed.image?.imageUrl">
|
||||
<img :src="feed.image.imageUrl" :alt="feed.image.title">
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<main>
|
||||
|
@ -21,10 +25,11 @@
|
|||
<div class="header" :class="{expanded: expandedItems[i]}"
|
||||
@click="expandedItems[i] = !expandedItems[i]">
|
||||
<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>
|
||||
|
||||
<div class="age" v-if="item.age">
|
||||
<div class="age" v-if="item.age" :title="new Date(item.pubDate).toLocaleString()">
|
||||
Published {{ item.age }} ago
|
||||
</div>
|
||||
|
||||
|
@ -65,6 +70,9 @@ export default {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.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;
|
||||
}
|
||||
|
||||
|
@ -106,6 +114,11 @@ main {
|
|||
background: #f4f4f4;
|
||||
margin-top: 0.25em;
|
||||
padding-top: 2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
|
@ -119,6 +132,8 @@ main {
|
|||
}
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
max-width: 70em;
|
||||
background: white;
|
||||
margin: 0 1em 1.5em 1em;
|
||||
border-radius: 1em;
|
||||
|
@ -141,6 +156,11 @@ main {
|
|||
.age {
|
||||
font-size: 0.9em;
|
||||
margin-top: -0.5em;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #186536;
|
||||
}
|
||||
}
|
||||
|
||||
.categories {
|
||||
|
|
Loading…
Reference in a new issue