Generating an RSS feed out of a Mastodon list

This is a little hack I’ve been running for nearly a month now to generate RSS feeds of some of my lists, namely the ones I and want to catch up on every few days.

Although there is a limit of 400 posts on your main timeline, the API is very well designed (much better than most billion dollar social network APIs, even before they ), 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 flow to issue an HTTPS request and parse the result into an RSS (actually, Atom) feed:

We're focusing on the box with the little linter tag.

This is a fully stateless setup in which I call the API whenever the feed URL is polled, so I have a hard caching threshold in the nginx ingress to ensure that this back-end only gets invoked every 30m.

I use this pattern (and the same 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) 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 , 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:, // 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>&#128172;${post.replies_count}  &#128257;${post.reblogs_count}  &#11088;${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>&#128172;${post.reblog.replies_count}  &#128257;${post.reblog.reblogs_count}  &#11088;${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

This page is referenced in: