2025-12-18 05:53:32 +00:00
|
|
|
import 'dotenv/config'
|
|
|
|
|
import { JSDOM } from 'jsdom'
|
|
|
|
|
import sanitizeHtml from 'sanitize-html'
|
2025-12-18 01:57:14 +00:00
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// ========================================================================
|
|
|
|
|
// helper functions
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
// regex to match service name within an html anchor tag
|
|
|
|
|
// used when linkFormat is 'html' in config.js
|
|
|
|
|
export function htmlServiceLinkRegex(serviceName) {
|
2025-12-18 01:57:14 +00:00
|
|
|
return new RegExp(
|
2025-12-28 17:23:18 +00:00
|
|
|
`(<a[^>]+href=["'][^"']*["'][^>]*>)${serviceName}(<\\/a>)`,
|
2025-12-18 05:53:32 +00:00
|
|
|
'i',
|
|
|
|
|
)
|
2025-12-18 01:57:14 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// regex to match service name within a markdown link
|
|
|
|
|
// used when linkFormat is 'markdown' in config.js
|
|
|
|
|
export function markdownServiceLinkRegex(serviceName) {
|
|
|
|
|
return new RegExp(`(\\[)${serviceName}(\\]\\([^)]*\\))`, 'i')
|
2025-12-25 17:23:40 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// escapes special markdown characters so they display literally
|
2025-12-22 01:13:31 +00:00
|
|
|
export function markdownCharEscape(text) {
|
2025-12-22 01:52:53 +00:00
|
|
|
return text
|
|
|
|
|
.replaceAll('`', '\\`')
|
|
|
|
|
.replaceAll('*', '\\*')
|
|
|
|
|
.replaceAll('_', '\\_')
|
|
|
|
|
.replaceAll('{', '\\{')
|
|
|
|
|
.replaceAll('}', '\\}')
|
|
|
|
|
.replaceAll('[', '\\[')
|
|
|
|
|
.replaceAll(']', '\\]')
|
|
|
|
|
.replaceAll('(', '\\(')
|
|
|
|
|
.replaceAll(')', '\\)')
|
|
|
|
|
.replaceAll('#', '\\#')
|
|
|
|
|
.replaceAll('+', '\\+')
|
|
|
|
|
.replaceAll('-', '\\-')
|
|
|
|
|
.replaceAll('.', '\\.')
|
|
|
|
|
.replaceAll('!', '\\!')
|
2025-12-22 01:13:31 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// ========================================================================
|
|
|
|
|
// generic feed parsers
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
// rss feed parser - grabs the latest item from any standard rss feed
|
2025-12-18 06:21:52 +00:00
|
|
|
export async function getRSSItemTitle(feedUrl) {
|
|
|
|
|
const res = await fetch(feedUrl)
|
|
|
|
|
const data = await res.text()
|
2025-12-25 18:52:25 +00:00
|
|
|
const dom = new JSDOM(data, { contentType: 'text/xml' })
|
2025-12-18 06:21:52 +00:00
|
|
|
const item = dom.window.document.querySelector('item')
|
|
|
|
|
const title = item.querySelector('title').textContent
|
2025-12-25 18:52:25 +00:00
|
|
|
const link = item.querySelector('link').textContent.trim()
|
2025-12-25 20:01:35 +00:00
|
|
|
const cleanTitle = sanitizeHtml(title, {
|
|
|
|
|
allowedTags: [],
|
|
|
|
|
allowedAttributes: {},
|
|
|
|
|
})?.trim()
|
2025-12-25 18:30:27 +00:00
|
|
|
return { text: cleanTitle, url: link }
|
2025-12-18 06:21:52 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// atom feed parser - grabs the latest entry from any standard atom feed
|
|
|
|
|
export async function getAtomFeed(url) {
|
|
|
|
|
const res = await fetch(url)
|
|
|
|
|
const data = await res.text()
|
|
|
|
|
const dom = new JSDOM(data, { contentType: 'text/xml' })
|
|
|
|
|
const entry = dom.window.document.querySelector('entry')
|
|
|
|
|
if (!entry) {
|
|
|
|
|
throw new Error('No recent items found')
|
|
|
|
|
}
|
|
|
|
|
const title = entry.querySelector('title').textContent
|
|
|
|
|
const link = entry.querySelector('link').getAttribute('href')
|
|
|
|
|
const cleanTitle = sanitizeHtml(title, {
|
|
|
|
|
allowedTags: [],
|
|
|
|
|
allowedAttributes: {},
|
|
|
|
|
})?.trim()
|
|
|
|
|
return { text: cleanTitle, url: link }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// json feed parser - grabs the latest item title
|
|
|
|
|
// optionally includes images in markdown format
|
|
|
|
|
export async function getJsonFeedItemTitle(feedUrl, showImage = true) {
|
|
|
|
|
const res = await fetch(feedUrl)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
const post = data.items[0]
|
|
|
|
|
const { title, image, url } = post
|
|
|
|
|
const text = !showImage
|
|
|
|
|
? title
|
|
|
|
|
: image
|
|
|
|
|
? `${title} `
|
|
|
|
|
: title
|
|
|
|
|
return { text, url }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
// music
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
// listenbrainz json api - grabs your most recent listen
|
|
|
|
|
// returns "track name<br />by artist name"
|
|
|
|
|
export async function getListenBrainzScrobble(config) {
|
|
|
|
|
const { feedUrl, userId } = config
|
|
|
|
|
|
|
|
|
|
const res = await fetch(feedUrl)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
if (!data.payload.listens || data.payload.listens.length === 0) {
|
|
|
|
|
throw new Error('No recent listens found')
|
|
|
|
|
}
|
|
|
|
|
const listen = data.payload.listens[0]
|
|
|
|
|
const { track_name, artist_name } = listen.track_metadata
|
|
|
|
|
const text = `${track_name}<br />by ${artist_name}`
|
|
|
|
|
const url = `https://listenbrainz.org/user/${userId}/`
|
|
|
|
|
return { text, url }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// maloja music scrobbling api - grabs your most recent listen
|
|
|
|
|
// returns "track name<br />by artist name"
|
|
|
|
|
// not configured in services.js by default but kept from upstream
|
|
|
|
|
export async function getMalojaScrobble(malojaUrl) {
|
|
|
|
|
const res = await fetch(`${malojaUrl}/apis/mlj_1/scrobbles?perpage=1`)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
const scrobble = data.list[0]
|
|
|
|
|
const {
|
|
|
|
|
title,
|
|
|
|
|
album: { artists },
|
|
|
|
|
} = scrobble.track
|
|
|
|
|
return `${title}<br />by ${artists.join(', ')}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
// social media
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
// mastodon rss feed parser - filters out replies (posts starting with @)
|
|
|
|
|
// also replaces urls with 🔗 emoji to keep things tidy
|
2025-12-25 20:54:44 +00:00
|
|
|
export async function getMastodonPost(feedUrl) {
|
|
|
|
|
const res = await fetch(feedUrl)
|
|
|
|
|
const data = await res.text()
|
2025-12-25 21:13:04 +00:00
|
|
|
const dom = new JSDOM(data, { contentType: 'text/xml' })
|
2025-12-25 20:54:44 +00:00
|
|
|
|
|
|
|
|
// get all items from the rss feed
|
|
|
|
|
const items = dom.window.document.querySelectorAll('item')
|
|
|
|
|
|
|
|
|
|
// find the first item that isn't a reply (doesn't start with @)
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const description = item.querySelector('description')?.textContent || ''
|
|
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// skip if it is a reply
|
2025-12-25 20:58:59 +00:00
|
|
|
if (description.trim().startsWith('@')) {
|
2025-12-25 20:54:44 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const link = item.querySelector('link')?.textContent.trim()
|
2025-12-25 20:58:59 +00:00
|
|
|
|
|
|
|
|
// use description as the text, stripping HTML
|
2025-12-25 21:22:42 +00:00
|
|
|
let cleanText = sanitizeHtml(description, {
|
2025-12-25 20:54:44 +00:00
|
|
|
allowedTags: [],
|
|
|
|
|
allowedAttributes: {},
|
|
|
|
|
})?.trim()
|
|
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// replace URLs with a link emoji
|
2025-12-25 21:22:42 +00:00
|
|
|
cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim()
|
|
|
|
|
|
2025-12-25 20:58:59 +00:00
|
|
|
return { text: cleanText, url: link }
|
2025-12-25 20:54:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fallback to first item if no non-reply found
|
|
|
|
|
const firstItem = items[0]
|
2025-12-25 20:58:59 +00:00
|
|
|
const description = firstItem.querySelector('description')?.textContent || ''
|
|
|
|
|
const link = firstItem.querySelector('link')?.textContent.trim()
|
2025-12-25 21:22:42 +00:00
|
|
|
let cleanText = sanitizeHtml(description, {
|
2025-12-25 20:38:08 +00:00
|
|
|
allowedTags: [],
|
|
|
|
|
allowedAttributes: {},
|
|
|
|
|
})?.trim()
|
2025-12-25 21:22:42 +00:00
|
|
|
// replace URLs with link emoji
|
|
|
|
|
cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim()
|
2025-12-25 20:58:59 +00:00
|
|
|
return { text: cleanText, url: link }
|
2025-12-25 20:38:08 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// ========================================================================
|
|
|
|
|
// movies & tv
|
|
|
|
|
// ========================================================================
|
2025-12-18 01:57:14 +00:00
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// letterboxd rss parser - can optionally extract poster images from cdata
|
|
|
|
|
// images are embedded in the description field as html
|
|
|
|
|
export async function getLetterboxdActivity(config) {
|
|
|
|
|
const { feedUrl, showImage = false } = config
|
2025-12-18 05:53:32 +00:00
|
|
|
const res = await fetch(feedUrl)
|
|
|
|
|
const data = await res.text()
|
2025-12-25 18:52:25 +00:00
|
|
|
const dom = new JSDOM(data, { contentType: 'text/xml' })
|
2025-12-18 05:53:32 +00:00
|
|
|
const item = dom.window.document.querySelector('item')
|
|
|
|
|
const title = item.querySelector('title').textContent
|
2025-12-25 18:52:25 +00:00
|
|
|
const link = item.querySelector('link').textContent.trim()
|
2025-12-25 19:19:09 +00:00
|
|
|
|
2025-12-25 20:01:35 +00:00
|
|
|
let text = title
|
2025-12-25 19:19:09 +00:00
|
|
|
if (showImage) {
|
|
|
|
|
// Parse CDATA content as HTML to extract image
|
|
|
|
|
const description = item.querySelector('description').textContent
|
|
|
|
|
const imgMatch = description.match(/<img[^>]+src="([^"]+)"/)
|
|
|
|
|
const image = imgMatch ? imgMatch[1] : null
|
2025-12-25 20:01:35 +00:00
|
|
|
text = image ? `${title} ` : title
|
2025-12-25 19:19:09 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-25 18:30:27 +00:00
|
|
|
return { text, url: link }
|
2025-12-18 05:53:32 +00:00
|
|
|
}
|
2025-12-18 05:35:31 +00:00
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// trakt episode history - requires TRAKT_SLURM variable (see readme for info)
|
|
|
|
|
// can optionally include trakt widget thumbnail image
|
|
|
|
|
export async function getTraktEpisode(config) {
|
|
|
|
|
const { userId, traktId, showImage = false } = config
|
2025-12-18 05:53:32 +00:00
|
|
|
const res = await fetch(
|
2025-12-28 17:23:18 +00:00
|
|
|
`https://trakt.tv/users/${userId}/history/episodes/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`,
|
2025-12-18 05:53:32 +00:00
|
|
|
)
|
|
|
|
|
const data = await res.text()
|
|
|
|
|
const dom = new JSDOM(data)
|
|
|
|
|
const entry = dom.window.document.querySelector('entry')
|
|
|
|
|
const title = entry.querySelector('title').textContent
|
2025-12-25 18:30:27 +00:00
|
|
|
const link = entry.querySelector('link').getAttribute('href')
|
|
|
|
|
const text = !showImage
|
2025-12-25 20:01:35 +00:00
|
|
|
? title
|
2025-12-28 17:23:18 +00:00
|
|
|
: `${title} `
|
2025-12-25 18:30:27 +00:00
|
|
|
return { text, url: link }
|
2025-12-18 05:35:31 +00:00
|
|
|
}
|
2025-12-22 02:42:53 +00:00
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// trakt movie history - requires TRAKT_SLURM variable (see readme for info)
|
|
|
|
|
// can optionally include trakt widget thumbnail image
|
|
|
|
|
export async function getTraktMovie(config) {
|
|
|
|
|
const { userId, traktId, showImage = false } = config
|
2025-12-25 18:20:44 +00:00
|
|
|
const res = await fetch(
|
2025-12-28 17:23:18 +00:00
|
|
|
`https://trakt.tv/users/${userId}/history/movies/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`,
|
2025-12-25 18:20:44 +00:00
|
|
|
)
|
|
|
|
|
const data = await res.text()
|
|
|
|
|
const dom = new JSDOM(data)
|
|
|
|
|
const entry = dom.window.document.querySelector('entry')
|
|
|
|
|
const title = entry.querySelector('title').textContent
|
2025-12-25 18:30:27 +00:00
|
|
|
const link = entry.querySelector('link').getAttribute('href')
|
2025-12-25 22:53:54 +00:00
|
|
|
const text = !showImage
|
|
|
|
|
? title
|
2025-12-28 17:23:18 +00:00
|
|
|
: `${title} `
|
2025-12-25 22:53:54 +00:00
|
|
|
return { text, url: link }
|
2025-12-25 18:20:44 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// ========================================================================
|
|
|
|
|
// books
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
// hardcover graphql api - grabs your currently reading book
|
|
|
|
|
export async function getHardcoverActivity(config) {
|
|
|
|
|
const { userId, feedUrl } = config
|
|
|
|
|
const query = `
|
|
|
|
|
query {
|
|
|
|
|
user(username: "${userId}") {
|
|
|
|
|
currently_reading_books(limit: 1) {
|
|
|
|
|
title
|
|
|
|
|
id
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
const res = await fetch(feedUrl, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ query }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
if (
|
|
|
|
|
!data.data?.user?.currently_reading_books ||
|
|
|
|
|
data.data.user.currently_reading_books.length === 0
|
|
|
|
|
) {
|
|
|
|
|
throw new Error('Nothing!')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const book = data.data.user.currently_reading_books[0]
|
2025-12-25 18:30:27 +00:00
|
|
|
return {
|
2025-12-28 17:23:18 +00:00
|
|
|
text: book.title,
|
|
|
|
|
url: `https://hardcover.app/books/${book.id}`,
|
2025-12-25 18:30:27 +00:00
|
|
|
}
|
2025-12-25 18:20:44 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:23:18 +00:00
|
|
|
// ========================================================================
|
|
|
|
|
// gaming
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
// steam web api - grabs your most recently played game
|
|
|
|
|
// requires STEAM_WEBAPI_KEY see readme for more info
|
|
|
|
|
// needs your steam id 64 (not username) - convert at steamid.io
|
|
|
|
|
export async function getSteamRecentlyPlayed(config) {
|
|
|
|
|
const { steamId } = config
|
|
|
|
|
const apiKey = process.env.STEAM_WEBAPI_KEY
|
|
|
|
|
if (!apiKey) {
|
|
|
|
|
throw new Error('STEAM_WEBAPI_KEY environment variable not set')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v1/?key=${process.env.STEAM_WEBAPI_KEY}&steamid=${steamId}&format=json`,
|
2025-12-22 02:42:53 +00:00
|
|
|
)
|
2025-12-28 17:23:18 +00:00
|
|
|
const data = await res.json()
|
|
|
|
|
|
|
|
|
|
if (!data.response.games || data.response.games.length === 0) {
|
|
|
|
|
throw new Error('No recent games found')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const game = data.response.games[0]
|
|
|
|
|
const { name, appid } = game
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
text: name,
|
|
|
|
|
url: `https://store.steampowered.com/app/${appid}`,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
// code & development
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
// source.tube (forgejo) rss parser - strips username from the beginning
|
|
|
|
|
// turns "dylan pushed an update to..." into "pushed an update to..."
|
|
|
|
|
export async function getSourceTubeActivity(config) {
|
|
|
|
|
const { feedUrl, userId } = config
|
|
|
|
|
|
|
|
|
|
const res = await fetch(feedUrl)
|
|
|
|
|
const data = await res.text()
|
|
|
|
|
const dom = new JSDOM(data, { contentType: 'text/xml' })
|
|
|
|
|
const item = dom.window.document.querySelector('item')
|
|
|
|
|
if (!item) {
|
|
|
|
|
throw new Error('No recent activity found')
|
|
|
|
|
}
|
|
|
|
|
const title = item.querySelector('title').textContent
|
|
|
|
|
const link = item.querySelector('link').textContent.trim()
|
|
|
|
|
let cleanTitle = sanitizeHtml(title, {
|
|
|
|
|
allowedTags: [],
|
|
|
|
|
allowedAttributes: {},
|
|
|
|
|
})?.trim()
|
|
|
|
|
|
|
|
|
|
// remove username prefix (e.g., "dylan pushed..." -> "pushed...")
|
|
|
|
|
if (userId) {
|
|
|
|
|
const usernamePattern = new RegExp(`^${userId}\\s+`, 'i')
|
|
|
|
|
cleanTitle = cleanTitle.replace(usernamePattern, '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { text: cleanTitle, url: link }
|
2025-12-22 02:42:53 +00:00
|
|
|
}
|