Compare commits

..

No commits in common. "main" and "v0.0.1" have entirely different histories.
main ... v0.0.1

11 changed files with 293 additions and 873 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
OMGLOL_KEY=
OMGLOL_USERNAME=melanie
TRAKT_SLURM=

View file

@ -24,5 +24,3 @@ jobs:
OMGLOL_KEY: ${{ secrets.OMGLOL_KEY }} OMGLOL_KEY: ${{ secrets.OMGLOL_KEY }}
OMGLOL_USERNAME: ${{ vars.OMGLOL_USERNAME }} OMGLOL_USERNAME: ${{ vars.OMGLOL_USERNAME }}
TRAKT_SLURM: ${{ secrets.TRAKT_SLURM }} TRAKT_SLURM: ${{ secrets.TRAKT_SLURM }}
STEAM_WEBAPI_KEY: ${{ secrets.STEAM_WEBAPI_KEY }}
HARDCOVER_API_KEY: ${{ secrets.HARDCOVER_API_KEY }}

4
.gitignore vendored
View file

@ -1,3 +1,7 @@
.DS_Store .DS_Store
node_modules/* node_modules/*
.env .env
# Local development files
fetch-now.js
now-local.html
preview-now.sh

230
README.md
View file

@ -2,28 +2,24 @@
Automatically updates your omg.lol Now page with your latest activity. Automatically updates your omg.lol Now page with your latest activity.
> Fork of [melanie/now-updater](https://source.tube/melanie/now-updater) with added support for HTML links, images/videos, Steam, Hardcover, some.pics, and simplified service configuration. > Fork of [melanie/now-updater](https://source.tube/melanie/now-updater) with added support for HTML output links, Mastodon, Last.fm, and dynamic URLs.
Demo: [dylan.omg.lol/now](https://dylan.omg.lol/now) Demo: [dylan.omg.lol/now](https://dylan.omg.lol/now)
## How It Works ## How It Works
Fetches content from RSS feeds, JSON feeds, and different services (Letterboxd, Trakt, Last.fm, Steam, Hardcover, etc.) and updates HTML or markdown links on your Now page. Supports optional images and videos for services that provide them (These are... iffy so if something breaks it's probably this). Runs every 3 hours via Forgejo Actions, only updating your page when changes are detected. Fetches content from RSS feeds, JSON feeds, and different services (Letterboxd, Trakt, Mastodon, Last.fm) and updates HTML or markdown links on your Now page. Runs every 3 hours via Forgejo Actions, only updating your page when changes are detected.
Images are matched using a `data-service` attribute on the img tag, which allows them to be updated repeatedly even after the initial replacement.
### Example ### Example
Your Now page links will be automatically updated from this: Your Now page links will be automatically updated from this:
**HTML links** (before): **HTML links** (before):
```html ```html
<a href="service-link">blog</a> <a href="https://your-blog.com">tktk</a>
<a href="service-link">gitlab</a>
``` ```
**Markdown links** (before): **Markdown links** (before):
```markdown ```markdown
[blog](service-link) [tktk](https://your-blog.com)
[gitlab](service-link)
``` ```
to this: to this:
@ -31,12 +27,10 @@ to this:
**HTML links** (after): **HTML links** (after):
```html ```html
<a href="https://your-blog.com/2025/12/my-latest-post">My Latest Post</a> <a href="https://your-blog.com/2025/12/my-latest-post">My Latest Post</a>
<a href="https://gitlab.com/username/project">Pushed to repository</a>
``` ```
**Markdown links** (after): **Markdown links** (after):
```markdown ```markdown
[My Latest Post](https://your-blog.com/2025/12/my-latest-post) [My Latest Post](https://your-blog.com/2025/12/my-latest-post)
[Pushed to repository](https://gitlab.com/username/project)
``` ```
## Setup ## Setup
@ -44,9 +38,8 @@ to this:
1. Fork this repo 1. Fork this repo
2. Enable Actions in repo settings 2. Enable Actions in repo settings
3. Add Actions variables: `OMGLOL_USERNAME` (your omg.lol username) 3. Add Actions variables: `OMGLOL_USERNAME` (your omg.lol username)
4. Add Actions secrets: `OMGLOL_KEY` (your API key) 4. Add Actions secrets: `OMGLOL_KEY` (your API key), `TRAKT_SLURM` (optional, see [Trakt](#trakt) for details)
5. Add optional secrets for services you want to use: `TRAKT_SLURM`, `STEAM_WEBAPI_KEY`, `HARDCOVER_API_KEY` 5. Add your sources in `config.js`
6. Activate your services in `services.js` by setting `isActive: true`
For local development, copy `.env.example` to `.env` with your credentials. For local development, copy `.env.example` to `.env` with your credentials.
@ -54,214 +47,67 @@ For local development, copy `.env.example` to `.env` with your credentials.
**Now page template:** **Now page template:**
```html ```html
I wrote this: <a href="https://your-blog.com">blog</a> I wrote this: [tktk](https://your-blog.com)
I watched this: <a href="https://letterboxd.com/username">letterboxd</a> I watched this: [tktk](trakt.tv/users/username)
<img data-service="letterboxd" src="letterboxd-image" style="max-width: 120px;">
I listened to: <a href="https://www.last.fm/user/username">lastfm</a>
``` ```
**services.js:** **config.js:**
```js ```js
export const services = [ export const linkFormat = 'markdown'
export const items = [
{ {
id: 'blog', id: 'blog',
isActive: true, regex: htmlLinkRegex('your-blog.com'),
feedUrl: 'https://your-blog.com/feed.json', getLatest: async () => getJsonFeedItemTitle('https://your-blog.com/feed.json'),
feedType: 'json',
}, },
{ {
id: 'letterboxd', id: 'letterboxd',
isActive: true, regex: htmlLinkRegex('letterboxd.com/username'),
userId: 'your-username', getLatest: async () => getLetterboxdActivity('username', true), // with image
feedUrlTemplate: 'https://letterboxd.com/{userId}/rss/',
feedType: 'letterboxd',
showImage: true, // optional: include movie posters
}, },
{ {
id: 'lastfm', id: 'trakt-movie',
isActive: true, regex: htmlLinkRegex('trakt.tv/users/username'),
userId: 'your-username', getLatest: async () => getTraktMovie('username', 'your-trakt-id', false), // text only
feedUrlTemplate: 'https://lfm.xiffy.nl/{userId}',
feedType: 'rss',
}, },
] ]
``` ```
## Configuration ## Configuration
### Quick Start Edit `config.js` to define your content sources. Each item needs:
- `id`: identifier for logs
- `regex`: pattern to match your link
- `getLatest`: async function that fetches and returns latest content
1. **In your Now page**, use service names as link text: Set `linkFormat` to `'html'` or `'markdown'` depending on your Now page format.
```html
<a href="service-link">blog</a>
<a href="service-link">gitlab</a>
```
2. **In services.js**, find the service and flip `isActive` to `true`: ## Available Functions
```javascript
{
id: 'gitlab',
isActive: true, // just change this!
userId: 'your-username',
feedUrlTemplate: 'https://gitlab.com/users/{userId}/activity.atom',
feedType: 'atom',
}
```
The service `id` is used to match the link text in your Now page (e.g., `<a href="...">blog</a>` matches `id: 'blog'`). If you need multiple instances of the same service just create separate entries in `services.js` with unique `id`s and matching link texts. Like so:
```js
{
id: 'mastodon-personal',
isActive: false,
userId: 'dylan',
instance: 'social.lol', // your mastodon instance
feedUrlTemplate: 'https://{instance}/@{userId}.rss',
feedType: 'mastodon',
showImage: true,
},
{
id: 'mastodon-work',
isActive: false,
userId: 'mrdylan',
instance: 'mastodon.instance', // your mastodon instance
feedUrlTemplate: 'https://{instance}/@{userId}.rss',
feedType: 'mastodon',
showImage: true,
},
```
Then you can have multiple Mastodon accounts on your Now page with different link texts: `[mastodon-personal](service-link)` and `[mastodon-work](service-link)`.
### Files
- **config.js** - Link format setting (`html` or `markdown`) and filter logic
- **services.js** - All available services with `isActive` flags (edit this to enable/disable services)
- **utils.js** - Generic feed handlers and service-specific logic
### Service Structure
Each service has:
- `id`: Identifier that matches the link text in your Now page (e.g., `id: 'blog-1'` matches `<a>blog-1</a>`)
- `isActive`: `true` to enable, `false` to disable
- `feedType`: Handler to use (`rss`, `atom`, `json`, or special handler name like `letterboxd`, `steam`, `hardcover`)
- `feedUrl`: Direct feed URL (use this for custom feeds without usernames)
- `feedUrlTemplate`: URL template with `{userId}` or `{instance}` placeholders
- `userId`: Your username/ID (only needed with feedUrlTemplate)
- `showImage`: Optional. Controls whether image/videos are included (defaults based on service)
- Extra params as needed (e.g., `instance` for federated services, `traktId` for Trakt widgets)
Set `linkFormat` in config.js to `'html'` or `'markdown'` depending on your Now page format.
### Image and Video Support
Many services support optional images and videos:
- **Letterboxd**: Movie posters from CDATA descriptions
- **Trakt**: Episode/movie poster widgets (portrait orientation, change the URL from `poster` to `thumb` for landscape)
- **Hardcover**: Book cover images
- **Steam**: Game library posters
- **Mastodon**: Attached images and videos from `media:content` tags (iffy, may not always work)
- **some.pics**: Photo images from RSS CDATA
Control image inclusion with the `showImage` flag in your service config. Images are returned separately from text so you can control placement on your Now page.
To use images, your img tags must include a `data-service` attribute matching the service `id` from services.js. For example:
```html
<img data-service="letterboxd" src="letterboxd-image" style="max-width: 120px;">
<img data-service="steam" src="steam-image" style="max-width: 120px;">
```
The `src` attribute can start with a placeholder (like `letterboxd-image`) or an existing URL - the updater will replace it either way as long as the `data-service` attribute is present.
## Available Services
All handlers now use a config object with consistent parameters. The `feedType` in services.js maps to these handlers.
### Generic Feed Handlers
- `getRSSItemTitle(feedUrl)` - Standard RSS feeds - `getRSSItemTitle(feedUrl)` - Standard RSS feeds
- `getAtomFeed(url)` - Standard Atom feeds - `getJsonFeedItemTitle(feedUrl, showImage)` - JSON feeds
- `getJsonFeedItemTitle(feedUrl, showImage)` - JSON feeds with optional images - `getLetterboxdActivity(username, showImage)` - Letterboxd activity
- `getTraktEpisode(username, id, showImage)` - Latest TV episode
- `getTraktMovie(username, id, showImage)` - Latest movie
- `getTraktEpisodeAndMovie(username, id, showImage)` - Latest episode and movie combined
- `getMastodonPost(feedUrl)` - Latest non-reply Mastodon post
- `getMalojaScrobble(url)` - Latest music scrobble from [maloja](https://github.com/krateng/maloja)
### omg.lol Services **Notes:**
- `getSomePicsPost(feedUrl)` - some.pics photo posts with images extracted from CDATA - `showImage` (defaults to `true`) controls whether images/posters are included in the output. Set it to `false` to show text only.
- For Last.fm, use `getRSSItemTitle('https://lfm.xiffy.nl/your-username')` (via [lfm.xiffy.nl](https://lfm.xiffy.nl))
### Music ## Trakt
- `getListenBrainzScrobble(config)` - ListenBrainz scrobbles (not tested, let me know!)
- Config: `{ feedUrl, userId }`
- For Last.fm, use generic RSS handler with `https://lfm.xiffy.nl/{userId}`
### Social Media
- `getMastodonPost(config)` - Latest non-reply Mastodon post with optional images/videos
- Config: `{ feedUrl, showImage }`
- Extracts media from `media:content` tags and filters out replies
### Movies & TV
- `getLetterboxdActivity(config)` - Letterboxd activity with optional poster images
- Config: `{ feedUrl, showImage }`
- Extracts images from CDATA descriptions
- `getTraktEpisode(config)` - Latest TV episode with optional poster
- Config: `{ userId, traktId, showImage }`
- Requires `TRAKT_SLURM` environment variable
- Uses portrait poster widgets
- `getTraktMovie(config)` - Latest movie with optional poster
- Config: `{ userId, traktId, showImage }`
- Requires `TRAKT_SLURM` environment variable
- Uses portrait poster widgets
### Books
- `getHardcoverActivity(config)` - Currently reading books with cover images
- Config: `{ userId, feedUrl }`
- Uses GraphQL API, requires `HARDCOVER_API_KEY`
- `userId` must be numeric user ID (not username)
### Gaming
- `getSteamRecentlyPlayed(config)` - Recently played Steam game with library poster
- Config: `{ userId }`
- Requires `STEAM_WEBAPI_KEY` environment variable
- `userId` must be Steam ID 64 (convert at https://steamid.io/)
### Code & Development
- `getSourceTubeActivity(config)` - Forgejo activity on source.tube
- Config: `{ feedUrl, userId }`
- Strips username prefix from activity descriptions (e.g., "dylan pushed to repository" becomes "pushed to repository")
**Important Notes:**
- `showImage` controls whether images/videos are included in the response
- Images and videos are returned separately from text (as `{ text, url, image, video }`)
- Generic RSS/Atom/JSON handlers work with most standard feeds
## API Keys Setup
### Trakt
To get your Trakt slurm key, follow these steps: To get your Trakt slurm key, follow these steps:
1. Go to your History page on Trakt: `https://trakt.tv/users/your-username/history` 1. Go to your History page on Trakt: `https://trakt.tv/users/your-username/history`
2. There should be an RSS feed icon on the top right of the page. Click it. 2. There should be an RSS feed icon on the top right of the page. Click it.
3. The URL it shows will look something like this: `https://trakt.tv/users/username/history.atom?slurm=your-slurm-key` 3. The URL it shows will look something like this: `https://trakt.tv/users/crankle/history.atom?slurm=your-slurm-key`
4. Copy the `slurm` value from the URL and add it as a secret in your Actions settings named `TRAKT_SLURM`. 4. Copy the `slurm` value from the URL and add it as a secret in your Actions settings named `TRAKT_SLURM`.
5. Add it to your `.env` file for local testing.
### Steam There may be a better way to get this key, but this is how I found it.
To get your Steam Web API key:
1. Visit https://steamcommunity.com/dev/apikey
2. Enter a domain name (I used omg.lol, I don't know if that will cause me issues later)
3. Agree to the terms and get your API key
4. Add it to your `.env` as `STEAM_WEBAPI_KEY` and to Actions secrets
5. Find your Steam ID 64 at https://steamid.io/ (enter your profile URL)
**Note**: The Steam handler uses the GetOwnedGames API endpoint sorted by `rtime_last_played`, which has no time limit. GetRecentlyPlayedGames can be used too, but it only shows anything played in the last 14 days which may be limiting.
### Hardcover
To get your Hardcover API key:
1. Visit https://hardcover.app/account/api (Make sure you're logged in)
2. Find the authorisation header token section
3. **Important**: The token shown will include "Bearer " at the beginning - do not include this prefix when adding it (Note the space after "Bearer", that needs to be removed too)
4. Add the token to your `.env` file as `HARDCOVER_API_KEY` and to Actions secrets
## Credits ## Credits

36
biome.json Normal file
View file

@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main"
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

View file

@ -1,7 +1,60 @@
import { services } from './services.js' import {
getJsonFeedItemContent,
getJsonFeedItemTitle,
getLetterboxdActivity,
getMalojaScrobble,
getMastodonPost,
getReadingBooklogr,
getRSSItemTitle,
getTraktEpisode,
getTraktMovie,
htmlLinkRegex,
} from './utils.js'
// set to 'html' for HTML links or 'markdown' for markdown links // Set to 'html' for HTML links or 'markdown' for markdown links
export const linkFormat = 'html' export const linkFormat = 'html'
// filter to only active services - edit services.js to toggle isActive or customise export const items = [
export const items = services.filter((s) => s.isActive) {
id: 'blog',
regex: htmlLinkRegex('dylan.weblog.lol'),
getLatest: async () =>
getJsonFeedItemTitle('https://dylan.weblog.lol/feed.json'),
},
{
id: 'pics',
regex: htmlLinkRegex('some.pics'),
getLatest: async () =>
getRSSItemTitle('https://dylan.some.pics/rss'),
},
{
id: 'lastfm',
regex: htmlLinkRegex('www.last.fm/music'),
getLatest: async () =>
getRSSItemTitle('https://lfm.xiffy.nl/lookathimthere'),
},
{
id: 'mastodon',
regex: htmlLinkRegex('social.lol/@dylan'),
getLatest: async () =>
getMastodonPost('https://social.lol/@dylan.rss'),
},
{
id: 'letterboxd',
regex: htmlLinkRegex('letterboxd.com/STFUDonny'),
getLatest: async () =>
getLetterboxdActivity('stfudonny', false),
},
{
id: 'trakt-episode',
regex: htmlLinkRegex('trakt.tv/episodes'),
getLatest: async () =>
getTraktEpisode('crankle', '78e1e87b446901f7e4f0883dd4995cec', false),
},
{
id: 'trakt-movie',
regex: htmlLinkRegex('trakt.tv/movies'),
getLatest: async () =>
getTraktMovie('crankle', '78e1e87b446901f7e4f0883dd4995cec', false),
},
]

141
index.js
View file

@ -1,40 +1,7 @@
import 'dotenv/config' import 'dotenv/config'
import { items, linkFormat } from './config.js' import { items, linkFormat } from './config.js'
import { import { markdownCharEscape } from './utils.js'
getAtomFeed,
getHardcoverActivity,
getJsonFeedItemTitle,
getLetterboxdActivity,
getListenBrainzScrobble,
getMalojaScrobble,
getMastodonPost,
getRSSItemTitle,
getSomePicsPost,
getSourceTubeActivity,
getSteamRecentlyPlayed,
getTraktEpisode,
getTraktMovie,
htmlServiceLinkRegex,
markdownCharEscape,
markdownServiceLinkRegex,
} from './utils.js'
const feedHandlers = {
rss: getRSSItemTitle,
atom: getAtomFeed,
json: getJsonFeedItemTitle,
'some.pics': getSomePicsPost,
'source.tube': getSourceTubeActivity,
mastodon: getMastodonPost,
letterboxd: getLetterboxdActivity,
'trakt-episode': getTraktEpisode,
'trakt-movie': getTraktMovie,
steam: getSteamRecentlyPlayed,
listenbrainz: getListenBrainzScrobble,
maloja: getMalojaScrobble,
hardcover: getHardcoverActivity,
}
const OMGLOL_API = `https://api.omg.lol/address/${process.env.OMGLOL_USERNAME}/now` const OMGLOL_API = `https://api.omg.lol/address/${process.env.OMGLOL_USERNAME}/now`
@ -65,125 +32,37 @@ export default async function now() {
let newNow = now let newNow = now
await Promise.all( await Promise.all(
items.map(async (item) => { items.map(async ({ id, regex, getLatest }) => {
try { try {
const handler = feedHandlers[item.feedType] const latest = await getLatest()
if (!handler) { console.log(`${id}: ${latest.text}`)
throw new Error(`Unknown feedType: ${item.feedType}`) console.log(`${id} URL: ${latest.url}`)
}
// build feedUrl from template if provided // Apply markdown escaping only for markdown links
let feedUrl = item.feedUrl
if (item.feedUrlTemplate) {
feedUrl = item.feedUrlTemplate
.replace('{userId}', item.userId || '')
.replace('{instance}', item.instance || '')
}
// generic handlers expect just feedUrl string, special handlers expect config object
const handlerParam = [
'rss',
'atom',
'json',
'maloja',
'some.pics',
].includes(item.feedType)
? feedUrl
: { ...item, feedUrl }
const latest = await handler(handlerParam)
console.log(`${item.id}: ${latest.text}`)
console.log(`${item.id} URL: ${latest.url}`)
if (latest.image) console.log(`${item.id} Image: ${latest.image}`)
if (latest.video) console.log(`${item.id} Video: ${latest.video}`)
// apply markdown escaping only for markdown links
const displayText = const displayText =
linkFormat === 'html' linkFormat === 'html'
? latest.text ? latest.text
: markdownCharEscape(latest.text) : markdownCharEscape(latest.text)
if (latest && latest.text && latest.url) { if (latest && latest.text && latest.url) {
// generate regex based on id and linkFormat newNow = newNow.replace(regex, (match, openTag, closeTag) => {
const regex = // Replace href in opening tag
linkFormat === 'html'
? htmlServiceLinkRegex(item.id)
: markdownServiceLinkRegex(item.id)
console.log(`\nDEBUG: Testing regex for ${item.id}`)
console.log(`Regex: ${regex}`)
const matched = regex.test(newNow)
console.log(`Regex match found: ${matched}`)
newNow = newNow.replace(regex, (match, openTag, oldText, closeTag) => {
console.log(`DEBUG: Replacing match for ${item.id}`)
console.log(`Old match: ${match}`)
console.log(`Old text: ${oldText}`)
// replace href in opening tag
const updatedOpenTag = openTag.replace( const updatedOpenTag = openTag.replace(
/href=["'][^"']*["']/, /href=["'][^"']*["']/,
`href="${latest.url}"`, `href="${latest.url}"`,
) )
const result = `${updatedOpenTag}${displayText}${closeTag}` return `${updatedOpenTag}${displayText}${closeTag}`
console.log(`New replacement: ${result}`)
return result
}) })
// replace image/video placeholders if available, otherwise remove tags
const imageId = `${item.id}-image`
const videoId = `${item.id}-video`
if (latest.image) {
// html image replacement
newNow = newNow.replace(
new RegExp(`(<img[^>]+src=["'])${imageId}(["'][^>]*>)`, 'gi'),
`$1${latest.image}$2`,
)
// markdown image replacement
newNow = newNow.replace(
new RegExp(`(!\\[[^\\]]*\\]\\()${imageId}(\\))`, 'gi'),
`$1${latest.image}$2`,
)
} else {
// remove img tags if no image available (handle multi-line tags and any attribute order)
newNow = newNow.replace(
new RegExp(`\\s*<img\\b[^>]*\\bsrc=["']${imageId}["'][^>]*>\\s*`, 'gis'),
'',
)
// remove markdown image
newNow = newNow.replace(
new RegExp(`!\\[[^\\]]*\\]\\(${imageId}\\)`, 'gi'),
'',
)
}
if (latest.video) {
// html video replacement
newNow = newNow.replace(
new RegExp(`(<video[^>]+src=["'])${videoId}(["'][^>]*>)`, 'gi'),
`$1${latest.video}$2`,
)
} else {
// remove video tags if no video available
newNow = newNow.replace(
new RegExp(`\\s*<video\\b[^>]*\\bsrc=["']${videoId}["'][^>]*>[\\s\\S]*?</video>\\s*`, 'gis'),
'',
)
}
} }
} catch (e) { } catch (e) {
console.warn(`⚠️ Failed to fetch ${item.id}`, e) console.warn(`⚠️ Failed to fetch ${id}`, e)
} }
}), }),
) )
console.log('\n=== FINAL COMPARISON ===')
console.log(`Original length: ${now.length}`)
console.log(`New length: ${newNow.length}`)
console.log(`Strings are equal: ${now === newNow}`)
if (now === newNow) { if (now === newNow) {
console.log('\nNow page has no changes') console.log('\nNow page has no changes')
} else { } else {
console.log('\nChanges detected! Updating Now page...')
await setNow(newNow) await setNow(newNow)
} }
} }

6
lefthook.yml Normal file
View file

@ -0,0 +1,6 @@
pre-commit:
commands:
check:
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
stage_fixed: true

View file

@ -1,31 +1,65 @@
<h1>What I've been up to</h1> # What I've been up to
<p>This page <em>should</em> update automatically with my latest activity.</p> This page automatically updates with my latest activity.
<div style="display: flex; flex-direction: column; gap: 0.75rem;"> <div style="display: flex; flex-direction: column; gap: 0.75rem;">
<div style="background: rgba(166, 227, 161, 0.15); padding: 1rem; border: 4px solid #a6e3a1; color:white;text-align: left;"><div><i class="fa-brands fa-lastfm"></i> I listened to this<br><a data-service="lastfm" href="https://www.last.fm/user/lookathimthere" style="color: #a6e3a1 !important;">lastfm</a></div></div> <div style="background: rgba(249, 226, 175, 0.15); padding: 1rem; border: 4px solid #f9e2af; color:white;">
<div style="background: rgba(250, 179, 135, 0.15); padding: 1rem; border: 4px solid #fab387; color:white; display: flex; justify-content: space-between; align-items: center;"><div style="text-align: left;"><i class="fa-solid fa-tv"></i> I watched this episode<br><a data-service="trakt-episode" href="https://trakt.tv/episodes" style="color: #fab387 !important;">trakt-episode</a></div><img data-service="trakt-episode" src="trakt-episode-image" style="max-width: 120px; max-height: 120px; object-fit: cover; border-radius: 4px;"></div> I wrote this<br><a href="https://dylan.weblog.lol" style="color: #f9e2af !important;">tktk</a>
<div style="background: rgba(250, 179, 135, 0.15); padding: 1rem; border: 4px solid #fab387; color:white; display: flex; justify-content: space-between; align-items: center;"><div style="text-align: left;"><i class="fa-solid fa-film"></i> I watched this movie<br><a data-service="trakt-movie" href="https://trakt.tv/movies/" style="color: #fab387 !important;">trakt-movie</a></div><img data-service="trakt-movie" src="trakt-movie-image" style="max-width: 120px; max-height: 120px; object-fit: cover; border-radius: 4px;"></div> </div>
<div style="background: rgba(243, 139, 168, 0.15); padding: 1rem; border: 4px solid #f38ba8; color:white; display: flex; justify-content: space-between; align-items: center;"><div style="text-align: left;"><i class="fa-solid omg-icon omg-letterboxd"></i> I rated this<br><a data-service="letterboxd" href="https://letterboxd.com/STFUDonny" style="color: #f38ba8 !important;">letterboxd</a></div><img data-service="letterboxd" src="letterboxd-image" style="max-width: 120px; max-height: 120px; object-fit: cover; border-radius: 4px;"></div> <div style="background: rgba(203, 166, 247, 0.15); padding: 1rem; border: 4px solid #cba6f7; color:white;">
<div style="background: rgba(180, 190, 254, 0.15); padding: 1rem; border: 4px solid #b4befe; color:white; display: flex; justify-content: space-between; align-items: center;"><div style="text-align: left;"><i class="fa-solid fa-book"></i> I'm reading this<br><a data-service="hardcover" href="https://hardcover.app" style="color: #b4befe !important;">hardcover</a></div><img data-service="hardcover" src="hardcover-image" style="max-width: 120px; max-height: 120px; object-fit: cover; border-radius: 4px;"></div> I posted this<br>
<div style="background: rgba(137, 180, 250, 0.15); padding: 1rem; border: 4px solid #89b4fa; color:white; display: flex; justify-content: space-between; align-items: center;"><div style="text-align: left;"><i class="fa-brands fa-steam"></i> I played this<br><a data-service="steam" href="https://store.steampowered.com" style="color: #89b4fa !important;">steam</a></div><img data-service="steam" src="steam-image" style="max-width: 120px; max-height: 120px; object-fit: cover; border-radius: 4px;"></div> <i class="fa-brands fa-mastodon"></i> <a href="https://social.lol/@dylan" style="color: #cba6f7 !important;">tktk</a>
<div style="background: rgba(148, 226, 213, 0.15); padding: 1rem; border: 4px solid #94e2d5; color:white; display: flex; justify-content: space-between; align-items: center;"><div style="text-align: left;"><i class="fa-regular fa-images"></i> I shared this photo<br><a data-service="some.pics" href="https://dylan.some.pics" style="color: #94e2d5 !important;">some.pics</a></div><img data-service="some.pics" src="some.pics-image" style="max-width: 120px; max-height: 120px; object-fit: cover; border-radius: 4px;"></div> </div>
<div style="background: rgba(249, 226, 175, 0.15); padding: 1rem; border: 4px solid #f9e2af; color:white;text-align: left;"><i class="fa-regular fa-newspaper"></i>I wrote this<br><a data-service="weblog" href="https://dylan.weblog.lol" style="color: #f9e2af !important;">weblog</a></div> <div style="background: rgba(148, 226, 213, 0.15); padding: 1rem; border: 4px solid #94e2d5; color:white;">
<div style="background: rgba(137, 220, 235, 0.15); padding: 1rem; border: 4px solid #89dceb; color:white;text-align: left;"><i class="fa-solid fa-code"></i> I pushed some (bad) code<br><a data-service="source.tube" href="https://source.tube/dylan" style="color: #89dceb !important;">source.tube</a></div> I shared this photo<br><a href="https://dylan.some.pics" style="color: #94e2d5 !important;">tktk</a>
</div>
<div style="background: rgba(166, 227, 161, 0.15); padding: 1rem; border: 4px solid #a6e3a1; color:white;">
I listened to this<br>
<i class="fa-brands fa-lastfm"></i> <a href="https://www.last.fm/user/lookathimthere" style="color: #a6e3a1 !important;">tktk</a>
</div>
<div style="background: rgba(243, 139, 168, 0.15); padding: 1rem; border: 4px solid #f38ba8; color:white;">
I rated this<br><a href="https://letterboxd.com/STFUDonny" style="color: #f38ba8 !important;">tktk</a>
</div>
<div style="background: rgba(250, 179, 135, 0.15); padding: 1rem; border: 4px solid #fab387; color:white;">
I watched this episode<br>
<i class="fa-solid fa-tv"></i> <a href="https://trakt.tv/episodes" style="color: #fab387 !important;">tktk</a><br>
and this movie<br>
<i class="fa-solid fa-film"></i> <a href="https://trakt.tv/movies/" style="color: #fab387 !important;">tktk</a>
</div>
</div> </div>
<h5>{last-updated}</h5> ##### {last-updated}
<p style="font-size:1em !important;">Want your own automatically updated Now page? Check out <a href="https://source.tube/dylan/now-updater">now-updater</a>.</p> <p style="font-size:0.825em !important;">Want your own automatically updated Now page? Check out <a href="https://source.tube/dylan/now-updater">now-updater</a>.</p>
<hr> ---
<div class="badges"> <div class="badges">
<a href="https://people.pledge.party"><img src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596198/people_pledge_badge_party_pink_cream_88x31_uyqfgg.png" alt="People Pledge 88x31 Badge"></a> <!-- People Pledge -->
<a href="https://social.lol/@dylan"><img src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596200/mastodon_ek8b0l.gif" alt="Follow me on Mastodon - @dylan@social.lol 88x31 Badge"></a> <a href="https://people.pledge.party"><img
<a href="https://source.tube/dylan/now-updater"><img src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596194/badly-hand-coded-88x31_txfsai.gif" alt="Badly Hand-Coded and Proud 88x31 Badge"></a> src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596198/people_pledge_badge_party_pink_cream_88x31_uyqfgg.png"
<a href="https://www.linux.org/pages/download/"><img src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596863/gnu-linux_ktn9ec.gif" alt="omg.lol 88x31 Badge"></a> alt="People Pledge 88x31 Badge"></a>
<a href="https://annas-archive.li"><img src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596623/freencool_mdunpb.gif" alt="Anna's Archive 88x31 Badge"></a> <!-- social.lol -->
<a href="https://privacybadger.org"><img src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596864/internetprivacy_tdutsi.gif" alt="Internet Privacy 88x31 Badge"></a> <a href="https://social.lol/@dylan"><img
<a href="https://home.omg.lol/referred-by/dylan"><img src="https://res.cloudinary.com/dkubc5dik/image/upload/omglol-88x31_rrl1fj.svg" alt="omg.lol 88x31 Badge"></a> src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596200/mastodon_ek8b0l.gif"
alt="Follow me on Mastodon - @dylan@social.lol 88x31 Badge"></a>
<!-- Badly Hand-Coded -->
<a href="https://source.tube/dylan/now-updater"><img
src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596194/badly-hand-coded-88x31_txfsai.gif"
alt="Badly Hand-Coded and Proud 88x31 Badge"></a>
<!-- Made on GNU/Linux -->
<a href="https://www.linux.org/pages/download/"><img
src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596863/gnu-linux_ktn9ec.gif"
alt="omg.lol 88x31 Badge"></a>
<!-- Anna's Archive -->
<a href="https://annas-archive.li"><img
src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596623/freencool_mdunpb.gif"
alt="Anna's Archive 88x31 Badge"></a>
<!-- Internet Privacy -->
<a href="https://privacybadger.org"><img
src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596864/internetprivacy_tdutsi.gif"
alt="Internet Privacy 88x31 Badge"></a>
<!-- omg.lol -->
<a href="https://home.omg.lol/referred-by/dylan"><img
src="https://res.cloudinary.com/dkubc5dik/image/upload/omglol-88x31_rrl1fj.svg" alt="omg.lol 88x31 Badge"></a>
</div> </div>
<h3><a href="https://dylan.omg.lol" style="font-weight: normal;">Back to my omg.lol page!</a></h3> ### [Back to my omg.lol page!](https://dylan.omg.lol)

View file

@ -1,191 +0,0 @@
// just flip isActive to true/false to enable/disable services
// customise usernames/URLs/IDs for each service
export const services = [
// ========================================================================
// omg.lol services
// ========================================================================
{
id: 'weblog',
isActive: false,
userId: 'dylan', // your omg.lol username
feedUrlTemplate: 'https://{userId}.weblog.lol/feed.json',
feedType: 'json',
},
{
id: 'some.pics',
isActive: false,
userId: 'dylan', // your omg.lol username
feedUrlTemplate: 'https://{userId}.some.pics/rss',
feedType: 'some.pics',
},
// ========================================================================
// blogging & writing
// ========================================================================
{
id: 'blog',
isActive: true,
feedUrl: 'https://blog.agnes.love/feed/', // direct url to your blog's rss feed
feedType: 'atom',
},
// ========================================================================
// music
// ========================================================================
{
id: 'lastfm',
isActive: true,
userId: 'keegbovo', // your last.fm username
feedUrlTemplate: 'https://lfm.xiffy.nl/{userId}',
feedType: 'rss',
},
{
id: 'listenbrainz',
isActive: false,
userId: 'your-username', // your listenbrainz username
feedUrlTemplate: 'https://api.listenbrainz.org/1/user/{userId}/listens?count=1',
feedType: 'listenbrainz',
},
{
id: 'maloja',
isActive: false,
feedUrl: 'https://your-maloja-instance.com', // your maloja instance url
feedType: 'maloja',
},
// ========================================================================
// social media
// ========================================================================
{
id: 'mastodon',
isActive: true,
userId: 'alien', // your mastodon username
instance: 'lesbian.alien.dentist', // your mastodon instance
feedUrlTemplate: 'lesbian.alien.dentist/@alien.atom',
feedType: 'atom',
showImage: true,
},
{
id: 'pixelfed',
isActive: false,
userId: 'your-username', // your pixelfed username
instance: 'pixelfed.instance', // your pixelfed instance
feedUrlTemplate: 'https://{instance}/@{userId}.atom',
feedType: 'atom',
},
{
id: 'bluesky',
isActive: true,
userId: 'agnes.love', // your bluesky handle
feedUrlTemplate: 'https://bsky.app/profile/agnes.love/rss',
feedType: 'rss',
},
// ========================================================================
// movies & tv
// ========================================================================
{
id: 'letterboxd',
isActive: false,
userId: 'stfudonny', // your letterboxd username
feedUrlTemplate: 'https://letterboxd.com/{userId}/rss/',
feedType: 'letterboxd',
showImage: true,
},
{
id: 'trakt-episode',
isActive: false,
userId: 'crankle', // your trakt username
traktId: '78e1e87b446901f7e4f0883dd4995cec', // your trakt ID
feedType: 'trakt-episode',
showImage: true,
},
{
id: 'trakt-movie',
isActive: false,
userId: 'crankle', // your trakt username
traktId: '78e1e87b446901f7e4f0883dd4995cec', // your trakt ID
feedType: 'trakt-movie',
showImage: true,
},
// ========================================================================
// books
// ========================================================================
{
id: 'hardcover',
isActive: false,
userId: '62036', // your hardcover user ID (find in your API token or profile URL)
feedUrl: 'https://api.hardcover.app/v1/graphql',
feedType: 'hardcover',
showImage: true,
},
{
id: 'storygraph',
isActive: true,
userId: 'telescopefish', // your storygraph username
feedUrlTemplate: 'https://app.thestorygraph.com/profile/{userId}.rss',
feedType: 'rss',
},
{
id: 'goodreads',
isActive: false,
userId: 'your-user-id', // your goodreads user id (numbers from your profile url)
feedUrlTemplate: 'https://www.goodreads.com/user/updates_rss/{userId}',
feedType: 'rss',
},
// ========================================================================
// gaming
// ========================================================================
{
id: 'steam',
isActive: false,
userId: '76561198022952207', // your steam ID 64 (not username) - convert at steamid.io
feedType: 'steam',
},
// ========================================================================
// code & development
// ========================================================================
{
id: 'source.tube',
isActive: false,
userId: 'dylan', // your source.tube username
feedUrlTemplate: 'https://source.tube/{userId}.rss',
feedType: 'source.tube',
},
{
id: 'gitlab',
isActive: false,
userId: 'your-username', // your gitlab username
feedUrlTemplate: 'https://gitlab.com/users/{userId}/activity.atom',
feedType: 'atom',
},
{
id: 'codeberg',
isActive: false,
userId: 'your-username', // your codeberg username
feedUrlTemplate: 'https://codeberg.org/{userId}.rss',
feedType: 'rss',
},
// ========================================================================
// video & streaming
// ========================================================================
{
id: 'youtube',
isActive: false,
userId: 'UC_your_channel_id', // your youtube channel ID (starts with UC, not your username)
feedUrlTemplate: 'https://www.youtube.com/feeds/videos.xml?channel_id={userId}',
feedType: 'atom',
},
{
id: 'twitch',
isActive: false,
userId: 'your-channel', // your twitch channel name
feedUrlTemplate: 'https://twitchrss.appspot.com/vod/{userId}',
feedType: 'rss',
},
]

412
utils.js
View file

@ -2,26 +2,21 @@ 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 by data-service attribute within an html anchor tag
// used when linkFormat is 'html' in config.js
export function htmlServiceLinkRegex(serviceName) {
return new RegExp( return new RegExp(
`(<a[^>]*data-service=["']${serviceName}["'][^>]*>)([^<]*)(<\\/a>)`, `(\\[).*?(\\]\\(https:\\/\\/${url.replaceAll('.', '\\.')}\\))`,
'i', 'i',
) )
} }
// regex to match service name within a markdown link export function htmlLinkRegex(url) {
// used when linkFormat is 'markdown' in config.js const escapedUrl = url.replaceAll('.', '\\.')
export function markdownServiceLinkRegex(serviceName) { return new RegExp(
return new RegExp(`(\\[)${serviceName}(\\]\\([^)]*\\))`, 'i') `(<a[^>]+href=["']https?:\\/\\/${escapedUrl}[^"']*["'][^>]*>).*?(<\\/a>)`,
'i',
)
} }
// escapes special markdown characters so they display literally
export function markdownCharEscape(text) { export function markdownCharEscape(text) {
return text return text
.replaceAll('`', '\\`') .replaceAll('`', '\\`')
@ -40,11 +35,6 @@ 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()
@ -59,100 +49,7 @@ 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 getMastodonPost(feedUrl) {
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 }
}
// ========================================================================
// omg.lol services
// ========================================================================
// some.pics rss parser - extracts image from cdata description
export async function getSomePicsPost(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()
// extract image from cdata description
const description = item.querySelector('description').textContent
const imgMatch = description.match(/<img[^>]+src="([^"]+)"/)
const image = imgMatch ? imgMatch[1] : null
return { text: title, url: link, image }
}
// ========================================================================
// 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
// 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 res = await fetch(feedUrl)
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' })
@ -164,288 +61,143 @@ export async function getMastodonPost(config) {
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 is a reply // skip if it's a reply (starts with @ mention)
if (description.trim().startsWith('@')) { if (description.trim().startsWith('@')) {
continue continue
} }
const link = item.querySelector('link')?.textContent.trim() 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(/<img[^>]+src="([^"]+)"/)
image = imgMatch ? imgMatch[1] : null
}
// use description as the text, stripping HTML // use description as the text, stripping HTML
let cleanText = sanitizeHtml(description, { let cleanText = sanitizeHtml(description, {
allowedTags: [], allowedTags: [],
allowedAttributes: {}, allowedAttributes: {},
})?.trim() })?.trim()
// replace URLs with a link emoji // replace URLs with link emoji
cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim() cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim()
return { return { text: cleanText, url: link }
text: cleanText,
url: link,
image: showImage ? image : null,
video: showImage ? video : null,
}
} }
// fallback to first item if no non-reply found // fallback to first item if no non-reply found
const firstItem = items[0] const firstItem = items[0]
const description = firstItem.querySelector('description')?.textContent || '' const description = firstItem.querySelector('description')?.textContent || ''
const link = firstItem.querySelector('link')?.textContent.trim() 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(/<img[^>]+src="([^"]+)"/)
image = imgMatch ? imgMatch[1] : null
}
let cleanText = sanitizeHtml(description, { let cleanText = sanitizeHtml(description, {
allowedTags: [], allowedTags: [],
allowedAttributes: {}, allowedAttributes: {},
})?.trim() })?.trim()
// replace URLs with link emoji // replace URLs with 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,
image: showImage ? image : null,
video: showImage ? video : null,
}
} }
// ======================================================================== 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')
const title = item.querySelector('title').textContent const title = item.querySelector('title').textContent
const link = item.querySelector('link').textContent.trim() const link = item.querySelector('link').textContent.trim()
let image = null let text = title
if (showImage) { if (showImage) {
// Parse CDATA content as HTML to extract image // Parse CDATA content as HTML to extract image
const description = item.querySelector('description').textContent const description = item.querySelector('description').textContent
const imgMatch = description.match(/<img[^>]+src="([^"]+)"/) const imgMatch = description.match(/<img[^>]+src="([^"]+)"/)
image = imgMatch ? imgMatch[1] : null const image = imgMatch ? imgMatch[1] : null
text = image ? `${title} ![${title}](${image})` : title
} }
return { text: title, url: link, image } return { text, url: link }
} }
// trakt episode history - requires TRAKT_SLURM variable (see readme for info) export async function getTraktEpisode(username, id, showImage = true) {
// 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/${userId}/history/episodes/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, `https://trakt.tv/users/${username}/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)
const entry = dom.window.document.querySelector('entry') const entry = dom.window.document.querySelector('entry')
const title = entry.querySelector('title').textContent const title = entry.querySelector('title').textContent
const link = entry.querySelector('link').getAttribute('href') const link = entry.querySelector('link').getAttribute('href')
const image = showImage const text = !showImage
? `https://widgets.trakt.tv/users/${traktId}/watched/poster@2x.jpg?type=episode&image_only=1` ? title
: null : `${title} ![${title}](https://widgets.trakt.tv/users/${id}/watched/thumb@2x.jpg?type=episode&image_only=1)`
return { text: title, url: link, image } return { text, url: link }
} }
// trakt movie history - requires TRAKT_SLURM variable (see readme for info) export async function getTraktMovie(username, id, showImage = true) {
// 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/${userId}/history/movies/added/asc.atom?slurm=${process.env.TRAKT_SLURM}`, `https://trakt.tv/users/${username}/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)
const entry = dom.window.document.querySelector('entry') const entry = dom.window.document.querySelector('entry')
const title = entry.querySelector('title').textContent const title = entry.querySelector('title').textContent
const link = entry.querySelector('link').getAttribute('href') const link = entry.querySelector('link').getAttribute('href')
const image = showImage const text = !showImage
? `https://widgets.trakt.tv/users/${traktId}/watched/poster@2x.jpg?type=movie&image_only=1` ? title
: null : `${title} ![${title}](https://widgets.trakt.tv/users/${id}/watched/thumb@2x.jpg?type=movie&image_only=1)`
return { text: title, url: link, image } return { text, url: link }
} }
// ======================================================================== export async function getTraktEpisodeAndMovie(username, id, showImage = true) {
// books const episode = await getTraktEpisode(username, id, showImage)
// ======================================================================== const movie = await getTraktMovie(username, id, showImage)
// 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 { return {
text: `${book.title} by ${author}`, text: `${episode.text} <br>and<br> ${movie.text}`,
url: `https://hardcover.app/books/${book.id}`, url: `https://trakt.tv/users/${username}`,
image: book.image?.url || null,
} }
} }
// ======================================================================== export async function getReadingBooklogr(booklogrUrl, booklogrUser) {
// gaming const res = await fetch(`${booklogrUrl}/v1/profiles/${booklogrUser}`)
// ======================================================================== const data = await res.json()
const books = data.books.filter(
// steam web api - grabs your most recently played game ({ reading_status }) => reading_status === 'Currently reading',
// 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() return books.map(({ title }) => title).join(',<br />')
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 }
} }