Compare commits

...

62 commits
v0.0.1 ... main

Author SHA1 Message Date
agnes
bafb19d339 Update services.js 2026-08-13 14:24:04 +00:00
agnes
20bec36c74 Update services.js 2026-08-13 14:18:45 +00:00
agnes
18a2a65458 Delete .env 2026-08-13 08:58:00 +00:00
agnes
3b290d44b6 Update .env 2026-08-13 08:57:05 +00:00
agnes
5abeb31f06 Update services.js 2026-08-13 08:56:05 +00:00
agnes
bb88932bde Update services.js 2026-08-13 08:54:42 +00:00
agnes
896919d0c3 Update services.js 2026-08-13 08:52:25 +00:00
dylan
bc18d5beca Merge pull request 'Fix regex, again!' (#5) from fix/image-updates into main
Reviewed-on: https://source.tube/dylan/now-updater/pulls/5
2025-12-31 09:30:12 +00:00
dylan
05904356b9 updated now-page-template with new image handling and layout adjustments 2025-12-31 00:19:37 +00:00
dylan
8d2c808c68 updated regex replacement to include old text in debug logging 2025-12-31 00:19:26 +00:00
dylan
7740822918 updated regex to match service by data-service attribute in html anchor tags 2025-12-31 00:19:02 +00:00
dylan
147445df66 added debug logging for regex matching and replacement in now function 2025-12-31 00:03:59 +00:00
dylan
973a774442 Merge pull request 'Update image handling' (#4) from fix/image-updates into main
Reviewed-on: https://source.tube/dylan/now-updater/pulls/4
2025-12-29 19:36:17 +00:00
dylan
185014493c updated image tags 2025-12-29 19:30:31 +00:00
dylan
61bb4d4d9e updated README to include details on new image handling 2025-12-29 19:30:19 +00:00
dylan
84878d3c17 Merge pull request 'Add Steam, Bluesky, Hardcover and more' (#3) from update/new-services into main
Reviewed-on: https://source.tube/dylan/now-updater/pulls/3
2025-12-28 22:27:17 +00:00
dylan
de7d37e9ac Merge branch 'main' into update/new-services 2025-12-28 22:26:04 +00:00
dylan
2bb671df4f Update .forgejo/workflows/update.yml 2025-12-28 22:25:45 +00:00
dylan
2165ce5bd3 updated README with support for new services 2025-12-28 22:18:27 +00:00
dylan
3faa24b283 updated font size 2025-12-28 21:45:34 +00:00
dylan
83a1ea9731 updated link style 2025-12-28 21:44:26 +00:00
dylan
692259b0c3 updated icon for weblog 2025-12-28 21:41:17 +00:00
dylan
ec2117aa13 updated to remove images from weblog and minify html to avoid parsing issues 2025-12-28 21:39:54 +00:00
dylan
9e4d661c8a removed img tag! 2025-12-28 21:35:13 +00:00
dylan
4483987073 updated closing div tag with no indentation to fix breaking when no images are found 2025-12-28 21:30:37 +00:00
dylan
ad10100c02 updated regex for img and video tag removal one more time... 2025-12-28 21:22:42 +00:00
dylan
5c78a8a491 updated regex for img and video tag removal to fix broken cards (fingers crossed!) 2025-12-28 21:18:27 +00:00
dylan
6819d100d3 updated image tag handling to support multi-line tags 2025-12-28 21:14:30 +00:00
dylan
79a27ae368 added logging for latest image and video in feed handler 2025-12-28 21:10:08 +00:00
dylan
37c167fc38 updated trakt episode and movie image URLs to use poster instead of thumb 2025-12-28 20:53:09 +00:00
dylan
c712c387ae updated some.pics integration 2025-12-28 20:51:19 +00:00
dylan
88720c7269 updated mastodon service to set isActive to false 2025-12-28 20:41:01 +00:00
dylan
7ded8d55c6 removed mastodon post section from now-page-template 2025-12-28 20:40:55 +00:00
dylan
2fbf9e809b updated to just html 2025-12-28 19:37:49 +00:00
dylan
0e9efce6fb updated to show images for mastodon posts 2025-12-28 19:34:17 +00:00
dylan
5a431287d6 added video support for mastodon posts 2025-12-28 19:11:57 +00:00
dylan
85fcc462da updated mastodon post handling to extract video and image media types 2025-12-28 19:11:54 +00:00
dylan
469dddd539 updated mastodon post image extraction to check for media:content first 2025-12-28 19:08:37 +00:00
dylan
3a8683204c updated image handling to remove img tags if no image is available 2025-12-28 19:08:32 +00:00
dylan
02f122d690 updated layout to left align and show images nicely 2025-12-28 19:08:27 +00:00
dylan
3b8a1edcb4 fixed steam api by passing the right thing lol 2025-12-28 18:49:21 +00:00
dylan
518feb17fe add steam and hardcover keys 2025-12-28 18:42:53 +00:00
dylan
b549858bc0 updated layout for hardcover and steam sections to include images 2025-12-28 18:40:12 +00:00
dylan
48a5aab1c2 updated hardcover and steam apis to actually work after testing 2025-12-28 18:40:03 +00:00
dylan
7dfff74ea0 added HARDCOVER_API_KEY to environment variables in update.yml 2025-12-28 18:14:05 +00:00
dylan
936a8437b7 added hardcover activity function with API key validation 2025-12-28 18:14:00 +00:00
dylan
221e96b7c1 added HARDCOVER_API_KEY to .env.example for hardcover service 2025-12-28 18:13:32 +00:00
dylan
e41e3f51d8 added STEAM_WEBAPI_KEY to environment variables in update.yml 2025-12-28 17:59:47 +00:00
dylan
13ebc9f4e3 updated to include images 2025-12-28 17:46:16 +00:00
dylan
689079cab4 updated to include image extraction and separate image urls 2025-12-28 17:45:59 +00:00
dylan
acdb9ff8d7 updated to differentiate between generic and special feeds 2025-12-28 17:32:17 +00:00
dylan
df031eaa4a fixed an error with the error 2025-12-28 17:29:09 +00:00
dylan
c196e8f328 updated now-page-template.md with new things 2025-12-28 17:27:20 +00:00
dylan
001834fe64 added Maloja scrobble handler to feedHandlers 2025-12-28 17:23:27 +00:00
dylan
e971dbf0ba updated regex and reorganised and added headings to match services.js 2025-12-28 17:23:18 +00:00
dylan
64488f88ed added some examples 2025-12-28 17:22:14 +00:00
dylan
db25612241 updated now-page-template.md layout 2025-12-28 16:58:45 +00:00
dylan
cc64e56b77 removed biome.json and lefthook.yml configuration files 2025-12-28 16:57:57 +00:00
dylan
8f807cb47a removed local development files from .gitignore 2025-12-28 16:57:45 +00:00
dylan
385f24a28a updated feed handling to use dedicated handlers for each feed type 2025-12-28 16:56:40 +00:00
dylan
b3b8658b72 moved services from config.js into their own services.js 2025-12-28 16:52:26 +00:00
dylan
7e9be02de6 added optional STEAM_WEBAPI_KEY 2025-12-28 16:06:58 +00:00
11 changed files with 884 additions and 304 deletions

View file

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

View file

@ -24,3 +24,5 @@ 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 }}

6
.gitignore vendored
View file

@ -1,7 +1,3 @@
.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,24 +2,28 @@
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 output links, Mastodon, Last.fm, and dynamic URLs. > 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.
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, 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. 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.
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="https://your-blog.com">tktk</a> <a href="service-link">blog</a>
<a href="service-link">gitlab</a>
``` ```
**Markdown links** (before): **Markdown links** (before):
```markdown ```markdown
[tktk](https://your-blog.com) [blog](service-link)
[gitlab](service-link)
``` ```
to this: to this:
@ -27,10 +31,12 @@ 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
@ -38,8 +44,9 @@ 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), `TRAKT_SLURM` (optional, see [Trakt](#trakt) for details) 4. Add Actions secrets: `OMGLOL_KEY` (your API key)
5. Add your sources in `config.js` 5. Add optional secrets for services you want to use: `TRAKT_SLURM`, `STEAM_WEBAPI_KEY`, `HARDCOVER_API_KEY`
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.
@ -47,67 +54,214 @@ For local development, copy `.env.example` to `.env` with your credentials.
**Now page template:** **Now page template:**
```html ```html
I wrote this: [tktk](https://your-blog.com) I wrote this: <a href="https://your-blog.com">blog</a>
I watched this: [tktk](trakt.tv/users/username) I watched this: <a href="https://letterboxd.com/username">letterboxd</a>
<img data-service="letterboxd" src="letterboxd-image" style="max-width: 120px;">
I listened to: <a href="https://www.last.fm/user/username">lastfm</a>
``` ```
**config.js:** **services.js:**
```js ```js
export const linkFormat = 'markdown' export const services = [
export const items = [
{ {
id: 'blog', id: 'blog',
regex: htmlLinkRegex('your-blog.com'), isActive: true,
getLatest: async () => getJsonFeedItemTitle('https://your-blog.com/feed.json'), feedUrl: 'https://your-blog.com/feed.json',
feedType: 'json',
}, },
{ {
id: 'letterboxd', id: 'letterboxd',
regex: htmlLinkRegex('letterboxd.com/username'), isActive: true,
getLatest: async () => getLetterboxdActivity('username', true), // with image userId: 'your-username',
feedUrlTemplate: 'https://letterboxd.com/{userId}/rss/',
feedType: 'letterboxd',
showImage: true, // optional: include movie posters
}, },
{ {
id: 'trakt-movie', id: 'lastfm',
regex: htmlLinkRegex('trakt.tv/users/username'), isActive: true,
getLatest: async () => getTraktMovie('username', 'your-trakt-id', false), // text only userId: 'your-username',
feedUrlTemplate: 'https://lfm.xiffy.nl/{userId}',
feedType: 'rss',
}, },
] ]
``` ```
## Configuration ## Configuration
Edit `config.js` to define your content sources. Each item needs: ### Quick Start
- `id`: identifier for logs
- `regex`: pattern to match your link
- `getLatest`: async function that fetches and returns latest content
Set `linkFormat` to `'html'` or `'markdown'` depending on your Now page format. 1. **In your Now page**, use service names as link text:
```html
<a href="service-link">blog</a>
<a href="service-link">gitlab</a>
```
## Available Functions 2. **In services.js**, find the service and flip `isActive` to `true`:
```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
- `getJsonFeedItemTitle(feedUrl, showImage)` - JSON feeds - `getAtomFeed(url)` - Standard Atom feeds
- `getLetterboxdActivity(username, showImage)` - Letterboxd activity - `getJsonFeedItemTitle(feedUrl, showImage)` - JSON feeds with optional images
- `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)
**Notes:** ### omg.lol Services
- `showImage` (defaults to `true`) controls whether images/posters are included in the output. Set it to `false` to show text only. - `getSomePicsPost(feedUrl)` - some.pics photo posts with images extracted from CDATA
- For Last.fm, use `getRSSItemTitle('https://lfm.xiffy.nl/your-username')` (via [lfm.xiffy.nl](https://lfm.xiffy.nl))
## Trakt ### Music
- `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/crankle/history.atom?slurm=your-slurm-key` 3. The URL it shows will look something like this: `https://trakt.tv/users/username/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.
There may be a better way to get this key, but this is how I found it. ### Steam
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

View file

@ -1,36 +0,0 @@
{
"$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,60 +1,7 @@
import { import { services } from './services.js'
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'
export const items = [ // filter to only active services - edit services.js to toggle isActive or customise
{ 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,7 +1,40 @@
import 'dotenv/config' import 'dotenv/config'
import { items, linkFormat } from './config.js' import { items, linkFormat } from './config.js'
import { markdownCharEscape } from './utils.js' import {
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`
@ -32,37 +65,125 @@ export default async function now() {
let newNow = now let newNow = now
await Promise.all( await Promise.all(
items.map(async ({ id, regex, getLatest }) => { items.map(async (item) => {
try { try {
const latest = await getLatest() const handler = feedHandlers[item.feedType]
console.log(`${id}: ${latest.text}`) if (!handler) {
console.log(`${id} URL: ${latest.url}`) throw new Error(`Unknown feedType: ${item.feedType}`)
}
// Apply markdown escaping only for markdown links // build feedUrl from template if provided
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) {
newNow = newNow.replace(regex, (match, openTag, closeTag) => { // generate regex based on id and linkFormat
// Replace href in opening tag const regex =
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}"`,
) )
return `${updatedOpenTag}${displayText}${closeTag}` const result = `${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 ${id}`, e) console.warn(`⚠️ Failed to fetch ${item.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)
} }
} }

View file

@ -1,6 +0,0 @@
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,65 +1,31 @@
# What I've been up to <h1>What I've been up to</h1>
This page automatically updates with my latest activity. <p>This page <em>should</em> update automatically with my latest activity.</p>
<div style="display: flex; flex-direction: column; gap: 0.75rem;"> <div style="display: flex; flex-direction: column; gap: 0.75rem;">
<div style="background: rgba(249, 226, 175, 0.15); padding: 1rem; border: 4px solid #f9e2af; color:white;"> <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>
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-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>
</div> <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 style="background: rgba(203, 166, 247, 0.15); padding: 1rem; border: 4px solid #cba6f7; color:white;"> <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>
I posted this<br> <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 class="fa-brands fa-mastodon"></i> <a href="https://social.lol/@dylan" style="color: #cba6f7 !important;">tktk</a> <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>
</div> <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 style="background: rgba(148, 226, 213, 0.15); padding: 1rem; border: 4px solid #94e2d5; color:white;"> <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>
I shared this photo<br><a href="https://dylan.some.pics" style="color: #94e2d5 !important;">tktk</a> <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>
</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>
##### {last-updated} <h5>{last-updated}</h5>
<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> <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>
--- <hr>
<div class="badges"> <div class="badges">
<!-- People Pledge --> <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>
<a href="https://people.pledge.party"><img <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>
src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596198/people_pledge_badge_party_pink_cream_88x31_uyqfgg.png" <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>
alt="People Pledge 88x31 Badge"></a> <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>
<!-- social.lol --> <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>
<a href="https://social.lol/@dylan"><img <a href="https://privacybadger.org"><img src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596864/internetprivacy_tdutsi.gif" alt="Internet Privacy 88x31 Badge"></a>
src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596200/mastodon_ek8b0l.gif" <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>
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>
### [Back to my omg.lol page!](https://dylan.omg.lol) <h3><a href="https://dylan.omg.lol" style="font-weight: normal;">Back to my omg.lol page!</a></h3>

191
services.js Normal file
View file

@ -0,0 +1,191 @@
// 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',
},
]

434
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 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(
`(\\[).*?(\\]\\(https:\\/\\/${url.replaceAll('.', '\\.')}\\))`, `(<a[^>]*data-service=["']${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,80 +59,79 @@ export async function getRSSItemTitle(feedUrl) {
return { text: cleanTitle, url: link } return { text: cleanTitle, url: link }
} }
export async function getMastodonPost(feedUrl) { // atom feed parser - grabs the latest entry from any standard atom feed
const res = await fetch(feedUrl) export async function getAtomFeed(url) {
const res = await fetch(url)
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 entry = dom.window.document.querySelector('entry')
// get all items from the rss feed if (!entry) {
const items = dom.window.document.querySelectorAll('item') throw new Error('No recent items found')
// 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's a reply (starts with @ mention)
if (description.trim().startsWith('@')) {
continue
}
const link = item.querySelector('link')?.textContent.trim()
// use description as the text, stripping HTML
let cleanText = sanitizeHtml(description, {
allowedTags: [],
allowedAttributes: {},
})?.trim()
// replace URLs with link emoji
cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim()
return { text: cleanText, url: link }
} }
const title = entry.querySelector('title').textContent
// fallback to first item if no non-reply found const link = entry.querySelector('link').getAttribute('href')
const firstItem = items[0] const cleanTitle = sanitizeHtml(title, {
const description = firstItem.querySelector('description')?.textContent || ''
const link = firstItem.querySelector('link')?.textContent.trim()
let cleanText = sanitizeHtml(description, {
allowedTags: [], allowedTags: [],
allowedAttributes: {}, allowedAttributes: {},
})?.trim() })?.trim()
// replace URLs with link emoji return { text: cleanTitle, url: link }
cleanText = cleanText.replace(/https?:\/\/\S+/g, '🔗').trim()
return { text: cleanText, url: link }
} }
// json feed parser - grabs the latest item title
// optionally includes images separately
export async function getJsonFeedItemTitle(feedUrl, showImage = true) { export async function getJsonFeedItemTitle(feedUrl, showImage = true) {
const res = await fetch(feedUrl) const res = await fetch(feedUrl)
const data = await res.json() const data = await res.json()
const post = data.items[0] const post = data.items[0]
const { title, image, url } = post const { title, image, url } = post
const text = !showImage return { text: title, url, image: showImage ? image : null }
? title
: image
? `${title} ![${title}](${image})`
: title
return { text, url }
} }
export async function getJsonFeedItemContent(feedUrl, showImage = true) { // ========================================================================
// 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 res = await fetch(feedUrl)
const data = await res.json() const data = await res.json()
const post = data.items[0] if (!data.payload.listens || data.payload.listens.length === 0) {
const { content_html, image, url } = post throw new Error('No recent listens found')
const title = sanitizeHtml(content_html, { }
allowedTags: [], const listen = data.payload.listens[0]
allowedAttributes: {}, const { track_name, artist_name } = listen.track_metadata
})?.trim() const text = `${track_name}<br />by ${artist_name}`
const text = !showImage const url = `https://listenbrainz.org/user/${userId}/`
? title
: image
? `${title} ![${title}](${image})`
: title
return { text, url } 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) { export async function getMalojaScrobble(malojaUrl) {
const res = await fetch(`${malojaUrl}/apis/mlj_1/scrobbles?perpage=1`) const res = await fetch(`${malojaUrl}/apis/mlj_1/scrobbles?perpage=1`)
const data = await res.json() const data = await res.json()
@ -134,70 +143,309 @@ export async function getMalojaScrobble(malojaUrl) {
return `${title}<br />by ${artists.join(', ')}` return `${title}<br />by ${artists.join(', ')}`
} }
export async function getLetterboxdActivity(username, showImage = true) { // ========================================================================
const res = await fetch(`https://letterboxd.com/${username}/rss/`) // 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(/<img[^>]+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(/<img[^>]+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 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 text = title let image = null
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="([^"]+)"/)
const image = imgMatch ? imgMatch[1] : null image = imgMatch ? imgMatch[1] : null
text = image ? `${title} ![${title}](${image})` : title
} }
return { text, url: link } return { text: title, url: link, image }
} }
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)
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 text = !showImage const image = showImage
? title ? `https://widgets.trakt.tv/users/${traktId}/watched/poster@2x.jpg?type=episode&image_only=1`
: `${title} ![${title}](https://widgets.trakt.tv/users/${id}/watched/thumb@2x.jpg?type=episode&image_only=1)` : null
return { text, url: link } return { text: title, url: link, image }
} }
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)
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 text = !showImage const image = showImage
? title ? `https://widgets.trakt.tv/users/${traktId}/watched/poster@2x.jpg?type=movie&image_only=1`
: `${title} ![${title}](https://widgets.trakt.tv/users/${id}/watched/thumb@2x.jpg?type=movie&image_only=1)` : null
return { text, url: link } return { text: title, url: link, image }
} }
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}`, // 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')
} }
}
export async function getReadingBooklogr(booklogrUrl, booklogrUser) { const query = `
const res = await fetch(`${booklogrUrl}/v1/profiles/${booklogrUser}`) 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() const data = await res.json()
const books = data.books.filter( if (!data.data?.user_books || data.data.user_books.length === 0) {
({ reading_status }) => reading_status === 'Currently reading', throw new Error('Nothing!')
) }
return books.map(({ title }) => title).join(',<br />')
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 }
} }