3 Commits

Author SHA1 Message Date
e4fc11405a Added function to post mastodon, or bluesky or both
All checks were successful
Build and Push Docker Image on Tag / build_and_push (push) Successful in 27s
2025-05-25 14:32:20 +02:00
6b52107afa Added function to control maximum post age
All checks were successful
Build and Push Docker Image on Tag / build_and_push (push) Successful in 27s
2025-05-25 12:53:18 +02:00
539a8abf28 Restructuring of the README.md 2025-05-25 09:37:47 +02:00
3 changed files with 169 additions and 80 deletions

View File

@ -1,53 +1,114 @@
# RSS-Feed zu Mastodon & Bluesky Poster
# 📰 BlueMastoFeed RSS zu Mastodon & Bluesky Poster
Dieses Tool liest regelmäßig einen RSS-Feed aus und veröffentlicht neue Beiträge automatisch auf Mastodon und Bluesky. Es läuft vollständig in einem Docker-Container und benötigt nur eine einfache .env-Datei zur Konfiguration.
**BlueMastoFeed** ist ein Docker-basiertes Tool, das regelmäßig einen RSS-Feed ausliest und neue Beiträge automatisch auf **Mastodon** und **Bluesky** veröffentlicht.
Dabei prüft es, ob ein Beitrag bereits gepostet wurde, und speichert dies lokal in einer Datei (/data/seen_posts.txt). Optional werden OpenGraph-Daten (Titel, Vorschaubild) der verlinkten Seiten extrahiert, um reichhaltigere Inhalte zu posten.
## Features
- RSS-Feed regelmäßig auslesen
- Postfilterung nach Alter (`MAX_POST_AGE_DAYS`)
- Verhindert doppelte Posts mit Hilfe einer persistierten ID-Liste
- Posten auf:
- ✅ Mastodon
- ✅ Bluesky
- ✅ Beides (konfigurierbar über `.env`)
- Optionaler E-Mail-Versand bei Erfolg oder Fehler
- Healthcheck-Endpoint auf Port 8000
## Voraussetzungen
- Docker installiert (mindestens Version 20.10)
- Docker (Version **20.10** oder höher)
- Zugangsdaten für Mastodon & Bluesky
- RSS-Feed-URL
- Gültige RSS-Feed-URL
## Einrichtung
1. Repository klonen
## Einrichtung für Produktivbetrieb
### 1. Datenverzeichnis auf dem Host erstellen
```bash
mkdir -p /opt/bluemastofeed/data
```
### 2. Container mit Umgebungsvariablen starten
```bash
docker run -d \
--name bluemastofeed \
-e FEED_URL=https://example.com/rss.xml \
-e MASTODON_API_BASE_URL=https://mastodon.social \
-e MASTODON_ACCESS_TOKEN=your_mastodon_access_token \
-e BSKY_IDENTIFIER=your_handle.bsky.social \
-e BSKY_PASSWORD=your_bluesky_password \
-v /opt/bluemastofeed/data:/data \
dev.ksite.de/ralf.kirchner/bluemastofeed:latest
```
## Einrichtung für Entwicklung
### 1. Repository klonen
```bash
git clone https://dev.ksite.de/ralf.kirchner/BlueMastoFeed.git
cd BlueMastoFeed
```
2. `.env`-Datei erstellen
### 2. `.env`-Datei erstellen
Erstelle eine Datei .env im Projektverzeichnis mit folgendem Inhalt:
Erstelle im Projektverzeichnis eine Datei namens `.env` mit folgendem Inhalt:
```env
FEED_URL=https://example.com/rss
FEED_URL=https://example.com/rss.xml
MASTODON_API_BASE_URL=https://mastodon.social
MASTODON_ACCESS_TOKEN=your_mastodon_token
BSKY_IDENTIFIER=your_bsky_handle
BSKY_PASSWORD=your_bsky_password
INTERVAL_MINUTES=30
```
3. Image bauen
### 3. Docker-Image lokal bauen
```bash
docker build -t bluemastofeed .
```
4. Container starten
### 4. Container starten
```bash
docker run -d \
--name rss-poster \
--env-file .env \
-v $(pwd)/data:/data \
-p 8000:8000 \
bluemastofeed
```
## Umgebungsvariablen
Die folgenden Umgebungsvariablen steuern das Verhalten des Containers. Sie können entweder direkt beim Start übergeben oder über eine `.env`-Datei definiert werden.
| Variable | Beschreibung | Beispielwert | Standardwert |
| ----------------------- | ------------------------------------------------------------ | -------------------------- | -------------- |
| `FEED_URL` | URL zum RSS- oder Atom-Feed | `https://example.com/feed` | _erforderlich_ |
| `MAX_POST_AGE_DAYS` | Maximales Alter eines Beitrags (in Tagen), der gepostet werden darf | `0` = nur heutige Beiträge | `0` |
| `POST_TARGETS` | Zielplattform(en): `mastodon`, `bluesky`, `both` | `mastodon` = nur Mastodon | `both` |
| `MASTODON_API_BASE_URL` | Basis-URL deiner Mastodon-Instanz | `https://mastodon.social` | _erforderlich_ |
| `MASTODON_ACCESS_TOKEN` | Access Token für die Mastodon API | `abc123...` | _erforderlich_ |
| `BSKY_IDENTIFIER` | Bluesky-Handle | `name.bsky.social` | _erforderlich_ |
| `BSKY_PASSWORD` | Passwort für das Bluesky-Konto | `passwort123` | _erforderlich_ |
| `INTERVAL_MINUTES` | Zeitintervall in Minuten zwischen den Feed-Prüfungen | `30` | `30` |
| `EMAIL_MODE` | Wann eine Status-E-Mail gesendet werden soll (`none`, `errors`, `all`) | `errors` | `errors` |
| `SMTP_HOST` | SMTP-Server für Status-E-Mails | `smtp.example.com` | _optional_ |
| `SMTP_PORT` | Port des SMTP-Servers | `587` | `587` |
| `SMTP_USER` | Benutzername für SMTP | `user@example.com` | _optional_ |
| `SMTP_PASSWORD` | Passwort für SMTP | `sicherespasswort` | _optional_ |
| `EMAIL_FROM` | Absenderadresse für E-Mails | `noreply@example.com` | _optional_ |
| `EMAIL_TO` | Empfängeradresse für E-Mails | `admin@example.com` | _optional_ |

View File

@ -14,26 +14,28 @@ from dotenv import load_dotenv
from http.server import HTTPServer, BaseHTTPRequestHandler
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from dateutil import parser as date_parser
from datetime import datetime, timezone, timedelta
load_dotenv()
FEED_URL = os.getenv("FEED_URL")
SEEN_POSTS_FILE = "/data/seen_posts.txt"
MASTODON_BASE_URL = os.getenv("MASTODON_API_BASE_URL")
MASTODON_TOKEN = os.getenv("MASTODON_ACCESS_TOKEN")
BSKY_HANDLE = os.getenv("BSKY_IDENTIFIER")
BSKY_PASSWORD = os.getenv("BSKY_PASSWORD")
MAX_POST_AGE_DAYS = int(os.getenv("MAX_POST_AGE_DAYS", 0))
POST_TARGETS = os.getenv("POST_TARGETS", "both").lower()
# Logging konfigurieren (Standard-Format)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
handler = logging.StreamHandler() # Log an stdout (Docker-Standard)
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
class HealthHandler(BaseHTTPRequestHandler):
"""Handles HTTP GET requests for the health check endpoint."""
def do_GET(self):
if self.path == "/health":
self.send_response(200)
@ -43,25 +45,24 @@ class HealthHandler(BaseHTTPRequestHandler):
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
"""Suppress default HTTP request logging."""
pass
def start_health_server():
"""Starts the health check HTTP server in a background thread."""
server = HTTPServer(("0.0.0.0", 8000), HealthHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
logger.info("Healthcheck server runs on port 8000.")
logger.info("Healthcheck server is running on port 8000.")
def should_send_email(on_success: bool):
"""Determines whether to send a status email based on mode and success."""
mode = os.getenv("EMAIL_MODE", "errors").lower()
if mode == "none":
return False
if mode == "all":
return True
if mode == "errors" and not on_success:
return True
return False
return (mode == "all") or (mode == "errors" and not on_success)
def send_status_email(subject, html_content):
"""Sends a formatted HTML email with the given subject and content."""
try:
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", 587))
@ -74,36 +75,36 @@ def send_status_email(subject, html_content):
msg["Subject"] = subject
msg["From"] = email_from
msg["To"] = email_to
part = MIMEText(html_content, "html")
msg.attach(part)
msg.attach(MIMEText(html_content, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_password)
server.sendmail(email_from, email_to, msg.as_string())
logger.info("Status E-Mail gesendet.")
logger.info("Status email sent.")
except Exception as e:
logger.error(f"Fehler beim Senden der E-Mail: {e}")
logger.error(f"Error sending status email: {e}")
def load_seen_ids():
"""Loads the set of already seen post IDs from file."""
os.makedirs(os.path.dirname(SEEN_POSTS_FILE), exist_ok=True)
if not os.path.exists(SEEN_POSTS_FILE):
with open(SEEN_POSTS_FILE, "w"): pass
return set()
open(SEEN_POSTS_FILE, "w").close()
with open(SEEN_POSTS_FILE, "r") as f:
return set(line.strip() for line in f)
def save_seen_id(post_id):
"""Appends a new post ID to the seen posts file."""
with open(SEEN_POSTS_FILE, "a") as f:
f.write(post_id + "\n")
def post_to_mastodon(message):
"""Posts a message to Mastodon."""
mastodon = Mastodon(access_token=MASTODON_TOKEN, api_base_url=MASTODON_BASE_URL)
mastodon.toot(message)
def fetch_og_data(url):
"""Fetches Open Graph title and image URL from a web page."""
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
@ -118,6 +119,7 @@ def fetch_og_data(url):
return None, None
def post_to_bluesky(message, link):
"""Posts a message and optional preview to Bluesky."""
client = Client()
client.login(BSKY_HANDLE, BSKY_PASSWORD)
@ -131,93 +133,113 @@ def post_to_bluesky(message, link):
"external": {
"uri": link,
"title": title,
"description": "", # Optional: Beschreibung kannst du per OG:description holen
"description": "",
"thumb": {
"$type": "blob",
"ref": None, # Wird vom Upload ersetzt
"mimeType": "", # Wird vom Upload ersetzt
"size": 0 # Wird vom Upload ersetzt
"ref": None,
"mimeType": "",
"size": 0
}
}
}
# Bild herunterladen und hochladen
img_resp = requests.get(image_url, timeout=10)
img_resp.raise_for_status()
image_bytes = BytesIO(img_resp.content)
blob = client.upload_blob(image_bytes)
embed["external"]["thumb"] = blob.blob # Automatisch ersetzt
blob = client.upload_blob(BytesIO(img_resp.content))
embed["external"]["thumb"] = blob.blob
client.send_post(text=text, embed=embed)
logger.info("Posted with OG preview.")
logger.info("Posted to Bluesky with OG preview.")
return
except Exception as e:
logger.error(f"Error uploading OG preview: {e}")
# Fallback: Nur Text + Link
client.send_post(f"{text}\n{link}")
logger.info("Posted without OG preview.")
logger.info("Posted to Bluesky without preview.")
def extract_post_date(entry):
"""Extracts the oldest available date from various RSS date fields."""
date_fields = [
entry.get("published"),
entry.get("updated"),
entry.get("date_published"),
entry.get("date_modified"),
entry.get("pubDate")
]
dates = []
for d in date_fields:
if d:
try:
dt = date_parser.parse(d)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
dates.append(dt)
except Exception as e:
logger.warning(f"⚠️ Cannot parse date field: {d} ({e})")
return min(dates) if dates else datetime.now(timezone.utc)
def main():
"""Main function to process feed entries and post new items."""
seen_ids = load_seen_ids()
feed = feedparser.parse(FEED_URL)
now = datetime.now(timezone.utc)
max_age = timedelta(days=MAX_POST_AGE_DAYS)
for entry in feed.entries:
post_id = entry.get("id") or entry.get("link")
if post_id in seen_ids:
continue
post_date = extract_post_date(entry)
age = now - post_date
age_days = age.days
age_hours = age.seconds // 3600
logger.info(f"Post '{entry.get('title', '').strip()}' is {age_days} days and {age_hours} hours old.")
if post_date < now - max_age:
logger.info(f"⏩ Skipping old post (older than {MAX_POST_AGE_DAYS} days): {post_id}")
continue
title = entry.get("title", "").strip()
link = entry.get("link", "").strip()
message = link # Link alleine posten für Mastodon OG-Vorschau
message = link
logger.info(f"New post: {title}")
try:
post_to_mastodon(message)
time.sleep(2)
post_to_bluesky(message, link)
if POST_TARGETS in ("mastodon", "both"):
post_to_mastodon(message)
time.sleep(2)
if POST_TARGETS in ("bluesky", "both"):
post_to_bluesky(message, link)
save_seen_id(post_id)
logger.info("Successfully posted.")
logger.info("Successfully posted.")
if should_send_email(on_success=True):
email_subject = f"✅ Erfolgreich gepostet: {title}"
email_body = f"""
<html>
<body>
<h2>Beitrag erfolgreich gepostet</h2>
<p><strong>Titel:</strong> {title}</p>
<p><strong>Link:</strong> <a href="{link}">{link}</a></p>
</body>
</html>
"""
send_status_email(email_subject, email_body)
send_status_email(
f"✅ Successfully posted: {title}",
f"<html><body><h2>Post successfully published</h2><p><b>Title:</b> {title}</p><p><b>Link:</b> <a href='{link}'>{link}</a></p></body></html>"
)
except Exception as e:
logger.error(f"Error posting: {e}")
logger.error(f"Error posting: {e}")
if should_send_email(on_success=False):
email_subject = f"❌ Fehler beim Posten: {title}"
email_body = f"""
<html>
<body>
<h2>Fehler beim Posten</h2>
<p><strong>Titel:</strong> {title}</p>
<p><strong>Link:</strong> <a href="{link}">{link}</a></p>
<p><strong>Fehlermeldung:</strong> {str(e)}</p>
</body>
</html>
"""
send_status_email(email_subject, email_body)
send_status_email(
f"❌ Error posting: {title}",
f"<html><body><h2>Error posting</h2><p><b>Title:</b> {title}</p><p><b>Link:</b> <a href='{link}'>{link}</a></p><p><b>Error message:</b> {str(e)}</p></body></html>"
)
time.sleep(5)
if __name__ == "__main__":
INTERVAL_MINUTES = int(os.getenv("INTERVAL_MINUTES", 30)) # Default: 30 Minuten
INTERVAL_MINUTES = int(os.getenv("INTERVAL_MINUTES", 30))
logger.info(f"Start feed check every {INTERVAL_MINUTES} minutes.")
start_health_server() # HTTP-Healthcheck starten
start_health_server()
while True:
try:

6
env
View File

@ -9,9 +9,15 @@ MASTODON_ACCESS_TOKEN=your_mastodon_access_token
BSKY_IDENTIFIER=your_handle.bsky.social
BSKY_PASSWORD=your_bluesky_password
# mögliche Werte: mastodon, bluesky, both
POST_TARGETS=both
# Intervall in Minuten für Feedprüfung
INTERVAL_MINUTES=30
# Maximales Alter eines Beitrags (in Tagen), der gepostet werden darf (0 = nur heute, 1 = bis gestern, usw.)
MAX_POST_AGE_DAYS=0
# E-Mail Einstellungen
SMTP_HOST=smtp.example.com
SMTP_PORT=587