updated regex and reorganised and added headings to match services.js

This commit is contained in:
dylan 2025-12-28 17:23:18 +00:00
parent 64488f88ed
commit e971dbf0ba

285
utils.js
View file

@ -2,21 +2,26 @@ import 'dotenv/config'
import { JSDOM } from 'jsdom' import { JSDOM } from 'jsdom'
import sanitizeHtml from 'sanitize-html' 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( return new RegExp(
`(\\[).*?(\\]\\(https:\\/\\/${url.replaceAll('.', '\\.')}\\))`, `(<a[^>]+href=["'][^"']*["'][^>]*>)${serviceName}(<\\/a>)`,
'i', 'i',
) )
} }
export function htmlLinkRegex(url) { // regex to match service name within a markdown link
const escapedUrl = url.replaceAll('.', '\\.') // used when linkFormat is 'markdown' in config.js
return new RegExp( export function markdownServiceLinkRegex(serviceName) {
`(<a[^>]+href=["']https?:\\/\\/${escapedUrl}[^"']*["'][^>]*>).*?(<\\/a>)`, return new RegExp(`(\\[)${serviceName}(\\]\\([^)]*\\))`, 'i')
'i',
)
} }
// escapes special markdown characters so they display literally
export function markdownCharEscape(text) { export function markdownCharEscape(text) {
return text return text
.replaceAll('`', '\\`') .replaceAll('`', '\\`')
@ -35,6 +40,11 @@ export function markdownCharEscape(text) {
.replaceAll('!', '\\!') .replaceAll('!', '\\!')
} }
// ========================================================================
// generic feed parsers
// ========================================================================
// rss feed parser - grabs the latest item from any standard rss feed
export async function getRSSItemTitle(feedUrl) { export async function getRSSItemTitle(feedUrl) {
const res = await fetch(feedUrl) const res = await fetch(feedUrl)
const data = await res.text() const data = await res.text()
@ -49,6 +59,80 @@ export async function getRSSItemTitle(feedUrl) {
return { text: cleanTitle, url: link } 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<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
export async function getMastodonPost(feedUrl) { export async function getMastodonPost(feedUrl) {
const res = await fetch(feedUrl) const res = await fetch(feedUrl)
const data = await res.text() const data = await res.text()
@ -61,7 +145,7 @@ export async function getMastodonPost(feedUrl) {
for (const item of items) { for (const item of items) {
const description = item.querySelector('description')?.textContent || '' 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('@')) { if (description.trim().startsWith('@')) {
continue continue
} }
@ -74,7 +158,7 @@ export async function getMastodonPost(feedUrl) {
allowedAttributes: {}, allowedAttributes: {},
})?.trim() })?.trim()
// replace URLs with link emoji // replace URLs with a link emoji
cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim() cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim()
return { text: cleanText, url: link } return { text: cleanText, url: link }
@ -93,49 +177,15 @@ export async function getMastodonPost(feedUrl) {
return { text: cleanText, url: link } 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 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}<br />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 data = await res.text()
const dom = new JSDOM(data, { contentType: 'text/xml' }) const dom = new JSDOM(data, { contentType: 'text/xml' })
const item = dom.window.document.querySelector('item') const item = dom.window.document.querySelector('item')
@ -154,9 +204,12 @@ export async function getLetterboxdActivity(username, showImage = true) {
return { text, url: link } 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( 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 data = await res.text()
const dom = new JSDOM(data) 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 link = entry.querySelector('link').getAttribute('href')
const text = !showImage const text = !showImage
? title ? 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 } 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( 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 data = await res.text()
const dom = new JSDOM(data) 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 link = entry.querySelector('link').getAttribute('href')
const text = !showImage const text = !showImage
? title ? 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 } return { text, url: link }
} }
export async function getTraktEpisodeAndMovie(username, id, showImage = true) { // ========================================================================
const episode = await getTraktEpisode(username, id, showImage) // books
const movie = await getTraktMovie(username, id, showImage) // ========================================================================
return {
text: `${episode.text} <br>and<br> ${movie.text}`, // hardcover graphql api - grabs your currently reading book
url: `https://trakt.tv/users/${username}`, 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 }),
})
export async function getReadingBooklogr(booklogrUrl, booklogrUser) {
const res = await fetch(`${booklogrUrl}/v1/profiles/${booklogrUser}`)
const data = await res.json() const data = await res.json()
const books = data.books.filter( if (
({ reading_status }) => reading_status === 'Currently reading', !data.data?.user?.currently_reading_books ||
) data.data.user.currently_reading_books.length === 0
return books.map(({ title }) => title).join(',<br />') ) {
throw new Error('Nothing!')
}
const book = data.data.user.currently_reading_books[0]
return {
text: book.title,
url: `https://hardcover.app/books/${book.id}`,
}
}
// ========================================================================
// 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`,
)
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 }
} }