From e971dbf0ba1dc5d8c1d5561b52ac284f38ab0449 Mon Sep 17 00:00:00 2001 From: dylan Date: Sun, 28 Dec 2025 17:23:18 +0000 Subject: [PATCH] updated regex and reorganised and added headings to match services.js --- utils.js | 279 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 210 insertions(+), 69 deletions(-) diff --git a/utils.js b/utils.js index 39aceec..0a59f69 100644 --- a/utils.js +++ b/utils.js @@ -2,21 +2,26 @@ import 'dotenv/config' import { JSDOM } from 'jsdom' import sanitizeHtml from 'sanitize-html' -export function markdownLinkRegex(url) { +// ======================================================================== +// helper functions +// ======================================================================== + +// regex to match service name within an html anchor tag +// used when linkFormat is 'html' in config.js +export function htmlServiceLinkRegex(serviceName) { return new RegExp( - `(\\[).*?(\\]\\(https:\\/\\/${url.replaceAll('.', '\\.')}\\))`, + `(]+href=["'][^"']*["'][^>]*>)${serviceName}(<\\/a>)`, 'i', ) } -export function htmlLinkRegex(url) { - const escapedUrl = url.replaceAll('.', '\\.') - return new RegExp( - `(]+href=["']https?:\\/\\/${escapedUrl}[^"']*["'][^>]*>).*?(<\\/a>)`, - 'i', - ) +// 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') } +// escapes special markdown characters so they display literally export function markdownCharEscape(text) { return text .replaceAll('`', '\\`') @@ -35,6 +40,11 @@ export function markdownCharEscape(text) { .replaceAll('!', '\\!') } +// ======================================================================== +// generic feed parsers +// ======================================================================== + +// rss feed parser - grabs the latest item from any standard rss feed export async function getRSSItemTitle(feedUrl) { const res = await fetch(feedUrl) const data = await res.text() @@ -49,6 +59,80 @@ export async function getRSSItemTitle(feedUrl) { return { text: cleanTitle, url: link } } +// 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}](${image})` + : title + return { text, url } +} + +// ======================================================================== +// music +// ======================================================================== + +// listenbrainz json api - grabs your most recent listen +// returns "track name
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}
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
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}
by ${artists.join(', ')}` +} + +// ======================================================================== +// social media +// ======================================================================== + +// mastodon rss feed parser - filters out replies (posts starting with @) +// also replaces urls with 🔗 emoji to keep things tidy export async function getMastodonPost(feedUrl) { const res = await fetch(feedUrl) const data = await res.text() @@ -61,7 +145,7 @@ export async function getMastodonPost(feedUrl) { for (const item of items) { const description = item.querySelector('description')?.textContent || '' - // skip if it's a reply (starts with @ mention) + // skip if it is a reply if (description.trim().startsWith('@')) { continue } @@ -74,7 +158,7 @@ export async function getMastodonPost(feedUrl) { allowedAttributes: {}, })?.trim() - // replace URLs with link emoji + // replace URLs with a link emoji cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim() return { text: cleanText, url: link } @@ -93,49 +177,15 @@ export async function getMastodonPost(feedUrl) { return { text: cleanText, url: link } } -export async function getJsonFeedItemTitle(feedUrl, showImage = true) { +// ======================================================================== +// movies & tv +// ======================================================================== + +// 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 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}](${image})` - : title - return { text, url } -} - -export async function getJsonFeedItemContent(feedUrl, showImage = true) { - const res = await fetch(feedUrl) - const data = await res.json() - const post = data.items[0] - const { content_html, image, url } = post - const title = sanitizeHtml(content_html, { - allowedTags: [], - allowedAttributes: {}, - })?.trim() - const text = !showImage - ? title - : image - ? `${title} ![${title}](${image})` - : title - return { text, url } -} - -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}
by ${artists.join(', ')}` -} - -export async function getLetterboxdActivity(username, showImage = true) { - const res = await fetch(`https://letterboxd.com/${username}/rss/`) const data = await res.text() const dom = new JSDOM(data, { contentType: 'text/xml' }) const item = dom.window.document.querySelector('item') @@ -154,9 +204,12 @@ export async function getLetterboxdActivity(username, showImage = true) { return { text, url: link } } -export async function getTraktEpisode(username, id, showImage = true) { +// 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 const res = await fetch( - `https://trakt.tv/users/${username}/history/episodes/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, + `https://trakt.tv/users/${userId}/history/episodes/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, ) const data = await res.text() const dom = new JSDOM(data) @@ -165,13 +218,16 @@ export async function getTraktEpisode(username, id, showImage = true) { const link = entry.querySelector('link').getAttribute('href') const text = !showImage ? title - : `${title} ![${title}](https://widgets.trakt.tv/users/${id}/watched/thumb@2x.jpg?type=episode&image_only=1)` + : `${title} ![${title}](https://widgets.trakt.tv/users/${traktId}/watched/thumb@2x.jpg?type=episode&image_only=1)` return { text, url: link } } -export async function getTraktMovie(username, id, showImage = true) { +// 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 const res = await fetch( - `https://trakt.tv/users/${username}/history/movies/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, + `https://trakt.tv/users/${userId}/history/movies/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, ) const data = await res.text() const dom = new JSDOM(data) @@ -180,24 +236,109 @@ export async function getTraktMovie(username, id, showImage = true) { const link = entry.querySelector('link').getAttribute('href') const text = !showImage ? title - : `${title} ![${title}](https://widgets.trakt.tv/users/${id}/watched/thumb@2x.jpg?type=movie&image_only=1)` + : `${title} ![${title}](https://widgets.trakt.tv/users/${traktId}/watched/thumb@2x.jpg?type=movie&image_only=1)` return { text, url: link } } -export async function getTraktEpisodeAndMovie(username, id, showImage = true) { - const episode = await getTraktEpisode(username, id, showImage) - const movie = await getTraktMovie(username, id, showImage) +// ======================================================================== +// 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] return { - text: `${episode.text}
and
${movie.text}`, - url: `https://trakt.tv/users/${username}`, + text: book.title, + url: `https://hardcover.app/books/${book.id}`, } } -export async function getReadingBooklogr(booklogrUrl, booklogrUser) { - const res = await fetch(`${booklogrUrl}/v1/profiles/${booklogrUser}`) - const data = await res.json() - const books = data.books.filter( - ({ reading_status }) => reading_status === 'Currently reading', +// ======================================================================== +// 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`, ) - return books.map(({ title }) => title).join(',
') + 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 } }