Initial commit

This commit is contained in:
2025-05-24 16:50:42 +02:00
commit a877a86c2d
9 changed files with 388 additions and 0 deletions

View File

@ -0,0 +1,31 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
jobs:
build_and_push:
runs-on: docker
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
run: |
docker buildx create --use
- name: Log in to Gitea Docker Registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login dev.ksite.de -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Extract short SHA
id: vars
run: echo "SHORT_SHA=$(echo $GITEA_SHA | cut -c1-7)" >> $GITEA_OUTPUT
- name: Build and push Docker image
run: |
docker tag dev.ksite.de/ralf.kirchner/bluemastofeed:${{ steps.vars.outputs.SHORT_SHA }} dev.ksite.de/ralf.kirchner/bluemastofeed:latest
docker push dev.ksite.de/ralf.kirchner/bluemastofeed:latest

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.env
data/*
!data/.gitkeep

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM python:3.11-slim
LABEL version="0.9.0"
RUN apt-get update && apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "bluemastofeed.py"]
HEALTHCHECK --interval=1m --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000

55
README.md Normal file
View File

@ -0,0 +1,55 @@
# RSS-Feed 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.
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.
Ein eingebauter HTTP-Healthcheck unter /health läuft auf Port 8000, um den Containerstatus einfach zu prüfen (z.B. für Kubernetes oder Docker-Healthchecks).
## Voraussetzungen
- Docker installiert (mindestens Version 20.10)
- Zugangsdaten für Mastodon & Bluesky
- RSS-Feed-URL
## Einrichtung
1. Repository klonen
```bash
git clone https://github.com/dein-benutzername/rss-poster.git
cd rss-poster
```
2. `.env`-Datei erstellen
Erstelle eine Datei .env im Projektverzeichnis mit folgendem Inhalt:
```env
FEED_URL=https://example.com/rss
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
```bash
docker build -t rss-poster .
```
4. Container starten
```bash
docker run -d \
--name rss-poster \
--env-file .env \
-v $(pwd)/data:/data \
-p 8000:8000 \
rss-poster
```

229
bluemastofeed.py Normal file
View File

@ -0,0 +1,229 @@
import os
import time
import feedparser
import json
import logging
import requests
import threading
import smtplib
from bs4 import BeautifulSoup
from io import BytesIO
from mastodon import Mastodon
from atproto import Client
from dotenv import load_dotenv
from http.server import HTTPServer, BaseHTTPRequestHandler
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
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")
# Logging konfigurieren (Standard-Format)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
handler = logging.StreamHandler() # Log an stdout (Docker-Standard)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
else:
self.send_response(404)
self.end_headers()
def start_health_server():
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.")
def should_send_email(on_success: bool):
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
def send_status_email(subject, html_content):
try:
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", 587))
smtp_user = os.getenv("SMTP_USER")
smtp_password = os.getenv("SMTP_PASSWORD")
email_from = os.getenv("EMAIL_FROM")
email_to = os.getenv("EMAIL_TO")
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = email_from
msg["To"] = email_to
part = MIMEText(html_content, "html")
msg.attach(part)
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.")
except Exception as e:
logger.error(f"Fehler beim Senden der E-Mail: {e}")
def load_seen_ids():
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()
with open(SEEN_POSTS_FILE, "r") as f:
return set(line.strip() for line in f)
def save_seen_id(post_id):
with open(SEEN_POSTS_FILE, "a") as f:
f.write(post_id + "\n")
def post_to_mastodon(message):
mastodon = Mastodon(access_token=MASTODON_TOKEN, api_base_url=MASTODON_BASE_URL)
mastodon.toot(message)
def fetch_og_data(url):
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
og_title = soup.find("meta", property="og:title")
og_image = soup.find("meta", property="og:image")
title = og_title["content"] if og_title and og_title.has_attr("content") else None
image_url = og_image["content"] if og_image and og_image.has_attr("content") else None
return title, image_url
except Exception as e:
logger.error(f"Error loading OG data: {e}")
return None, None
def post_to_bluesky(message, link):
client = Client()
client.login(BSKY_HANDLE, BSKY_PASSWORD)
title, image_url = fetch_og_data(link)
text = title or message
if title and image_url:
try:
embed = {
"$type": "app.bsky.embed.external",
"external": {
"uri": link,
"title": title,
"description": "", # Optional: Beschreibung kannst du per OG:description holen
"thumb": {
"$type": "blob",
"ref": None, # Wird vom Upload ersetzt
"mimeType": "", # Wird vom Upload ersetzt
"size": 0 # Wird vom Upload ersetzt
}
}
}
# 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
client.send_post(text=text, embed=embed)
logger.info("Posted 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.")
def main():
seen_ids = load_seen_ids()
feed = feedparser.parse(FEED_URL)
for entry in feed.entries:
post_id = entry.get("id") or entry.get("link")
if post_id in seen_ids:
continue
title = entry.get("title", "").strip()
link = entry.get("link", "").strip()
message = link # Link alleine posten für Mastodon OG-Vorschau
logger.info(f"New post: {title}")
try:
post_to_mastodon(message)
time.sleep(2)
post_to_bluesky(message, link)
save_seen_id(post_id)
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)
except Exception as 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)
time.sleep(5)
if __name__ == "__main__":
INTERVAL_MINUTES = int(os.getenv("INTERVAL_MINUTES", 30)) # Default: 30 Minuten
logger.info(f"Start feed check every {INTERVAL_MINUTES} minutes.")
start_health_server() # HTTP-Healthcheck starten
while True:
try:
main()
except Exception as e:
logger.error(f"Error in main execution: {e}")
logger.info(f"Wait {INTERVAL_MINUTES} minutes until next execution...")
time.sleep(INTERVAL_MINUTES * 60)

3
data/.gitkeep Normal file
View File

@ -0,0 +1,3 @@
Das wurde von Admin geändert. Geändert von masterkir@gmx.de
Und nun noch mit Passwort geschützt.

17
docker-compose.yml Normal file
View File

@ -0,0 +1,17 @@
version: '3.8'
services:
bluemastofeed:
build: .
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 1m
timeout: 5s
retries: 3
start_period: 10s
env_file:
- .env
volumes:
- ./data:/data

25
env Normal file
View File

@ -0,0 +1,25 @@
# RSS Feed
FEED_URL=https://example.com/rss.xml
# Mastodon
MASTODON_API_BASE_URL=https://mastodon.social
MASTODON_ACCESS_TOKEN=your_mastodon_access_token
# Bluesky
BSKY_IDENTIFIER=your_handle.bsky.social
BSKY_PASSWORD=your_bluesky_password
# Intervall in Minuten für Feedprüfung
INTERVAL_MINUTES=30
# E-Mail Einstellungen
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=bot@example.com
SMTP_PASSWORD=super_secure_password
EMAIL_FROM=bot@example.com
EMAIL_TO=admin@example.com
# E-Mail-Modus: none | errors | all
EMAIL_MODE=errors

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
feedparser
Mastodon.py
atproto
python-dotenv
beautifulsoup4
python-dateutil
requests