Compare commits

...

5 Commits

5 changed files with 188 additions and 94 deletions

7
assets/style.css Normal file → Executable file
View File

@ -1,5 +1,5 @@
body { body {
max-width: 640px; max-width: 720px;
margin: 0 0 0 auto; margin: 0 0 0 auto;
padding: 8px; padding: 8px;
font-family: sans-serif; font-family: sans-serif;
@ -33,7 +33,8 @@ main li b {
} }
img { img {
margin: 10px auto; margin: 10px 0 10px 50%;
transform: translateX(-50%);
max-width: 100%; max-width: 100%;
} }
@ -51,7 +52,7 @@ hr {
} }
footer a { footer a {
padding-bottom: 30vh; padding-bottom: 3in;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {

View File

@ -1,3 +0,0 @@
{
"nitter": {}
}

256
lib.js Normal file → Executable file
View File

@ -3,7 +3,6 @@
import Path from "path" import Path from "path"
import FS from "fs/promises" import FS from "fs/promises"
import { JSDOM } from "jsdom" import { JSDOM } from "jsdom"
import mime from "mime-types"
// | o | // | o |
@ -40,14 +39,14 @@ export const write = async (path, content) => {
} }
export const createNetworkingError = response => { export const createNetworkingError = response => {
return new Error(`Request failed, ${response.status}: ${response.statusText}`) return new Error(`Request failed for ${response.url}, ${response.status}: ${response.statusText}`)
} }
export const getLinkExtname = link => export const getLinkExtname = link =>
Path.extname(new URL(link).pathname) Path.extname(new URL(link).pathname)
export const getImageBasePath = (source, postId) => export const getImageBasePath = (source, postId) =>
`images/${source.name}-${postId}` `${source.name}-${postId}`
export const writeStylesheet = (path, { directory, batch }) => export const writeStylesheet = (path, { directory, batch }) =>
batch.add( batch.add(
@ -67,12 +66,33 @@ export const testBlacklist = (array, blacklist) =>
blacklist.find(tag => array.includes(tag)) !== undefined blacklist.find(tag => array.includes(tag)) !== undefined
export const createView = (directory, pageSize, extra = {}) => { export const createView = (directory, pageSize, extra = {}) => {
return { let view = {
batch: new PromiseBatch(), batch: new PromiseBatch(),
directory, directory,
pageSize, pageSize,
...extra ...extra
} }
return view
}
export const openImageStore = async view => {
let imageStorePath = Path.join(view.directory, view.imageStoreDirectory)
let dirents = await FS.readdir(imageStorePath, { withFileTypes: true })
view.imageStore = new Map()
for(let dirent of dirents) {
if(dirent.isFile()) {
let basename = dirent.name.slice(0, dirent.name.lastIndexOf('.'))
view.imageStore.set(basename, Path.join(view.imageStoreDirectory, dirent.name))
}
}
return view
}
export const isUnset = (value) => {
return typeof value === "undefined" || value === null
} }
// //
@ -80,17 +100,18 @@ export const createView = (directory, pageSize, extra = {}) => {
// | `-. `-. // | `-. `-.
// ' `-' `-' // ' `-' `-'
export async function fetchRss(source) { export async function fetchChannel(source) {
let { hostname } = source let { hostname } = source
let error let error
let response let response
let rss let rss
let channel
try { try {
response = await delayedFetch( response = await delayedFetch(
new URL(source.pathname, 'https://' + hostname), new URL(source.pathname, 'https://' + hostname),
{}, {},
source.courtesyWait ?? 5 * 1000 source.courtesyWait
) )
} catch(err) { } catch(err) {
error = err error = err
@ -99,12 +120,24 @@ export async function fetchRss(source) {
source.errored = error !== undefined || !response.ok source.errored = error !== undefined || !response.ok
if(source.errored) { if(source.errored) {
source.error = error ?? createNetworkingError(response) source.error = error ?? createNetworkingError(response)
} else { return
rss = await response.text() }
console.log(`Found ${source.name} at ${hostname}`)
console.log(`Found ${source.name} at ${hostname}`)
try {
channel = createChannel(await response.text())
} catch(err) {
error = err
} }
return rss source.errored = error !== undefined
if(source.errored) {
source.error = error
return
}
return channel
} }
let waitingList = new Map() let waitingList = new Map()
@ -115,17 +148,33 @@ export const delayedFetch = async (url, options, courtesyWait = 5 * 1000) => {
waitingList.set(domain, waitFor + courtesyWait) waitingList.set(domain, waitFor + courtesyWait)
if(waitFor !== 0) { if(waitFor !== 0) {
console.log(`Waiting ${waitFor}ms to download ${url}`)
await sleep(waitFor) await sleep(waitFor)
} }
return await fetch(url, options) return await fetch(url, options)
} }
export const retryDelayedFetch = async (url, options, courtesyWait, retryAttempts) => {
let attemptsTried = 0
let response = undefined
while(isUnset(response) && attemptsTried <= (retryAttempts ?? 3)) {
if(attemptsTried > 0)
console.error(`Failed to fetch ${url}, retrying...`)
response = await delayedFetch(url, options, courtesyWait)
attemptsTried++
}
return response
}
export const getCacheFilename = (source) => export const getCacheFilename = (source) =>
source.name + '.xml' source.name + '.xml'
export const getCachePath = (source, { directory }) => export const getCachePath = (source, cache) =>
Path.join(directory, getCacheFilename(source)) Path.join(cache.directory, getCacheFilename(source))
export const cacheSource = (source, cache) => export const cacheSource = (source, cache) =>
write(getCachePath(source, cache), renderCache(source, cache)) write(getCachePath(source, cache), renderCache(source, cache))
@ -149,19 +198,21 @@ export const openCache = async (source, cache) => {
if(exists) if(exists)
rss = await FS.readFile(path, { encoding: 'utf8' }) rss = await FS.readFile(path, { encoding: 'utf8' })
if(exists & rss) { if(exists && rss) {
// if(source.user == 'nanoraptor') {
// source.asdf = 'b'
// source.cache.asdf = 'b'
// }
let channel = createChannel(rss) let channel = createChannel(rss)
let date = readPubDate(channel.querySelector('pubDate'))
let link = new URL(channel.querySelector('link').textContent)
source.cache = { source.cache = {
channel, channel,
date, date: readPubDate(channel.querySelector('pubDate')),
link link: new URL(channel.querySelector('link').textContent),
} }
} else { } else {
source.cache = { source.cache = {
date: new Date(0), date: new Date(0)
} }
if(source.hostname) if(source.hostname)
@ -176,6 +227,7 @@ export const openCache = async (source, cache) => {
export const buildCacheLink = source => export const buildCacheLink = source =>
new URL('https://' + source.hostname) new URL('https://' + source.hostname)
// .replaceAll(/\n\s*/g, '')
export const renderCache = (source, cache) => `\ export const renderCache = (source, cache) => `\
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
@ -206,32 +258,39 @@ export const readPubDate = (pubDate) =>
export const createPosts = async (channel, source, fromDate, reducerCallback) => { export const createPosts = async (channel, source, fromDate, reducerCallback) => {
let items = channel.querySelectorAll('item') let items = channel.querySelectorAll('item')
// if(items.length === 0) {
// // throw new NoMatchesError('Got no matches')
// return source
// }
let promises = [] let promises = []
for(let item of items) { for(let item of items) {
let post = createPost(item, source)
if(post.date <= fromDate)
continue
source.items.push(item) source.items.push(item)
let promise = createPost(item, source, reducerCallback)
.then(post => {
if(post && post.date > fromDate) {
source.posts.push(post)
}
return post let postResolvable = reducerCallback(post)
})
if(postResolvable instanceof Promise) {
postResolvable
.then(post => {
if(post) {
source.posts.push(post)
}
})
} else {
if(postResolvable) {
source.posts.push(postResolvable)
}
}
promises.push(promise) promises.push(postResolvable)
} }
await Promise.all(promises) await Promise.all(promises)
return source return source
} }
export const createPost = async (item, source, reducerCallback) => { export const createPost = (item, source) => {
let description = new JSDOM(item.querySelector('description').textContent).window.document let description = new JSDOM(item.querySelector('description').textContent).window.document
let date = readPubDate(item.querySelector('pubDate')) let date = readPubDate(item.querySelector('pubDate'))
let link = item.querySelector('link').textContent let link = item.querySelector('link').textContent
@ -249,7 +308,7 @@ export const createPost = async (item, source, reducerCallback) => {
occurances: [] occurances: []
} }
return await reducerCallback(post) return post
} }
export const processCategories = (post) => { export const processCategories = (post) => {
@ -263,7 +322,7 @@ export const processCategories = (post) => {
return post return post
} }
export const extractImages = (post, cache = true) => { export const extractImages = (post) => {
let images = post.description.querySelectorAll('img') let images = post.description.querySelectorAll('img')
if(images) { if(images) {
@ -272,7 +331,7 @@ export const extractImages = (post, cache = true) => {
for(let image of images) { for(let image of images) {
let { src } = image let { src } = image
if(!src) { if(isUnset(src)) {
let finalSrc = image.srcset.split(', ').pop() let finalSrc = image.srcset.split(', ').pop()
src = finalSrc.slice(0, finalSrc.indexOf(' ') ) src = finalSrc.slice(0, finalSrc.indexOf(' ') )
@ -403,7 +462,7 @@ export const renderPostDetail = (name, value) =>
export const renderImage = href => { export const renderImage = href => {
return `\ return `\
<a href="${href}" download><img src="${href}" loading="lazy"></img></a>` <a href="${href}"><img src="${href}" loading="lazy"></img></a>`
} }
export const renderDate = date => export const renderDate = date =>
@ -479,21 +538,36 @@ export const renderNavEntry = (list) => {
// | | | | | | | | | | | | | // | | | | | | | | | | | | |
// `-' `-' ' ' `-` `-' ' `-' ' ' // `-' `-' ' ' `-` `-' ' `-' ' '
export const downloadImage = async (url, basename, courtesyWait, { batch, directory }) => { export const downloadImage = async (url, basename, { courtesyWait, retryAttempts }, { batch, directory, imageStoreDirectory }) => {
let response = await delayedFetch(url, {}, courtesyWait) let response = await retryDelayedFetch(url, {}, courtesyWait, retryAttempts)
.catch(err => console.error(`Failed download of ${url}:`, err, err.errors) ) .catch(err => console.error(`Failed download of ${url}:`, err, err.errors) )
if(response == undefined) {
console.error('Could not download image: ' + url)
return url
}
if(response.ok) { if(response.ok) {
let relativePath = basename + imageExtensions[response.headers.get('Content-Type')] let mimetype = response.headers.get('Content-Type').split(';')[0]
let path = Path.join(directory, relativePath) let extension = imageExtensions[mimetype]
if(typeof extension !== 'string') {
console.error(`Unknown mimetype for ${url}: ${mimetype}. Cannot download`)
return url
}
let relativePath = basename + extension
let path = Path.join(directory, imageStoreDirectory, relativePath)
const download = () => write(path, response.body) const download = () => write(path, response.body)
.then(annotate( `Downloaded ${relativePath}`)) .then(annotate( `Downloaded ${relativePath}`))
// TODO: See if the image is downloaded before even trying to download it
batch.add(FS.access(path).catch(download)) batch.add(FS.access(path).catch(download))
return relativePath return relativePath
} else { } else {
throw createNetworkingError(response) console.error( createNetworkingError(response) )
return url
} }
} }
@ -503,8 +577,13 @@ export const downloadImages = (images, source, postId, view) => {
for(let i = 0; i < images.length; i++) { for(let i = 0; i < images.length; i++) {
let basename = images.length > 1 ? basePath + '-' + i : basePath let basename = images.length > 1 ? basePath + '-' + i : basePath
let pathname = view.imageStore.get(basename)
pathnames.push(downloadImage(images[i], basename, source.courtesyWait, view)) if(isUnset(pathname)) {
pathname = downloadImage(images[i], basename, source, view)
}
pathnames.push(pathname)
} }
return Promise.all(pathnames) return Promise.all(pathnames)
@ -546,19 +625,19 @@ export const createFeed = (name, sources, main = false) => {
} }
} }
export const fetchRssFromInstances = async (source) => { export const fetchChannelFromInstances = async (source) => {
let index = 0 let index = 0
let instances = source.instances let instances = source.instances
let cachedLink = source.cache.link let cachedLink = source.cache.link
let rss let channel
if(cachedLink) { if(cachedLink) {
instances.unshift(cachedLink.hostname) instances.unshift(cachedLink.hostname)
} }
while(!rss && index != instances.length) { while(!channel && index != instances.length) {
source.hostname = instances[index] source.hostname = instances[index]
rss = await fetchRss(source) channel = await fetchChannel(source)
if(source.errored) { if(source.errored) {
console.error(`Failed to fetch ${source.name} from ${source.hostname}: `, source.error) console.error(`Failed to fetch ${source.name} from ${source.hostname}: `, source.error)
@ -568,15 +647,15 @@ export const fetchRssFromInstances = async (source) => {
} }
} }
return rss return channel
} }
export const populateSource = async (rss, source, postReducerCallback, useCache = true) => { export const populateSource = async (channel, source, postReducerCallback, cache) => {
let fromDate = 0 let fromDate = 0
source.items = [] source.items = []
source.posts = [] source.posts = []
if(useCache) { if(cache.enabled) {
fromDate = source.latestPostDate fromDate = source.latestPostDate
if(source.cache.channel) if(source.cache.channel)
@ -590,8 +669,8 @@ export const populateSource = async (rss, source, postReducerCallback, useCache
return postReducerCallback(post) return postReducerCallback(post)
} }
if(rss ?? false) if(channel ?? false)
source = await createPosts(createChannel(rss), source, fromDate, remoteReducerCallback) source = await createPosts(channel, source, fromDate, remoteReducerCallback)
return source return source
} }
@ -615,14 +694,26 @@ export const writeView = (sources, feeds, view) => {
writeStylesheet(Path.join(import.meta.dirname, 'assets/style.css'), view) writeStylesheet(Path.join(import.meta.dirname, 'assets/style.css'), view)
} }
export const createSource = async (source, getRss, postReducerCallback, cache) => { export const createSource = async (source, getChannel, postReducerCallback, cache) => {
source = await openCache(source, cache) if(cache.enabled)
source = await populateSource(await getRss(source), source, postReducerCallback, cache.populate) source = await openCache(source, cache)
source = await populateSource(await getChannel(source), source, postReducerCallback, cache)
cache.batch.add(cacheSource(source, cache)) if(cache.enabled)
cache.batch.add(cacheSource(source, cache))
return source return source
} }
export const createSourceOptions = (options, view) => {
if(isUnset(options.courtesyWait))
options.courtesyWait = 1000
if(isUnset(options.retryAttempts))
options.retryAttempts = 3
return options
}
// | | ,- // | | ,-
// ;-. | ,-: |- | ,-. ;-. ;-.-. ,-. // ;-. | ,-: |- | ,-. ;-. ;-.-. ,-.
// | | | | | | |- | | | | | | `-. // | | | | | | |- | | | | | | `-.
@ -630,20 +721,20 @@ export const createSource = async (source, getRss, postReducerCallback, cache) =
// ' -' // ' -'
export const tumblr = { export const tumblr = {
createSource(user, courtesyWait, postReducerCallback, cache) { createSource(user, options, postReducerCallback, cache) {
let lowercaseUser = user.toLowerCase() let lowercaseUser = user.toLowerCase()
let source = { let source = {
type: 'tumblr', type: 'tumblr',
description: `Aggregate feed for @${lowercaseUser} on tumblr.com`, description: `Aggregate feed for @${lowercaseUser} on tumblr.com`,
hostname: lowercaseUser + '.tumblr.com', hostname: lowercaseUser + '.tumblr.com',
pathname: 'rss', pathname: 'rss',
courtesyWait,
name: `tumblr-${lowercaseUser}`, name: `tumblr-${lowercaseUser}`,
displayName: user, displayName: user,
user: lowercaseUser, user: lowercaseUser,
...createSourceOptions(options)
} }
return createSource(source, fetchRss, postReducerCallback, cache) return createSource(source, fetchChannel, postReducerCallback, cache)
}, },
createSources(users, ...args) { createSources(users, ...args) {
@ -672,19 +763,19 @@ export const tumblr = {
} }
export const nitter = { export const nitter = {
createSource(user, instances, courtesyWait, postReducerCallback, cache) { createSource(user, options, instances, postReducerCallback, cache) {
let source = { let source = {
type: 'nitter', type: 'nitter',
description: `Aggregate feed for @${user} on twitter.com`, description: `Aggregate feed for @${user} on twitter.com`,
instances, instances,
pathname: user + '/rss', pathname: user + '/rss',
courtesyWait,
name: `nitter-${user}`, name: `nitter-${user}`,
displayName: user, displayName: user,
user user,
...createSourceOptions(options)
} }
return createSource(source, fetchRssFromInstances, postReducerCallback, cache) return createSource(source, fetchChannelFromInstances, postReducerCallback, cache)
}, },
createSources(users, ...args) { createSources(users, ...args) {
@ -697,17 +788,36 @@ export const nitter = {
return creator.innerHTML.slice(1) !== post.source.user return creator.innerHTML.slice(1) !== post.source.user
}, },
pullImages async pullImages (post, view, imageMirrorDomain, discardPostIfNoImages = false, getPostId = getPostIdFromPathname) {
let images = extractImages(post)
let mirroredImages = []
const mirrorImage = nitter.createImageMirrorer(post, imageMirrorDomain)
if(!discardPostIfNoImages || images.length > 0) {
post.images = await downloadImages(
images.map(mirrorImage),
post.source,
getPostId(post),
view
)
return post
}
},
createImageMirrorer(post, imageMirrorDomain) {
let mirrorUrl = new URL(imageMirrorDomain)
let basePathname = new URL(post.guid).pathname
return (image, index, images) => {
mirrorUrl.pathname = Path.join(basePathname, 'photo', (index + 1).toString())
return mirrorUrl.href
}
}
} }
// TODO: Mastodon support
//
// "Turns out Mastodon has built-in RSS; your feed URL is [instance]/@[username].rss, so for example I'm
// https://mastodon.social/@brownpau.rss (note the "@")"
// - https://mastodon.social/@brownpau/100523448408374430
export const mastodon = { export const mastodon = {
createSource(usertag, courtesyWait, postReducerCallback, cache) { createSource(usertag, options, postReducerCallback, cache) {
let [ user, hostname ] = usertag.toLowerCase().split('@') let [ user, hostname ] = usertag.toLowerCase().split('@')
let source = { let source = {
@ -715,13 +825,13 @@ export const mastodon = {
description: `Aggregate feed for @${user} at ${hostname}`, description: `Aggregate feed for @${user} at ${hostname}`,
hostname, hostname,
pathname: '@' + user + ".rss", pathname: '@' + user + ".rss",
courtesyWait,
name: `${hostname}-${user}`, name: `${hostname}-${user}`,
displayName: user, displayName: user,
user, user,
...createSourceOptions(options)
} }
return createSource(source, fetchRss, postReducerCallback, cache) return createSource(source, fetchChannel, postReducerCallback, cache)
}, },
isRepost(post) { isRepost(post) {
@ -747,4 +857,4 @@ export const mastodon = {
return post return post
} }
} }
} }

3
package.json Normal file → Executable file
View File

@ -10,8 +10,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"jsdom": "^22.1.0", "jsdom": "^22.1.0"
"mime-types": "^2.1.35"
}, },
"type": "module" "type": "module"
} }

View File

@ -1,13 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.stdenv.mkDerivation {
name = "rssssing-dev";
buildInputs = [
pkgs.nodejs_21
pkgs.yarn
];
shellHook = ''
export PATH="$PWD/node_modules/.bin/:$PATH"
export NPM_PACKAGES="$HOME/.npm-packages"
'';
}