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
+ 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
- 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
- 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} `
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} `
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 }
}