initial commit
This commit is contained in:
commit
ababe6ae36
8 changed files with 542 additions and 0 deletions
8
.gitignore.txt
Normal file
8
.gitignore.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
events.db
|
||||
publish.log
|
||||
cron.log
|
||||
build-constraints.txt
|
||||
config.toml
|
||||
54
README.md
Normal file
54
README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# nostr-calendar
|
||||
|
||||
Fetch events from an ICS + RSS feed and publish them as NIP-52 calendar
|
||||
events (kind 31923) to Nostr relays, requesting inclusion in a kind 31924
|
||||
calendar.
|
||||
|
||||
## Install
|
||||
|
||||
Clone this repository to `/opt/nostr-calendar`:
|
||||
|
||||
sudo git clone <repo-url> /opt/nostr-calendar
|
||||
sudo chown -R "$USER:$USER" /opt/nostr-calendar
|
||||
cd /opt/nostr-calendar
|
||||
./install.sh
|
||||
|
||||
The installer creates a virtualenv, installs dependencies, and copies
|
||||
`config.toml.example` to `config.toml`.
|
||||
|
||||
## Configure
|
||||
|
||||
Edit `config.toml` and set at minimum:
|
||||
|
||||
- `secret_key` — your Nostr secret key (`nsec1...` or hex)
|
||||
- `relays` — relays to publish to
|
||||
- `calendar.d`, `calendar.title`, `calendar.description` — your calendar
|
||||
|
||||
Then lock down the file:
|
||||
|
||||
chmod 600 config.toml
|
||||
|
||||
## Test
|
||||
|
||||
/opt/nostr-calendar/venv/bin/python /opt/nostr-calendar/publish.py
|
||||
|
||||
Check the output and `publish.log`. On the first run, all events are
|
||||
published; subsequent runs only publish new or changed events (dedupe
|
||||
is tracked in `events.db`).
|
||||
|
||||
## Schedule with cron
|
||||
|
||||
crontab -e
|
||||
|
||||
Add:
|
||||
|
||||
*/30 * * * * /opt/nostr-calendar/run.sh >> /opt/nostr-calendar/cron.log 2>&1
|
||||
|
||||
This runs every 30 minutes. Adjust the `*/30` to taste.
|
||||
|
||||
## Notes
|
||||
|
||||
- `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`.
|
||||
- The only place the absolute install path appears is the crontab entry.
|
||||
1
build-constraints.txt
Normal file
1
build-constraints.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
scikit-build-core<0.10
|
||||
39
config.toml.example
Normal file
39
config.toml.example
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Copy this file to config.toml and fill in your secret key:
|
||||
# cp config.toml.example config.toml
|
||||
# chmod 600 config.toml
|
||||
#
|
||||
# config.toml is gitignored and must never be committed.
|
||||
|
||||
# Nostr secret key (nsec1... or 64-char hex).
|
||||
secret_key = "nsec1_REPLACE_ME"
|
||||
|
||||
# Relays to publish to. Always prefixed with wss://
|
||||
relays = [
|
||||
"wss://basspistol.org",
|
||||
"wss://nos.lol",
|
||||
]
|
||||
|
||||
# Feed sources
|
||||
ics_url = "https://do.basspistol.org/feed/ics?show_recurrent=true"
|
||||
rss_url = "https://do.basspistol.org/feed/rss?show_recurrent=true"
|
||||
|
||||
# Only publish events that also appear in the RSS feed.
|
||||
# This tracks the "announcement stream" rather than the full ICS backlog.
|
||||
rss_only = true
|
||||
|
||||
# Local paths (relative to script directory)
|
||||
db_path = "events.db"
|
||||
log_path = "publish.log"
|
||||
|
||||
# Default end time offset when DTEND is missing (seconds)
|
||||
default_duration = 3600
|
||||
|
||||
# Content template. {title}, {location}, {url} are substituted.
|
||||
content_template = """{title}
|
||||
📍 {location}
|
||||
🔗 {url}"""
|
||||
|
||||
[calendar]
|
||||
d = "basspistol-do"
|
||||
title = "Basspistol Do 🗓️"
|
||||
description = "Underground events promoted by the people, for the people. Ad-free and decentralized."
|
||||
43
install.sh
Executable file
43
install.sh
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve the directory this script lives in, regardless of cwd.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "Installing from: $SCRIPT_DIR"
|
||||
|
||||
# Pin the build backend for coincurve 20.0.0 (fixes Python 3.14 build).
|
||||
echo 'scikit-build-core<0.10' > build-constraints.txt
|
||||
|
||||
# Create venv next to the script.
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install --upgrade pip
|
||||
|
||||
# Use the local build constraint. PIP_CONSTRAINT needs an absolute path.
|
||||
PIP_CONSTRAINT="$SCRIPT_DIR/build-constraints.txt" \
|
||||
PIP_BUILD_CONSTRAINT="$SCRIPT_DIR/build-constraints.txt" \
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
|
||||
# Seed config.toml from the example if it doesn't exist yet.
|
||||
if [ ! -f config.toml ]; then
|
||||
cp config.toml.example config.toml
|
||||
chmod 600 config.toml
|
||||
echo
|
||||
echo "Created config.toml from example — edit it and set your nsec."
|
||||
fi
|
||||
|
||||
chmod +x run.sh publish.py
|
||||
|
||||
echo
|
||||
echo "Installed to $SCRIPT_DIR"
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo " 1. Edit $SCRIPT_DIR/config.toml and set your nsec."
|
||||
echo " 2. Test: $SCRIPT_DIR/venv/bin/python $SCRIPT_DIR/publish.py"
|
||||
echo " 3. Add cron (every 30 min):"
|
||||
echo
|
||||
echo " crontab -e"
|
||||
echo
|
||||
echo " */30 * * * * $SCRIPT_DIR/run.sh >> $SCRIPT_DIR/cron.log 2>&1"
|
||||
echo
|
||||
386
publish.py
Normal file
386
publish.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch ICS + RSS events from do.basspistol.org and publish them as
|
||||
NIP-52 calendar events (kind 31923) to Nostr relays, requesting
|
||||
inclusion in a kind 31924 calendar.
|
||||
|
||||
Usage:
|
||||
publish.py # normal run
|
||||
publish.py --dry-run # log what would be published, no DB writes, no relay calls
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import feedparser
|
||||
import requests
|
||||
from icalendar import Calendar
|
||||
|
||||
from pynostr.event import Event
|
||||
from pynostr.key import PrivateKey
|
||||
from pynostr.relay_manager import RelayManager
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = SCRIPT_DIR / "config.toml"
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def setup_logging(log_path: Path):
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(log_path, encoding="utf-8"),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger("nostr-calendar")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feed fetching / parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_text(url: str) -> str:
|
||||
resp = requests.get(url, timeout=30, headers={"User-Agent": "nostr-calendar/1.0"})
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def parse_ics(text: str) -> list[dict]:
|
||||
cal = Calendar.from_ical(text)
|
||||
return [dict(c) for c in cal.walk("VEVENT")]
|
||||
|
||||
|
||||
def parse_rss(text: str) -> dict:
|
||||
feed = feedparser.parse(text)
|
||||
by_url = {}
|
||||
for entry in feed.entries:
|
||||
link = entry.get("link", "")
|
||||
if not link:
|
||||
continue
|
||||
|
||||
image = ""
|
||||
enclosures = entry.get("enclosures", [])
|
||||
if enclosures:
|
||||
image = enclosures[0].get("href", "")
|
||||
if not image:
|
||||
for l in entry.get("links", []):
|
||||
if l.get("rel") == "enclosure":
|
||||
image = l.get("href", "")
|
||||
break
|
||||
if not image:
|
||||
m = re.search(r'<img[^>]+src="([^"]+)"', entry.get("description", ""))
|
||||
if m:
|
||||
image = m.group(1)
|
||||
|
||||
categories = [c.get("term", "") for c in entry.get("tags", []) if c.get("term")]
|
||||
by_url[link] = {
|
||||
"image": image,
|
||||
"categories": categories,
|
||||
"description": entry.get("description", ""),
|
||||
}
|
||||
return by_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ICS field helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ics_str(component: dict, key: str) -> str:
|
||||
val = component.get(key)
|
||||
if val is None:
|
||||
return ""
|
||||
if hasattr(val, "to_ical"):
|
||||
try:
|
||||
return val.to_ical().decode("utf-8", errors="replace").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def ics_dt(component: dict, key: str) -> int | None:
|
||||
val = component.get(key)
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
dt = val.dt
|
||||
if isinstance(dt, datetime):
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return int(dt.timestamp())
|
||||
return int(datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc).timestamp())
|
||||
except Exception as e:
|
||||
log.warning("Could not parse %s: %s", key, e)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content / tags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalize_tag(tag: str) -> str:
|
||||
tag = tag.strip().lower()
|
||||
tag = re.sub(r"\s+", "-", tag)
|
||||
tag = re.sub(r"[^\w\-]", "", tag)
|
||||
return tag.strip("-")
|
||||
|
||||
|
||||
def build_content(template: str, title: str, location: str, url: str) -> str:
|
||||
return template.format(title=title, location=location, url=url)
|
||||
|
||||
|
||||
def make_event_id(ics_url: str, uid: str) -> str:
|
||||
m = re.match(r"^(\d+)@", uid)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return hashlib.sha256(uid.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def day_timestamps(start: int, end: int | None) -> list[int]:
|
||||
days = [start // 86400]
|
||||
if end is not None:
|
||||
last = end // 86400
|
||||
if last > days[0]:
|
||||
days.extend(range(days[0] + 1, last + 1))
|
||||
return days
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dedupe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def init_db(path: Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS published (
|
||||
d_tag TEXT PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL,
|
||||
event_id TEXT,
|
||||
published_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def content_hash(*parts) -> str:
|
||||
h = hashlib.sha256()
|
||||
for p in parts:
|
||||
h.update(str(p).encode("utf-8"))
|
||||
h.update(b"\x00")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def already_published(conn, d_tag: str, chash: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT content_hash FROM published WHERE d_tag = ?", (d_tag,)
|
||||
).fetchone()
|
||||
return row is not None and row[0] == chash
|
||||
|
||||
|
||||
def record_published(conn, d_tag: str, chash: str, event_id: str):
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO published (d_tag, content_hash, event_id, published_at) VALUES (?, ?, ?, ?)",
|
||||
(d_tag, chash, event_id, int(time.time())),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_calendar_event(cfg: dict) -> Event:
|
||||
cal = cfg["calendar"]
|
||||
return Event(
|
||||
kind=31924,
|
||||
content=cal["description"],
|
||||
tags=[
|
||||
["d", cal["d"]],
|
||||
["title", cal["title"]],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def make_calendar_event_nostr(cfg, pubkey_hex, title, start, end, location, url,
|
||||
image, tags, d_tag, duration, template) -> Event:
|
||||
cal_coord = f"31924:{pubkey_hex}:{cfg['calendar']['d']}"
|
||||
final_end = end if end is not None else start + duration
|
||||
|
||||
ev_tags = [
|
||||
["d", d_tag],
|
||||
["title", title],
|
||||
["start", str(start)],
|
||||
["end", str(final_end)],
|
||||
["a", cal_coord],
|
||||
]
|
||||
if location:
|
||||
ev_tags.append(["location", location])
|
||||
if image:
|
||||
ev_tags.append(["image", image])
|
||||
for t in tags:
|
||||
ev_tags.append(["t", t])
|
||||
for day in day_timestamps(start, final_end):
|
||||
ev_tags.append(["D", str(day)])
|
||||
|
||||
return Event(
|
||||
kind=31923,
|
||||
content=build_content(template, title, location, url),
|
||||
tags=ev_tags,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
dry_run = "--dry-run" in sys.argv
|
||||
cfg = load_config()
|
||||
setup_logging(SCRIPT_DIR / cfg.get("log_path", "publish.log"))
|
||||
|
||||
if dry_run:
|
||||
log.info("=== DRY RUN — no DB writes, no relay publishing ===")
|
||||
|
||||
secret = cfg["secret_key"]
|
||||
relays = cfg["relays"]
|
||||
ics_url = cfg["ics_url"]
|
||||
rss_url = cfg["rss_url"]
|
||||
duration = int(cfg.get("default_duration", 3600))
|
||||
template = cfg.get("content_template", "{title}\n📍 {location}\n🔗 {url}")
|
||||
rss_only = bool(cfg.get("rss_only", False))
|
||||
|
||||
pk = PrivateKey.from_nsec(secret) if secret.startswith("nsec") else PrivateKey(bytes.fromhex(secret))
|
||||
pubkey_hex = pk.public_key.hex()
|
||||
|
||||
log.info("Using pubkey %s", pubkey_hex)
|
||||
log.info("Relays: %s", ", ".join(relays))
|
||||
log.info("rss_only=%s", rss_only)
|
||||
|
||||
log.info("Fetching ICS …")
|
||||
ics_text = fetch_text(ics_url)
|
||||
log.info("Fetching RSS …")
|
||||
rss_text = fetch_text(rss_url)
|
||||
|
||||
log.info("Parsing ICS …")
|
||||
vevents = parse_ics(ics_text)
|
||||
log.info("Parsing RSS …")
|
||||
rss_map = parse_rss(rss_text)
|
||||
|
||||
log.info("Found %d ICS events, %d RSS entries", len(vevents), len(rss_map))
|
||||
|
||||
conn = init_db(SCRIPT_DIR / cfg.get("db_path", "events.db"))
|
||||
|
||||
to_publish = []
|
||||
skipped = 0
|
||||
skipped_no_rss = 0
|
||||
|
||||
for vev in vevents:
|
||||
uid = ics_str(vev, "UID")
|
||||
if not uid:
|
||||
continue
|
||||
|
||||
title = ics_str(vev, "SUMMARY")
|
||||
url = ics_str(vev, "URL")
|
||||
location = ics_str(vev, "LOCATION")
|
||||
start = ics_dt(vev, "DTSTART")
|
||||
end = ics_dt(vev, "DTEND")
|
||||
categories_ics = ics_str(vev, "CATEGORIES")
|
||||
|
||||
if start is None:
|
||||
log.warning("Skipping %s — no DTSTART", uid)
|
||||
continue
|
||||
|
||||
rss = rss_map.get(url, {})
|
||||
|
||||
if rss_only and not rss:
|
||||
skipped_no_rss += 1
|
||||
continue
|
||||
|
||||
image = rss.get("image", "")
|
||||
rss_cats = rss.get("categories", [])
|
||||
|
||||
if categories_ics:
|
||||
raw_tags = [c.strip() for c in categories_ics.split(",") if c.strip()]
|
||||
else:
|
||||
raw_tags = rss_cats
|
||||
tags = [normalize_tag(t) for t in raw_tags if normalize_tag(t)]
|
||||
|
||||
d_tag = make_event_id(ics_url, uid)
|
||||
chash = content_hash(title, start, end, location, url, image, ",".join(tags))
|
||||
|
||||
if dry_run:
|
||||
log.info("DRY RUN %s: title=%r image=%r tags=%r start=%d end=%s",
|
||||
d_tag, title, image, tags, start, end)
|
||||
continue
|
||||
|
||||
if already_published(conn, d_tag, chash):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
ev = make_calendar_event_nostr(
|
||||
cfg, pubkey_hex, title, start, end, location, url,
|
||||
image, tags, d_tag, duration, template,
|
||||
)
|
||||
to_publish.append((d_tag, chash, ev))
|
||||
|
||||
if dry_run:
|
||||
log.info("DRY RUN complete. Would publish %d, skipped_no_rss=%d",
|
||||
len([x for x in vevents]), skipped_no_rss)
|
||||
return
|
||||
|
||||
log.info("Publishing %d new/changed events (skipped %d unchanged, %d without RSS)",
|
||||
len(to_publish), skipped, skipped_no_rss)
|
||||
|
||||
if not to_publish:
|
||||
log.info("Nothing to do.")
|
||||
return
|
||||
|
||||
cal_ev = make_calendar_event(cfg)
|
||||
cal_ev.sign(pk.hex())
|
||||
|
||||
relay_manager = RelayManager(timeout=6)
|
||||
for r in relays:
|
||||
relay_manager.add_relay(r)
|
||||
|
||||
relay_manager.publish_event(cal_ev)
|
||||
log.info("Published calendar kind 31924 (d=%s)", cfg["calendar"]["d"])
|
||||
|
||||
for d_tag, chash, ev in to_publish:
|
||||
ev.sign(pk.hex())
|
||||
relay_manager.publish_event(ev)
|
||||
log.info("Queued event %s — %s", d_tag, ev.content.split("\n", 1)[0])
|
||||
|
||||
relay_manager.run_sync()
|
||||
time.sleep(2)
|
||||
relay_manager.close_all_relay_connections()
|
||||
|
||||
for d_tag, chash, ev in to_publish:
|
||||
record_published(conn, d_tag, chash, ev.id)
|
||||
|
||||
log.info("Done. %d events published.", len(to_publish))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
coincurve==20.0.0
|
||||
feedparser>=6.0.0
|
||||
icalendar>=5.0.0
|
||||
requests>=2.28.0
|
||||
pynostr>=0.6.0
|
||||
6
run.sh
Executable file
6
run.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
#!/bin/bash
|
||||
# Resolve the directory this script lives in, regardless of cwd.
|
||||
# Cron starts in $HOME, so the explicit cd matters.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR" || exit 1
|
||||
exec "$SCRIPT_DIR/venv/bin/python" "$SCRIPT_DIR/publish.py"
|
||||
Loading…
Add table
Add a link
Reference in a new issue