import 'dotenv/config' import { JSDOM } from 'jsdom' import sanitizeHtml from 'sanitize-html' // ======================================================================== // 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( `(]+href=["'][^"']*["'][^>]*>)${serviceName}(<\\/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('`', '\\`') .replaceAll('*', '\\*') .replaceAll('_', '\\_') .replaceAll('{', '\\{') .replaceAll('}', '\\}') .replaceAll('[', '\\[') .replaceAll(']', '\\]') .replaceAll('(', '\\(') .replaceAll(')', '\\)') .replaceAll('#', '\\#') .replaceAll('+', '\\+') .replaceAll('-', '\\-') .replaceAll('.', '\\.') .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() const dom = new JSDOM(data, { contentType: 'text/xml' }) const item = dom.window.document.querySelector('item') const title = item.querySelector('title').textContent const link = item.querySelector('link').textContent.trim() const cleanTitle = sanitizeHtml(title, { allowedTags: [], allowedAttributes: {}, })?.trim() 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 separately 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 return { text: title, url, image: showImage ? image : null } } // ======================================================================== // 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 // extracts images from posts if available export async function getMastodonPost(config) { const { feedUrl, showImage = false } = typeof config === 'string' ? { feedUrl: config } : config const res = await fetch(feedUrl) const data = await res.text() const dom = new JSDOM(data, { contentType: 'text/xml' }) // 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 || '' // skip if it is a reply if (description.trim().startsWith('@')) { continue } const link = item.querySelector('link')?.textContent.trim() // extract media from media:content or description html // check for media:content (mastodon uses this for attachments) const mediaContent = item.getElementsByTagName('media:content')[0] let image = null let video = null if (mediaContent) { const mediaUrl = mediaContent.getAttribute('url') const mediaType = mediaContent.getAttribute('type') // check if it's a video or image if (mediaType?.startsWith('video/')) { video = mediaUrl } else if (mediaType?.startsWith('image/')) { image = mediaUrl } } // fallback to img tags in description html if (!image && !video) { const imgMatch = description.match(/]+src="([^"]+)"/) image = imgMatch ? imgMatch[1] : null } // use description as the text, stripping HTML let cleanText = sanitizeHtml(description, { allowedTags: [], allowedAttributes: {}, })?.trim() // replace URLs with a link emoji cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim() return { text: cleanText, url: link, image: showImage ? image : null, video: showImage ? video : null, } } // fallback to first item if no non-reply found const firstItem = items[0] const description = firstItem.querySelector('description')?.textContent || '' const link = firstItem.querySelector('link')?.textContent.trim() // extract media from media:content or description html // check for media:content (mastodon uses this for attachments) const mediaContent = firstItem.getElementsByTagName('media:content')[0] let image = null let video = null if (mediaContent) { const mediaUrl = mediaContent.getAttribute('url') const mediaType = mediaContent.getAttribute('type') // check if it's a video or image if (mediaType?.startsWith('video/')) { video = mediaUrl } else if (mediaType?.startsWith('image/')) { image = mediaUrl } } // fallback to img tags in description html if (!image && !video) { const imgMatch = description.match(/]+src="([^"]+)"/) image = imgMatch ? imgMatch[1] : null } let cleanText = sanitizeHtml(description, { allowedTags: [], allowedAttributes: {}, })?.trim() // replace URLs with link emoji cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim() return { text: cleanText, url: link, image: showImage ? image : null, video: showImage ? video : null, } } // ======================================================================== // 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.text() const dom = new JSDOM(data, { contentType: 'text/xml' }) const item = dom.window.document.querySelector('item') const title = item.querySelector('title').textContent const link = item.querySelector('link').textContent.trim() let image = null if (showImage) { // Parse CDATA content as HTML to extract image const description = item.querySelector('description').textContent const imgMatch = description.match(/]+src="([^"]+)"/) image = imgMatch ? imgMatch[1] : null } return { text: title, url: link, image } } // 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/${userId}/history/episodes/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, ) const data = await res.text() const dom = new JSDOM(data) const entry = dom.window.document.querySelector('entry') const title = entry.querySelector('title').textContent const link = entry.querySelector('link').getAttribute('href') const image = showImage ? `https://widgets.trakt.tv/users/${traktId}/watched/thumb@2x.jpg?type=episode&image_only=1` : null return { text: title, url: link, image } } // 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/${userId}/history/movies/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, ) const data = await res.text() const dom = new JSDOM(data) const entry = dom.window.document.querySelector('entry') const title = entry.querySelector('title').textContent const link = entry.querySelector('link').getAttribute('href') const image = showImage ? `https://widgets.trakt.tv/users/${traktId}/watched/thumb@2x.jpg?type=movie&image_only=1` : null return { text: title, url: link, image } } // ======================================================================== // books // ======================================================================== // hardcover graphql api - grabs your currently reading book // requires HARDCOVER_API_KEY see readme for more info export async function getHardcoverActivity(config) { const { userId, feedUrl } = config const apiKey = process.env.HARDCOVER_API_KEY if (!apiKey) { throw new Error('HARDCOVER_API_KEY environment variable not set') } const query = ` query { user_books( where: {user_id: {_eq: ${userId}}, status_id: {_eq: 2}} ) { book { title id image { url } contributions { author { name } } } } } ` const res = await fetch(feedUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', authorization: `Bearer ${apiKey}`, 'User-Agent': 'now-updater/1.0 (omg.lol now page updater)', }, body: JSON.stringify({ query }), }) const data = await res.json() if (!data.data?.user_books || data.data.user_books.length === 0) { throw new Error('Nothing!') } const userBook = data.data.user_books[0] const book = userBook.book const author = book.contributions?.[0]?.author?.name || 'Unknown Author' return { text: `${book.title} by ${author}`, url: `https://hardcover.app/books/${book.id}`, image: book.image?.url || null, } } // ======================================================================== // 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 { userId } = 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/GetOwnedGames/v1/?key=${apiKey}&steamid=${userId}&format=json&include_appinfo=1&include_played_free_games=1`, ) const data = await res.json() if (!data.response.games || data.response.games.length === 0) { throw new Error('No games found') } // sort by most recently played const games = data.response.games const sortedGames = games .filter((g) => g.rtime_last_played > 0) .sort((a, b) => b.rtime_last_played - a.rtime_last_played) if (sortedGames.length === 0) { throw new Error('Nothing yet!') } const game = sortedGames[0] const { name, appid } = game return { text: name, url: `https://store.steampowered.com/app/${appid}`, image: `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/library_600x900.jpg`, } } // ======================================================================== // 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 } }