Initial commit
This commit is contained in:
31
.gitea/workflows/docker-build.yml
Normal file
31
.gitea/workflows/docker-build.yml
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.env
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
17
Dockerfile
Normal file
17
Dockerfile
Normal 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
55
README.md
Normal 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
229
bluemastofeed.py
Normal 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
3
data/.gitkeep
Normal 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
17
docker-compose.yml
Normal 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
25
env
Normal 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
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
feedparser
|
||||
Mastodon.py
|
||||
atproto
|
||||
python-dotenv
|
||||
beautifulsoup4
|
||||
python-dateutil
|
||||
requests
|
Reference in New Issue
Block a user