fresh readme
This commit is contained in:
parent
f6558efa1f
commit
6e48b5516d
1 changed files with 234 additions and 28 deletions
262
README.md
262
README.md
|
|
@ -1,54 +1,260 @@
|
||||||
# nostr-calendar
|
# nostr-calendar
|
||||||
|
|
||||||
Fetch events from an ICS + RSS feed and publish them as NIP-52 calendar
|
Fetch events from an ICS + RSS feed and publish them as [NIP-52](https://github.com/nostr-protocol/nips/blob/master/52.md)
|
||||||
events (kind 31923) to Nostr relays, requesting inclusion in a kind 31924
|
calendar events (kind `31923`) to Nostr relays, requesting inclusion in a
|
||||||
calendar.
|
kind `31924` calendar.
|
||||||
|
|
||||||
|
Built for [do.basspistol.org](https://do.basspistol.org) but configurable
|
||||||
|
for any ICS + RSS pair.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Every run:
|
||||||
|
|
||||||
|
1. Fetches the ICS feed (complete event list with UTC start/end times).
|
||||||
|
2. Fetches the RSS feed (the "announcement stream" — only the most recent
|
||||||
|
entries, with images and tags).
|
||||||
|
3. Merges them by event URL. The ICS is the source of truth for time,
|
||||||
|
location, UID and tags; the RSS contributes the image and, if the ICS
|
||||||
|
lacks categories, the tags.
|
||||||
|
4. Publishes each event as a kind `31923` Nostr event, referencing the
|
||||||
|
calendar (kind `31924`) via an `a` tag.
|
||||||
|
5. Skips events that haven't changed since the last run, using a content
|
||||||
|
hash stored in a local SQLite database.
|
||||||
|
|
||||||
|
Events are **addressable** (`d` tag = ICS UID), so republishing replaces
|
||||||
|
the previous version on the relay rather than creating duplicates.
|
||||||
|
|
||||||
|
### `rss_only` mode
|
||||||
|
|
||||||
|
When `rss_only = true` (recommended), only events that appear in the RSS
|
||||||
|
feed are published. The RSS is treated as the "announcement stream":
|
||||||
|
newly-created events appear there, and recurring events reappear once the
|
||||||
|
current occurrence ends. This avoids back-filling the Nostr calendar with
|
||||||
|
every event in the ICS, including old ones that may be stale or cancelled.
|
||||||
|
|
||||||
|
With `rss_only = false`, every event in the ICS is published. Events
|
||||||
|
without an RSS match simply have no image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux (tested on Raspberry Pi OS)
|
||||||
|
- Python 3.11+ (3.12 or 3.13 recommended — see the note on 3.14 below)
|
||||||
|
- Outbound internet access to the feed URLs and relays
|
||||||
|
|
||||||
|
### Python 3.14 note
|
||||||
|
|
||||||
|
`coincurve` (a `pynostr` dependency) has no prebuilt wheel for Python 3.14
|
||||||
|
at the time of writing, so it must build from source. The installer pins
|
||||||
|
`coincurve==20.0.0` and constrains its build backend to
|
||||||
|
`scikit-build-core<0.10` to work around two upstream build bugs. This
|
||||||
|
works but is slow. **If you can, install Python 3.12 or 3.13** — prebuilt
|
||||||
|
wheels exist and installation is much faster.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Clone this repository to `/opt/nostr-calendar`:
|
Clone to `/opt/nostr-calendar`:
|
||||||
|
|
||||||
sudo git clone <repo-url> /opt/nostr-calendar
|
```bash
|
||||||
sudo chown -R "$USER:$USER" /opt/nostr-calendar
|
sudo git clone <repo-url> /opt/nostr-calendar
|
||||||
cd /opt/nostr-calendar
|
sudo chown -R "$USER:$USER" /opt/nostr-calendar
|
||||||
./install.sh
|
cd /opt/nostr-calendar
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
The installer creates a virtualenv, installs dependencies, and copies
|
The installer:
|
||||||
`config.toml.example` to `config.toml`.
|
|
||||||
|
- creates a virtualenv in `venv/`
|
||||||
|
- installs dependencies with the build constraints for Python 3.14
|
||||||
|
- copies `config.toml.example` to `config.toml` (if it doesn't exist)
|
||||||
|
- locks `config.toml` to mode `600`
|
||||||
|
- prints next steps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
Edit `config.toml` and set at minimum:
|
Edit `config.toml`:
|
||||||
|
|
||||||
- `secret_key` — your Nostr secret key (`nsec1...` or hex)
|
```toml
|
||||||
- `relays` — relays to publish to
|
secret_key = "nsec1..." # or 64-char hex
|
||||||
- `calendar.d`, `calendar.title`, `calendar.description` — your calendar
|
|
||||||
|
|
||||||
Then lock down the file:
|
relays = [
|
||||||
|
"wss://basspistol.org",
|
||||||
|
"wss://nos.lol",
|
||||||
|
]
|
||||||
|
|
||||||
chmod 600 config.toml
|
ics_url = "https://do.basspistol.org/feed/ics?show_recurrent=true"
|
||||||
|
rss_url = "https://do.basspistol.org/feed/rss?show_recurrent=true"
|
||||||
|
|
||||||
|
rss_only = true # only publish events advertised in RSS
|
||||||
|
|
||||||
|
[calendar]
|
||||||
|
d = "basspistol-do"
|
||||||
|
title = "Basspistol Do 🗓️"
|
||||||
|
description = "Underground events promoted by the people, for the people."
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.toml` contains your secret key and is **gitignored**. Never commit it.
|
||||||
|
`config.toml.example` is the committed template.
|
||||||
|
|
||||||
|
### Calendar (kind 31924)
|
||||||
|
|
||||||
|
The `[calendar]` block defines the kind `31924` event that your published
|
||||||
|
events request inclusion in, via an `a` tag of the form:
|
||||||
|
|
||||||
|
```
|
||||||
|
31924:<your-pubkey>:<calendar.d>
|
||||||
|
```
|
||||||
|
|
||||||
|
You don't need to publish the calendar separately first — the script
|
||||||
|
publishes it on every run (idempotent, since it's addressable via `d`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
/opt/nostr-calendar/venv/bin/python /opt/nostr-calendar/publish.py
|
Always dry-run first. It fetches and parses the feeds, logs what would be
|
||||||
|
published (including images and tags), and touches neither the database
|
||||||
|
nor the relays:
|
||||||
|
|
||||||
Check the output and `publish.log`. On the first run, all events are
|
```bash
|
||||||
published; subsequent runs only publish new or changed events (dedupe
|
/opt/nostr-calendar/venv/bin/python /opt/nostr-calendar/publish.py --dry-run
|
||||||
is tracked in `events.db`).
|
```
|
||||||
|
|
||||||
|
Sample output:
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO DRY RUN 17943: title='Blokes fantasma 33 anys resistint' image='https://...jpg' tags=['gràcia', 'concert', ...] start=1791021600 end=1791079200
|
||||||
|
...
|
||||||
|
INFO DRY RUN complete. Would publish 104, skipped_no_rss=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run for real:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/nostr-calendar/venv/bin/python /opt/nostr-calendar/publish.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs go to both stdout and `publish.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Schedule with cron
|
## Schedule with cron
|
||||||
|
|
||||||
crontab -e
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
Add:
|
Add:
|
||||||
|
|
||||||
*/30 * * * * /opt/nostr-calendar/run.sh >> /opt/nostr-calendar/cron.log 2>&1
|
```
|
||||||
|
*/30 * * * * /opt/nostr-calendar/run.sh >> /opt/nostr-calendar/cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
This runs every 30 minutes. Adjust the `*/30` to taste.
|
`run.sh` resolves its own directory, `cd`s into it, and invokes the
|
||||||
|
virtualenv Python — so it's safe to call from cron regardless of the
|
||||||
|
starting working directory.
|
||||||
|
|
||||||
## Notes
|
Adjust `*/30` to taste. Every 15–30 minutes is plenty; the RSS feed is
|
||||||
|
truncated server-side, so polling more often doesn't recover older events.
|
||||||
|
|
||||||
- `config.toml` contains your secret key and is gitignored. Never commit it.
|
---
|
||||||
- The virtualenv is not relocatable — if you move the directory after
|
|
||||||
installing, recreate the venv by re-running `./install.sh`.
|
## Files
|
||||||
- The only place the absolute install path appears is the crontab entry.
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `publish.py` | Main script. `--dry-run` for a no-op test. |
|
||||||
|
| `run.sh` | Cron wrapper. Resolves its own directory. |
|
||||||
|
| `install.sh` | Creates venv, installs deps, seeds config. |
|
||||||
|
| `requirements.txt` | Python dependencies. |
|
||||||
|
| `config.toml.example` | Configuration template (committed). |
|
||||||
|
| `config.toml` | Your configuration (gitignored, mode 600). |
|
||||||
|
| `events.db` | SQLite dedupe table (gitignored). |
|
||||||
|
| `publish.log` | Log file (gitignored). |
|
||||||
|
| `cron.log` | Cron output (gitignored). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dedupe behavior
|
||||||
|
|
||||||
|
`events.db` stores, per event: the `d` tag, a content hash, the published
|
||||||
|
event id, and a timestamp. On each run:
|
||||||
|
|
||||||
|
- If the event is absent → publish.
|
||||||
|
- If the `d` tag exists but the content hash differs → republish (addressable
|
||||||
|
event replaces the previous version on the relay).
|
||||||
|
- If both match → skip.
|
||||||
|
|
||||||
|
The content hash covers title, start, end, location, URL, image, and tags.
|
||||||
|
|
||||||
|
To force a full republish, delete `events.db`. Relays will replace the
|
||||||
|
existing events; nothing is duplicated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**`ModuleNotFoundError: No module named 'feedparser'`**
|
||||||
|
You're running the script with system Python. Use the virtualenv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/nostr-calendar/venv/bin/python /opt/nostr-calendar/publish.py
|
||||||
|
```
|
||||||
|
|
||||||
|
or activate it first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/nostr-calendar/venv/bin/activate
|
||||||
|
./publish.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**`coincurve` fails to build**
|
||||||
|
See the Python 3.14 note above. The installer handles it, but if you're
|
||||||
|
installing manually you need `PIP_CONSTRAINT=build-constraints.txt` with
|
||||||
|
`scikit-build-core<0.10` inside it.
|
||||||
|
|
||||||
|
**Only some events have images**
|
||||||
|
Expected. Images come from the RSS feed, which only carries the most
|
||||||
|
recent entries. With `rss_only = true`, only RSS-advertised events are
|
||||||
|
published, and all of them get images.
|
||||||
|
|
||||||
|
**Event times look wrong**
|
||||||
|
The ICS feed uses UTC (`DTSTART:...Z`) and is authoritative. The RSS
|
||||||
|
renders times in the feed server's local timezone — ignore it.
|
||||||
|
|
||||||
|
**Relay doesn't accept writes**
|
||||||
|
Check `publish.log` for WebSocket errors. Some relays require paid access
|
||||||
|
or reject certain kinds; try adding a third relay as a fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright (C) 2026 <copyright holder>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue