Compare commits
No commits in common. "main" and "v0.0.1" have entirely different histories.
11 changed files with 293 additions and 873 deletions
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
OMGLOL_KEY=
|
||||
OMGLOL_USERNAME=melanie
|
||||
TRAKT_SLURM=
|
||||
|
|
@ -24,5 +24,3 @@ jobs:
|
|||
OMGLOL_KEY: ${{ secrets.OMGLOL_KEY }}
|
||||
OMGLOL_USERNAME: ${{ vars.OMGLOL_USERNAME }}
|
||||
TRAKT_SLURM: ${{ secrets.TRAKT_SLURM }}
|
||||
STEAM_WEBAPI_KEY: ${{ secrets.STEAM_WEBAPI_KEY }}
|
||||
HARDCOVER_API_KEY: ${{ secrets.HARDCOVER_API_KEY }}
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
.DS_Store
|
||||
node_modules/*
|
||||
.env
|
||||
# Local development files
|
||||
fetch-now.js
|
||||
now-local.html
|
||||
preview-now.sh
|
||||
230
README.md
230
README.md
|
|
@ -2,28 +2,24 @@
|
|||
|
||||
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)
|
||||
|
||||
## 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.
|
||||
|
||||
Images are matched using a `data-service` attribute on the img tag, which allows them to be updated repeatedly even after the initial replacement.
|
||||
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.
|
||||
|
||||
### Example
|
||||
Your Now page links will be automatically updated from this:
|
||||
|
||||
**HTML links** (before):
|
||||
```html
|
||||
<a href="service-link">blog</a>
|
||||
<a href="service-link">gitlab</a>
|
||||
<a href="https://your-blog.com">tktk</a>
|
||||
```
|
||||
**Markdown links** (before):
|
||||
```markdown
|
||||
[blog](service-link)
|
||||
[gitlab](service-link)
|
||||
[tktk](https://your-blog.com)
|
||||
```
|
||||
|
||||
to this:
|
||||
|
|
@ -31,12 +27,10 @@ to this:
|
|||
**HTML links** (after):
|
||||
```html
|
||||
<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
|
||||
[My Latest Post](https://your-blog.com/2025/12/my-latest-post)
|
||||
[Pushed to repository](https://gitlab.com/username/project)
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
|
@ -44,9 +38,8 @@ to this:
|
|||
1. Fork this repo
|
||||
2. Enable Actions in repo settings
|
||||
3. Add Actions variables: `OMGLOL_USERNAME` (your omg.lol username)
|
||||
4. Add Actions secrets: `OMGLOL_KEY` (your API key)
|
||||
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`
|
||||
4. Add Actions secrets: `OMGLOL_KEY` (your API key), `TRAKT_SLURM` (optional, see [Trakt](#trakt) for details)
|
||||
5. Add your sources in `config.js`
|
||||
|
||||
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:**
|
||||
```html
|
||||
I wrote this: <a href="https://your-blog.com">blog</a>
|
||||
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>
|
||||
I wrote this: [tktk](https://your-blog.com)
|
||||
I watched this: [tktk](trakt.tv/users/username)
|
||||
```
|
||||
|
||||
**services.js:**
|
||||
**config.js:**
|
||||
```js
|
||||
export const services = [
|
||||
export const linkFormat = 'markdown'
|
||||
|
||||
export const items = [
|
||||
{
|
||||
id: 'blog',
|
||||
isActive: true,
|
||||
feedUrl: 'https://your-blog.com/feed.json',
|
||||
feedType: 'json',
|
||||
regex: htmlLinkRegex('your-blog.com'),
|
||||
getLatest: async () => getJsonFeedItemTitle('https://your-blog.com/feed.json'),
|
||||
},
|
||||
{
|
||||
id: 'letterboxd',
|
||||
isActive: true,
|
||||
userId: 'your-username',
|
||||
feedUrlTemplate: 'https://letterboxd.com/{userId}/rss/',
|
||||
feedType: 'letterboxd',
|
||||
showImage: true, // optional: include movie posters
|
||||
regex: htmlLinkRegex('letterboxd.com/username'),
|
||||
getLatest: async () => getLetterboxdActivity('username', true), // with image
|
||||
},
|
||||
{
|
||||
id: 'lastfm',
|
||||
isActive: true,
|
||||
userId: 'your-username',
|
||||
feedUrlTemplate: 'https://lfm.xiffy.nl/{userId}',
|
||||
feedType: 'rss',
|
||||
id: 'trakt-movie',
|
||||
regex: htmlLinkRegex('trakt.tv/users/username'),
|
||||
getLatest: async () => getTraktMovie('username', 'your-trakt-id', false), // text only
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
## 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:
|
||||
```html
|
||||
<a href="service-link">blog</a>
|
||||
<a href="service-link">gitlab</a>
|
||||
```
|
||||
Set `linkFormat` to `'html'` or `'markdown'` depending on your Now page format.
|
||||
|
||||
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',
|
||||
}
|
||||
```
|
||||
## Available Functions
|
||||
|
||||
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
|
||||
- `getAtomFeed(url)` - Standard Atom feeds
|
||||
- `getJsonFeedItemTitle(feedUrl, showImage)` - JSON feeds with optional images
|
||||
- `getJsonFeedItemTitle(feedUrl, showImage)` - JSON feeds
|
||||
- `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
|
||||
- `getSomePicsPost(feedUrl)` - some.pics photo posts with images extracted from CDATA
|
||||
**Notes:**
|
||||
- `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
|
||||
- `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
|
||||
## Trakt
|
||||
|
||||
To get your Trakt slurm key, follow these steps:
|
||||
|
||||
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.
|
||||
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`.
|
||||
5. Add it to your `.env` file for local testing.
|
||||
|
||||
### 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
|
||||
There may be a better way to get this key, but this is how I found it.
|
||||
|
||||
## Credits
|
||||
|
||||
|
|
|
|||
36
biome.json
Normal file
36
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
config.js
61
config.js
|
|
@ -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'
|
||||
|
||||
// filter to only active services - edit services.js to toggle isActive or customise
|
||||
export const items = services.filter((s) => s.isActive)
|
||||
export const items = [
|
||||
{
|
||||
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
141
index.js
|
|
@ -1,40 +1,7 @@
|
|||
import 'dotenv/config'
|
||||
|
||||
import { items, linkFormat } from './config.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,
|
||||
}
|
||||
import { markdownCharEscape } from './utils.js'
|
||||
|
||||
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
|
||||
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
items.map(async ({ id, regex, getLatest }) => {
|
||||
try {
|
||||
const handler = feedHandlers[item.feedType]
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown feedType: ${item.feedType}`)
|
||||
}
|
||||
const latest = await getLatest()
|
||||
console.log(`${id}: ${latest.text}`)
|
||||
console.log(`${id} URL: ${latest.url}`)
|
||||
|
||||
// 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
|
||||
// Apply markdown escaping only for markdown links
|
||||
const displayText =
|
||||
linkFormat === 'html'
|
||||
? latest.text
|
||||
: markdownCharEscape(latest.text)
|
||||
|
||||
if (latest && latest.text && latest.url) {
|
||||
// generate regex based on id and linkFormat
|
||||
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
|
||||
newNow = newNow.replace(regex, (match, openTag, closeTag) => {
|
||||
// Replace href in opening tag
|
||||
const updatedOpenTag = openTag.replace(
|
||||
/href=["'][^"']*["']/,
|
||||
`href="${latest.url}"`,
|
||||
)
|
||||
const result = `${updatedOpenTag}${displayText}${closeTag}`
|
||||
console.log(`New replacement: ${result}`)
|
||||
return result
|
||||
return `${updatedOpenTag}${displayText}${closeTag}`
|
||||
})
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
console.log('\nNow page has no changes')
|
||||
} else {
|
||||
console.log('\nChanges detected! Updating Now page...')
|
||||
await setNow(newNow)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
lefthook.yml
Normal file
6
lefthook.yml
Normal 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
|
||||
|
|
@ -1,31 +1,65 @@
|
|||
<h1>What I've been up to</h1>
|
||||
<p>This page <em>should</em> update automatically with my latest activity.</p>
|
||||
# What I've been up to
|
||||
This page automatically updates with my latest activity.
|
||||
|
||||
<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(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 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(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(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>
|
||||
<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 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(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(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 style="background: rgba(249, 226, 175, 0.15); padding: 1rem; border: 4px solid #f9e2af; color:white;">
|
||||
I wrote this<br><a href="https://dylan.weblog.lol" style="color: #f9e2af !important;">tktk</a>
|
||||
</div>
|
||||
<div style="background: rgba(203, 166, 247, 0.15); padding: 1rem; border: 4px solid #cba6f7; color:white;">
|
||||
I posted this<br>
|
||||
<i class="fa-brands fa-mastodon"></i> <a href="https://social.lol/@dylan" style="color: #cba6f7 !important;">tktk</a>
|
||||
</div>
|
||||
<div style="background: rgba(148, 226, 213, 0.15); padding: 1rem; border: 4px solid #94e2d5; color:white;">
|
||||
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>
|
||||
|
||||
<h5>{last-updated}</h5>
|
||||
<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>
|
||||
##### {last-updated}
|
||||
<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">
|
||||
<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://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://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>
|
||||
<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>
|
||||
<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://privacybadger.org"><img src="https://res.cloudinary.com/dkubc5dik/image/upload/v1766596864/internetprivacy_tdutsi.gif" alt="Internet Privacy 88x31 Badge"></a>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<!-- social.lol -->
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<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)
|
||||
191
services.js
191
services.js
|
|
@ -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
412
utils.js
|
|
@ -2,26 +2,21 @@ import 'dotenv/config'
|
|||
import { JSDOM } from 'jsdom'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
|
||||
// ========================================================================
|
||||
// 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) {
|
||||
export function markdownLinkRegex(url) {
|
||||
return new RegExp(
|
||||
`(<a[^>]*data-service=["']${serviceName}["'][^>]*>)([^<]*)(<\\/a>)`,
|
||||
`(\\[).*?(\\]\\(https:\\/\\/${url.replaceAll('.', '\\.')}\\))`,
|
||||
'i',
|
||||
)
|
||||
}
|
||||
|
||||
// regex to match service name within a markdown link
|
||||
// used when linkFormat is 'markdown' in config.js
|
||||
export function markdownServiceLinkRegex(serviceName) {
|
||||
return new RegExp(`(\\[)${serviceName}(\\]\\([^)]*\\))`, 'i')
|
||||
export function htmlLinkRegex(url) {
|
||||
const escapedUrl = url.replaceAll('.', '\\.')
|
||||
return new RegExp(
|
||||
`(<a[^>]+href=["']https?:\\/\\/${escapedUrl}[^"']*["'][^>]*>).*?(<\\/a>)`,
|
||||
'i',
|
||||
)
|
||||
}
|
||||
|
||||
// escapes special markdown characters so they display literally
|
||||
export function markdownCharEscape(text) {
|
||||
return text
|
||||
.replaceAll('`', '\\`')
|
||||
|
|
@ -40,11 +35,6 @@ export function markdownCharEscape(text) {
|
|||
.replaceAll('!', '\\!')
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// generic feed parsers
|
||||
// ========================================================================
|
||||
|
||||
// rss feed parser - grabs the latest item from any standard rss feed
|
||||
export async function getRSSItemTitle(feedUrl) {
|
||||
const res = await fetch(feedUrl)
|
||||
const data = await res.text()
|
||||
|
|
@ -59,100 +49,7 @@ export async function getRSSItemTitle(feedUrl) {
|
|||
return { text: cleanTitle, url: link }
|
||||
}
|
||||
|
||||
// atom feed parser - grabs the latest entry from any standard atom feed
|
||||
export async function getAtomFeed(url) {
|
||||
const res = await fetch(url)
|
||||
const data = await res.text()
|
||||
const dom = new JSDOM(data, { contentType: 'text/xml' })
|
||||
const entry = dom.window.document.querySelector('entry')
|
||||
if (!entry) {
|
||||
throw new Error('No recent items found')
|
||||
}
|
||||
const title = entry.querySelector('title').textContent
|
||||
const link = entry.querySelector('link').getAttribute('href')
|
||||
const cleanTitle = sanitizeHtml(title, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
})?.trim()
|
||||
return { text: cleanTitle, url: link }
|
||||
}
|
||||
|
||||
// json feed parser - grabs the latest item title
|
||||
// optionally includes images 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
|
||||
export async function getMastodonPost(feedUrl) {
|
||||
const res = await fetch(feedUrl)
|
||||
const data = await res.text()
|
||||
const dom = new JSDOM(data, { contentType: 'text/xml' })
|
||||
|
|
@ -164,288 +61,143 @@ export async function getMastodonPost(config) {
|
|||
for (const item of items) {
|
||||
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('@')) {
|
||||
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
|
||||
// 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,
|
||||
}
|
||||
return { text: cleanText, url: link }
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
return { text: cleanText, url: link }
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 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
|
||||
export async function getJsonFeedItemTitle(feedUrl, showImage = true) {
|
||||
const res = await fetch(feedUrl)
|
||||
const data = await res.json()
|
||||
const post = data.items[0]
|
||||
const { title, image, url } = post
|
||||
const text = !showImage
|
||||
? title
|
||||
: image
|
||||
? `${title} `
|
||||
: title
|
||||
return { text, url }
|
||||
}
|
||||
|
||||
export async function getJsonFeedItemContent(feedUrl, showImage = true) {
|
||||
const res = await fetch(feedUrl)
|
||||
const data = await res.json()
|
||||
const post = data.items[0]
|
||||
const { content_html, image, url } = post
|
||||
const title = sanitizeHtml(content_html, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
})?.trim()
|
||||
const text = !showImage
|
||||
? title
|
||||
: image
|
||||
? `${title} `
|
||||
: title
|
||||
return { text, url }
|
||||
}
|
||||
|
||||
export async function getMalojaScrobble(malojaUrl) {
|
||||
const res = await fetch(`${malojaUrl}/apis/mlj_1/scrobbles?perpage=1`)
|
||||
const data = await res.json()
|
||||
const scrobble = data.list[0]
|
||||
const {
|
||||
title,
|
||||
album: { artists },
|
||||
} = scrobble.track
|
||||
return `${title}<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 dom = new JSDOM(data, { contentType: 'text/xml' })
|
||||
const item = dom.window.document.querySelector('item')
|
||||
const title = item.querySelector('title').textContent
|
||||
const link = item.querySelector('link').textContent.trim()
|
||||
|
||||
let image = null
|
||||
let text = title
|
||||
if (showImage) {
|
||||
// Parse CDATA content as HTML to extract image
|
||||
const description = item.querySelector('description').textContent
|
||||
const imgMatch = description.match(/<img[^>]+src="([^"]+)"/)
|
||||
image = imgMatch ? imgMatch[1] : null
|
||||
const image = imgMatch ? imgMatch[1] : null
|
||||
text = image ? `${title} ` : title
|
||||
}
|
||||
|
||||
return { text: title, url: link, image }
|
||||
return { text, url: link }
|
||||
}
|
||||
|
||||
// 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
|
||||
export async function getTraktEpisode(username, id, showImage = true) {
|
||||
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 dom = new JSDOM(data)
|
||||
const entry = dom.window.document.querySelector('entry')
|
||||
const title = entry.querySelector('title').textContent
|
||||
const link = entry.querySelector('link').getAttribute('href')
|
||||
const image = showImage
|
||||
? `https://widgets.trakt.tv/users/${traktId}/watched/poster@2x.jpg?type=episode&image_only=1`
|
||||
: null
|
||||
return { text: title, url: link, image }
|
||||
const text = !showImage
|
||||
? title
|
||||
: `${title} `
|
||||
return { text, url: link }
|
||||
}
|
||||
|
||||
// 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
|
||||
export async function getTraktMovie(username, id, showImage = true) {
|
||||
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 dom = new JSDOM(data)
|
||||
const entry = dom.window.document.querySelector('entry')
|
||||
const title = entry.querySelector('title').textContent
|
||||
const link = entry.querySelector('link').getAttribute('href')
|
||||
const image = showImage
|
||||
? `https://widgets.trakt.tv/users/${traktId}/watched/poster@2x.jpg?type=movie&image_only=1`
|
||||
: null
|
||||
return { text: title, url: link, image }
|
||||
const text = !showImage
|
||||
? title
|
||||
: `${title} `
|
||||
return { text, url: link }
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// books
|
||||
// ========================================================================
|
||||
|
||||
// hardcover graphql api - grabs your currently reading book
|
||||
// requires HARDCOVER_API_KEY see readme for more info
|
||||
export async function getHardcoverActivity(config) {
|
||||
const { userId, feedUrl } = config
|
||||
const apiKey = process.env.HARDCOVER_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error('HARDCOVER_API_KEY environment variable not set')
|
||||
}
|
||||
|
||||
const query = `
|
||||
query {
|
||||
user_books(
|
||||
where: {user_id: {_eq: ${userId}}, status_id: {_eq: 2}}
|
||||
) {
|
||||
book {
|
||||
title
|
||||
id
|
||||
image {
|
||||
url
|
||||
}
|
||||
contributions {
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const res = await fetch(feedUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
'User-Agent': 'now-updater/1.0 (omg.lol now page updater)',
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!data.data?.user_books || data.data.user_books.length === 0) {
|
||||
throw new Error('Nothing!')
|
||||
}
|
||||
|
||||
const userBook = data.data.user_books[0]
|
||||
const book = userBook.book
|
||||
const author = book.contributions?.[0]?.author?.name || 'Unknown Author'
|
||||
export async function getTraktEpisodeAndMovie(username, id, showImage = true) {
|
||||
const episode = await getTraktEpisode(username, id, showImage)
|
||||
const movie = await getTraktMovie(username, id, showImage)
|
||||
return {
|
||||
text: `${book.title} by ${author}`,
|
||||
url: `https://hardcover.app/books/${book.id}`,
|
||||
image: book.image?.url || null,
|
||||
text: `${episode.text} <br>and<br> ${movie.text}`,
|
||||
url: `https://trakt.tv/users/${username}`,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 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`,
|
||||
export async function getReadingBooklogr(booklogrUrl, booklogrUser) {
|
||||
const res = await fetch(`${booklogrUrl}/v1/profiles/${booklogrUser}`)
|
||||
const data = await res.json()
|
||||
const books = data.books.filter(
|
||||
({ reading_status }) => reading_status === 'Currently reading',
|
||||
)
|
||||
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 }
|
||||
return books.map(({ title }) => title).join(',<br />')
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue