This is a little hack I’ve been running for nearly a month now to generate RSS feeds of some of my Mastodon lists, namely the ones I use as alternate topical timelines and want to catch up on every few days.
Although there is a limit of 400 posts on your main timeline, the Mastodon API is very well designed (much better than most billion dollar social network APIs, even before they became extinct), so consuming these later via RSS is pretty easy to do.
In my case, I just generated an access token for a new app with scoped permissions to read my lists and whipped up a Node-RED flow to issue an HTTPS request and parse the result into an RSS (actually, Atom) feed:
This is a fully stateless setup in which I call the Mastodon API whenever the feed URL is polled, so I have a hard caching threshold in the nginx
ingress to ensure that this Node-RED back-end only gets invoked every 30m.
I use this pattern (and the same Node-RED instance) for managing a number of other feeds I generate from simple scrapers and run through Azure cognitive services, and it works great.
Below is the (still somewhat untidy, but working) JavaScript code that renders each item in passable HTML patterned after Open RSS.
The resulting Atom feed is rendered inside a template node and ultimately consumed by Reeder, which I typically use to catch up on things over breakfast.
Here’s the function node contents:
// This handles the JSON reply from Mastodon, after being passed through a `split` node
// For each item that comes through in `msg.payload`, we need to do a fair amount of
// fractional templating that is just easier to do in vanilla JS
var post = msg.payload,
item = {
feed_title: "<your title>",
feed_url: "<your url>",
date: post.created_at,
author: `${post.account.display_name}`,
title: `@${post.account.acct}`,
url: post.uri,
parts: msg.parts, // split/join index
req: msg.req, // ongoing HTTP request context
res: msg.res, // ditto
headers: msg.headers // ditto
},
date = Date.parse(post.created_at),
d = new Date(date),
local_date = d.toLocaleString("PT"),
footer = `<p><small>- ${post.account.display_name} (@${post.account.username}) <a href="${post.url}">${local_date}</a></small></p>` +
`<p>💬${post.replies_count} 🔁${post.reblogs_count} ⭐${post.favourites_count}</p>`,
attachments = [],
mentions = [],
tags = [];
// Gather attachments
if(post.media_attachments) {
post.media_attachments.forEach(function(att) {
attachments.push(`<a href="${att.url}"><img src="${att.preview_url}" title="${att.description}"/></a>`)
});
}
// Set up main body
item.body = `${post.content}`
// Content Warning
if(post.sensitive) {
item.title = `@${post.account.username}: Content Warning: ${post.spoiler_text}`
}
// If this is a boost, then build a nested post
else if(post.reblog) {
item.title = `@${post.account.username} boosted @${post.reblog.account.username}`
var reblog_attachments = [],
date = Date.parse(post.reblog.created_at),
d = new Date(date),
local_date = d.toLocaleString("PT"),
reblog_footer = `<p><small>- ${post.reblog.account.display_name} (@${post.reblog.account.username}) <a href="${post.reblog.url}">${local_date}</a></small></p>` +
`<p>💬${post.reblog.replies_count} 🔁${post.reblog.reblogs_count} ⭐${post.reblog.favourites_count}</p>`
// Gather reblogged post attachments
if (post.reblog.media_attachments) {
post.reblog.media_attachments.forEach(function (att) {
reblog_attachments.push(`<a href="${att.url}"><img src="${att.preview_url}" title="${att.description}"/></a>`)
});
}
item.body = `<blockquote>${post.reblog.content}${reblog_attachments.join("")}${reblog_footer}</blockquote>`
}
// Reply
else if(post.in_reply_to_id && post.mentions.length) {
item.title = `@${post.account.username} replied to @${post.mentions[0].username}`
}
// Render a poll - this is hacky but gives you an idea of what the pool looks like
else if (post.poll) {
var options = [],
plaintext = post.content.replace(/<\/?[^>]+(>|$)/g, "")
if (plaintext.length > 32) {
plaintext = plaintext.substring(0, 32) + "..."
}
item.title = `@${post.account.username} posted a poll: ${plaintext}`
if (post.poll.options) {
post.poll.options.forEach(function (o) {
options.push(`<li><b>${o.title}</b> <i>(${o.votes_count} votes)</i></li>`)
});
}
var date = Date.parse(post.poll.expires_at),
d = new Date(date),
local_date = d.toLocaleString("PT"),
verb = post.poll.expired ? "closed" : "will close",
inner_footer = `<p><small>Poll ${verb} at ${local_date}</small></p>`
item.body = `<blockquote><ul>${options.join("")}</ul>${inner_footer}</blockquote>`
}
// Your plain post
else {
var plaintext = post.content.replace(/<\/?[^>]+(>|$)/g, "")
if(plaintext.length > 32) {
plaintext = plaintext.substring(0,32) + "..."
}
item.title = `@${post.account.username}: ${plaintext}`
}
// Now tack on main post attachments and tags
if(attachments.length) {
item.body += `<p>${attachments.join("")}</p>`
}
if (tags.length) {
item.body += `<p>${tags.join(", ")}</p>`
}
item.body += `${footer}`
// Brute force HTML cleanup tor Atom
item.body = item.body.replace(/<br>/g,"<br/>")
// Set a reference to conform to Node-RED conventions
item.payload = item;
return item