10 Commits

Author SHA1 Message Date
3d58068291 chore(base): Release v0.9.7
All checks were successful
Build and Push Docker Image on Tag / build_and_push (push) Successful in 45s
Create Release / release (push) Successful in 8s
2025-06-02 19:23:11 +02:00
e04c838ede refactor(app): Code structured and grouped 2025-06-02 17:35:55 +02:00
3e1255ccdc fix(main): Fixed error in hashtag display 2025-06-02 17:22:00 +02:00
3bb33ca379 fix(app): Add hashtags to bluesky post 2025-06-02 16:27:49 +02:00
7fe2a1de00 chore(app): Release v0.9.6
All checks were successful
Build and Push Docker Image on Tag / build_and_push (push) Successful in 30s
Create Release / release (push) Successful in 10s
2025-06-01 14:38:19 +02:00
8f5813b39c fix(app): Add title to mastodon post 2025-06-01 13:49:25 +02:00
17357da659 chore(base): Projektstruktur mit Gitea und Changelog-Support eingerichtet
All checks were successful
Build and Push Docker Image on Tag / build_and_push (push) Successful in 32s
Create Release / release (push) Successful in 10s
2025-05-31 21:40:30 +02:00
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
9 changed files with 431 additions and 102 deletions

38
.chglog/CHANGELOG.tpl.md Executable file
View File

@ -0,0 +1,38 @@
{{ with index .Versions 0 }}
<a name="{{ .Tag.Name }}"></a>
## {{ if .Tag.Previous }}[{{ .Tag.Name }}]({{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }}){{ else }}{{ .Tag.Name }}{{ end }} ({{ datetime "2006-01-02" .Tag.Date }})
{{ range .CommitGroups -}}
### {{ .Title }}
{{ range .Commits -}}
* {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} ([{{ .Hash.Short }}]({{ $.Info.RepositoryURL }}/commit/{{ .Hash.Short }}))
{{ end }}
{{ end -}}
{{- if .RevertCommits -}}
### Reverts
{{ range .RevertCommits -}}
* {{ .Revert.Header }} ([{{ .Hash.Short }}]({{ $.Info.RepositoryURL }}/commit/{{ .Hash.Short }}))
{{ end }}
{{ end -}}
{{- if .MergeCommits -}}
### Pull Requests
{{ range .MergeCommits -}}
* {{ .Header }} ([{{ .Hash.Short }}]({{ $.Info.RepositoryURL }}/commit/{{ .Hash.Short }}))
{{ end }}
{{ end -}}
{{- if .NoteGroups -}}
{{ range .NoteGroups -}}
### {{ .Title }}
{{ range .Notes }}
{{ .Body }}
{{ end }}
{{ end -}}
{{ end -}}
{{ end }}

37
.chglog/config.yml Executable file
View File

@ -0,0 +1,37 @@
style: github
template: CHANGELOG.tpl.md
info:
title: CHANGELOG
repository_url: https://dev.ksite.de/ralf.kirchner/BlueMastoFeed
options:
commits:
sort_by: "date" # Optional, default is OK too
exclude_merge_commits: false
commit_groups:
group_by: "Type"
title_maps:
feat: Features
fix: Bug Fixes
perf: Performance Improvements
refactor: Code Refactoring
docs: Documentation
chore: Maintenance
test: Tests
build: Build System
ci: Continuous Integration
style: Code Style
header:
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
pattern_maps:
- Type
- Scope
- Subject
notes:
keywords:
- BREAKING CHANGE
- DEPRECATED

100
.gitea/workflows/release.yml Executable file
View File

@ -0,0 +1,100 @@
name: Create Release
on:
push:
tags:
- 'v*' # Nur bei Tags wie v1.0.0, v2.0.0
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Enable debug output
run: set -x
- name: Checkout full history including tags
uses: actions/checkout@v3
with:
fetch-depth: 0
fetch-tags: true
- name: Show environment variables for debugging
run: |
echo "GIT_REMOTE_URL=$(git config --get remote.origin.url)"
echo "GITHUB_REF=$GITHUB_REF"
- name: Extract OWNER and REPO from git remote URL
id: repo-info
run: |
REMOTE_URL=$(git config --get remote.origin.url)
OWNER=$(echo "$REMOTE_URL" | sed -E 's#.*/([^/]+)/([^/]+)(\.git)?#\1#')
REPO=$(echo "$REMOTE_URL" | sed -E 's#.*/([^/]+)/([^/]+)(\.git)?#\2#')
echo "OWNER=$OWNER" >> $GITHUB_ENV
echo "REPO=$REPO" >> $GITHUB_ENV
- name: Install git-chglog binary (no Go needed)
run: |
GIT_CHGLOG_VERSION="0.15.1"
curl -sSL "https://github.com/git-chglog/git-chglog/releases/download/v${GIT_CHGLOG_VERSION}/git-chglog_${GIT_CHGLOG_VERSION}_linux_amd64.tar.gz" -o git-chglog.tar.gz
tar -xzf git-chglog.tar.gz
chmod +x git-chglog
sudo mv git-chglog /usr/local/bin/
- name: Determine current and previous tag
id: tags
run: |
CURRENT_TAG="${GITHUB_REF##*/}"
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_TAG}^" 2>/dev/null || true)
echo "CURRENT_TAG=$CURRENT_TAG"
echo "PREVIOUS_TAG=$PREVIOUS_TAG"
echo "CURRENT_TAG=$CURRENT_TAG" >> $GITHUB_ENV
echo "PREVIOUS_TAG=$PREVIOUS_TAG" >> $GITHUB_ENV
- name: Generate CHANGELOG.md
run: |
# Optional: kompletter Changelog (nicht für Release-Body)
git-chglog -o CHANGELOG.md
# Nur der relevante Abschnitt zwischen Tags
if [ -n "$PREVIOUS_TAG" ]; then
git-chglog "$PREVIOUS_TAG..$CURRENT_TAG" > RELEASE_BODY.md
else
git-chglog "$CURRENT_TAG" > RELEASE_BODY.md
fi
echo "Release changelog content:"
cat RELEASE_BODY.md
- name: Replace issue references with Markdown links
env:
OWNER: ${{ env.OWNER }}
REPO: ${{ env.REPO }}
run: |
sed -i -E "s/([^\\[])#([0-9]+)/\1[#\2](https:\/\/dev.ksite.de\/${OWNER}\/${REPO}\/issues\/\2)/g" RELEASE_BODY.md
- name: Create Gitea Release via API
env:
TOKEN: ${{ secrets.TOKEN }}
OWNER: ${{ env.OWNER }}
REPO: ${{ env.REPO }}
CURRENT_TAG: ${{ env.CURRENT_TAG }}
run: |
# Base64-encode und sicher escapen für JSON
BODY=$(base64 -w0 RELEASE_BODY.md)
DECODED_BODY=$(echo "$BODY" | base64 -d | jq -Rs .)
echo "Creating release for tag $CURRENT_TAG"
curl -s -X POST "https://dev.ksite.de/api/v1/repos/${OWNER}/${REPO}/releases" \
-H "Content-Type: application/json" \
-H "Authorization: token $TOKEN" \
-d @- <<EOF
{
"tag_name": "${CURRENT_TAG}",
"name": "${REPO} ${CURRENT_TAG}",
"body": ${DECODED_BODY}
}
EOF

9
.gitignore vendored
View File

@ -2,3 +2,12 @@
data/* data/*
!data/.gitkeep !data/.gitkeep
# Config & meta
CHANGELOG.md
ENVIRONMENT.md
# IDEs / Editor
.vscode/
.idea/
.DS_Store

View File

@ -1,5 +1,5 @@
FROM python:3.11-slim FROM python:3.11-slim
LABEL version="0.9.0" LABEL version="0.9.7"
RUN apt-get update && apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*
@ -15,3 +15,5 @@ HEALTHCHECK --interval=1m --timeout=5s --start-period=10s --retries=3 \
EXPOSE 8000 EXPOSE 8000

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 ## Voraussetzungen
- Docker installiert (mindestens Version 20.10) - Docker (Version **20.10** oder höher)
- Zugangsdaten für Mastodon & Bluesky - 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 ```bash
git clone https://dev.ksite.de/ralf.kirchner/BlueMastoFeed.git git clone https://dev.ksite.de/ralf.kirchner/BlueMastoFeed.git
cd BlueMastoFeed 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 ```env
FEED_URL=https://example.com/rss FEED_URL=https://example.com/rss.xml
MASTODON_API_BASE_URL=https://mastodon.social MASTODON_API_BASE_URL=https://mastodon.social
MASTODON_ACCESS_TOKEN=your_mastodon_token MASTODON_ACCESS_TOKEN=your_mastodon_token
BSKY_IDENTIFIER=your_bsky_handle BSKY_IDENTIFIER=your_bsky_handle
BSKY_PASSWORD=your_bsky_password BSKY_PASSWORD=your_bsky_password
INTERVAL_MINUTES=30
``` ```
3. Image bauen ### 3. Docker-Image lokal bauen
```bash ```bash
docker build -t bluemastofeed . docker build -t bluemastofeed .
``` ```
4. Container starten ### 4. Container starten
```bash ```bash
docker run -d \ docker run -d \
--name rss-poster \ --name rss-poster \
--env-file .env \ --env-file .env \
-v $(pwd)/data:/data \ -v $(pwd)/data:/data \
-p 8000:8000 \
bluemastofeed 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

@ -1,11 +1,12 @@
import os import os
import time import time
import feedparser import feedparser
import json
import logging import logging
import requests import requests
import threading import threading
import smtplib import smtplib
import re
import unicodedata
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from io import BytesIO from io import BytesIO
from mastodon import Mastodon from mastodon import Mastodon
@ -14,25 +15,31 @@ from dotenv import load_dotenv
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from dateutil import parser as date_parser
from datetime import datetime, timezone, timedelta
# Load environment variables
load_dotenv() load_dotenv()
# Configuration
FEED_URL = os.getenv("FEED_URL") FEED_URL = os.getenv("FEED_URL")
SEEN_POSTS_FILE = "/data/seen_posts.txt" SEEN_POSTS_FILE = "/data/seen_posts.txt"
MASTODON_BASE_URL = os.getenv("MASTODON_API_BASE_URL") MASTODON_BASE_URL = os.getenv("MASTODON_API_BASE_URL")
MASTODON_TOKEN = os.getenv("MASTODON_ACCESS_TOKEN") MASTODON_TOKEN = os.getenv("MASTODON_ACCESS_TOKEN")
BSKY_HANDLE = os.getenv("BSKY_IDENTIFIER") BSKY_HANDLE = os.getenv("BSKY_IDENTIFIER")
BSKY_PASSWORD = os.getenv("BSKY_PASSWORD") 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 setup
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
handler = logging.StreamHandler() # Log an stdout (Docker-Standard) handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger.addHandler(handler) logger.addHandler(handler)
# Healthcheck server
class HealthHandler(BaseHTTPRequestHandler): class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
if self.path == "/health": if self.path == "/health":
@ -43,23 +50,57 @@ class HealthHandler(BaseHTTPRequestHandler):
self.send_response(404) self.send_response(404)
self.end_headers() self.end_headers()
def log_message(self, format, *args):
pass
def start_health_server(): def start_health_server():
server = HTTPServer(("0.0.0.0", 8000), HealthHandler) server = HTTPServer(("0.0.0.0", 8000), HealthHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True) thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start() thread.start()
logger.info("Healthcheck server runs on port 8000.") logger.info(f"Healthcheck server running on port 8000.")
# Email helper
def should_send_email(on_success: bool): def should_send_email(on_success: bool):
mode = os.getenv("EMAIL_MODE", "errors").lower() mode = os.getenv("EMAIL_MODE", "errors").lower()
if mode == "none": return (mode == "all") or (mode == "errors" and not on_success)
return False
if mode == "all":
return True
if mode == "errors" and not on_success:
return True
return False
def generate_email_html(status: str, title: str, link: str, error_message: str = None) -> str:
color = "#2e7d32" if status == "success" else "#d32f2f"
bg_color = "#f5f5f5" if status == "success" else "#fff3f3"
border_color = "#ccc" if status == "success" else "#e57373"
emoji = "" if status == "success" else ""
heading = "Post Published" if status == "success" else "Error Posting Entry"
meta = "This is an automated success notification." if status == "success" else "Please check logs or configuration."
error_html = f"""
<p><strong>Error:</strong></p>
<div class=\"error\">{error_message}</div>
""" if error_message else ""
return f"""
<html>
<head>
<style>
body {{ font-family: 'Courier New', monospace; background-color: {bg_color}; color: #333; padding: 20px; }}
.container {{ background-color: #ffffff; border: 1px solid {border_color}; border-radius: 8px; padding: 20px; max-width: 600px; margin: auto; }}
h2 {{ color: {color}; }}
a {{ color: #1a73e8; text-decoration: none; }}
.error {{ font-family: monospace; background-color: #fce4ec; padding: 10px; border-radius: 4px; color: #b71c1c; }}
.meta {{ font-size: 14px; color: #777; }}
</style>
</head>
<body>
<div class=\"container\">
<h2>{emoji} {heading}</h2>
<p><strong>Title:</strong><br>{title}</p>
<p><strong>Link:</strong><br><a href=\"{link}\">{link}</a></p>
{error_html}
<p class=\"meta\">{meta}</p>
</div>
</body>
</html>
"""
def send_status_email(subject, html_content): def send_status_email(subject, html_content):
try: try:
@ -74,24 +115,49 @@ def send_status_email(subject, html_content):
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = email_from msg["From"] = email_from
msg["To"] = email_to msg["To"] = email_to
msg.attach(MIMEText(html_content, "html"))
part = MIMEText(html_content, "html")
msg.attach(part)
with smtplib.SMTP(smtp_host, smtp_port) as server: with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls() server.starttls()
server.login(smtp_user, smtp_password) server.login(smtp_user, smtp_password)
server.sendmail(email_from, email_to, msg.as_string()) 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}")
logger.info(f"✅ Status email sent successfully.")
except Exception as e:
logger.error(f"❌ Error sending email: {e}")
# Utility functions
def extract_facets_utf8(text: str):
facets = []
def get_byte_range(char_start, char_end):
byte_start = len(text[:char_start].encode("utf-8"))
byte_end = len(text[:char_end].encode("utf-8"))
return byte_start, byte_end
for match in re.finditer(r"#(\w+)", text):
tag = match.group(1)
byte_start, byte_end = get_byte_range(*match.span())
facets.append({
"index": {"byteStart": byte_start, "byteEnd": byte_end},
"features": [{"$type": "app.bsky.richtext.facet#tag", "tag": tag}]
})
for match in re.finditer(r"https?://[^\s]+", text):
url = match.group(0)
byte_start, byte_end = get_byte_range(*match.span())
facets.append({
"index": {"byteStart": byte_start, "byteEnd": byte_end},
"features": [{"$type": "app.bsky.richtext.facet#link", "uri": url}]
})
return facets
def load_seen_ids(): def load_seen_ids():
os.makedirs(os.path.dirname(SEEN_POSTS_FILE), exist_ok=True) os.makedirs(os.path.dirname(SEEN_POSTS_FILE), exist_ok=True)
if not os.path.exists(SEEN_POSTS_FILE): if not os.path.exists(SEEN_POSTS_FILE):
with open(SEEN_POSTS_FILE, "w"): pass open(SEEN_POSTS_FILE, "w").close()
return set()
with open(SEEN_POSTS_FILE, "r") as f: with open(SEEN_POSTS_FILE, "r") as f:
return set(line.strip() for line in f) return set(line.strip() for line in f)
@ -99,8 +165,12 @@ def save_seen_id(post_id):
with open(SEEN_POSTS_FILE, "a") as f: with open(SEEN_POSTS_FILE, "a") as f:
f.write(post_id + "\n") f.write(post_id + "\n")
def post_to_mastodon(message): def post_to_mastodon(title, link, tags):
mastodon = Mastodon(access_token=MASTODON_TOKEN, api_base_url=MASTODON_BASE_URL) mastodon = Mastodon(access_token=MASTODON_TOKEN, api_base_url=MASTODON_BASE_URL)
hashtags = " ".join(f"#{tag}" for tag in tags) if tags else ""
message = f"{title}\n\n{link}"
if hashtags:
message += f"\n\n{hashtags}"
mastodon.toot(message) mastodon.toot(message)
def fetch_og_data(url): def fetch_og_data(url):
@ -114,116 +184,121 @@ def fetch_og_data(url):
image_url = og_image["content"] if og_image and og_image.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 return title, image_url
except Exception as e: except Exception as e:
logger.error(f"Error loading OG data: {e}") logger.error(f"Error fetching OG data: {e}")
return None, None return None, None
def post_to_bluesky(message, link): def post_to_bluesky(title, link, tags):
client = Client() client = Client()
client.login(BSKY_HANDLE, BSKY_PASSWORD) client.login(BSKY_HANDLE, BSKY_PASSWORD)
title, image_url = fetch_og_data(link) hashtags = " ".join(f"#{tag}" for tag in tags) if tags else ""
text = title or message message = f"{title}\n\n{link}"
if hashtags:
message += f"\n\n{hashtags}"
if title and image_url: facets = extract_facets_utf8(message)
try:
try:
og_title, image_url = fetch_og_data(link)
if og_title and image_url:
embed = { embed = {
"$type": "app.bsky.embed.external", "$type": "app.bsky.embed.external",
"external": { "external": {
"uri": link, "uri": link,
"title": title, "title": title,
"description": "", # Optional: Beschreibung kannst du per OG:description holen "description": "",
"thumb": { "thumb": {"$type": "blob", "ref": None, "mimeType": "", "size": 0}
"$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 = requests.get(image_url, timeout=10)
img_resp.raise_for_status() img_resp.raise_for_status()
image_bytes = BytesIO(img_resp.content) blob = client.upload_blob(BytesIO(img_resp.content))
embed["external"]["thumb"] = blob.blob
blob = client.upload_blob(image_bytes) client.send_post(text=message, embed=embed, facets=facets)
embed["external"]["thumb"] = blob.blob # Automatisch ersetzt logger.info(f"✅ Posted to Bluesky with preview.")
client.send_post(text=text, embed=embed)
logger.info("Posted with OG preview.")
return return
except Exception as e: except Exception as e:
logger.error(f"Error uploading OG preview: {e}") logger.error(f"Error uploading preview to Bluesky: {e}")
# Fallback: Nur Text + Link client.send_post(text=message, facets=facets)
client.send_post(f"{text}\n{link}") logger.info(f"💡 Posted to Bluesky without preview.")
logger.info("Posted without OG preview.")
def extract_post_date(entry):
date_fields = [entry.get(k) for k in ("published", "updated", "date_published", "date_modified", "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"⚠️ Could not parse date: {d} ({e})")
return min(dates) if dates else datetime.now(timezone.utc)
def main(): def main():
seen_ids = load_seen_ids() seen_ids = load_seen_ids()
feed = feedparser.parse(FEED_URL) feed = feedparser.parse(FEED_URL)
now = datetime.now(timezone.utc)
max_age = timedelta(days=MAX_POST_AGE_DAYS)
for entry in feed.entries: for entry in feed.entries:
post_id = entry.get("id") or entry.get("link") post_id = entry.get("id") or entry.get("link")
if post_id in seen_ids: if post_id in seen_ids:
continue continue
post_date = extract_post_date(entry)
if post_date < now - max_age:
logger.info(f"⏩ Skipping old post ({MAX_POST_AGE_DAYS}+ days): {post_id}")
continue
title = entry.get("title", "").strip() title = entry.get("title", "").strip()
link = entry.get("link", "").strip() link = entry.get("link", "").strip()
message = link # Link alleine posten für Mastodon OG-Vorschau
logger.info(f"New post: {title}") def sanitize_tag(tag):
tag = tag.lower()
tag = unicodedata.normalize("NFKD", tag).encode("ascii", "ignore").decode("ascii")
tag = re.sub(r"\W+", "", tag)
return tag
tags = []
if "tags" in entry:
raw_tags = [tag.get("term") if isinstance(tag, dict) else getattr(tag, "term", None) for tag in entry.tags]
tags = [sanitize_tag(t) for t in raw_tags if t]
logger.info(f"💡 New post found: {title}")
try: try:
post_to_mastodon(message) if POST_TARGETS in ("mastodon", "both"):
time.sleep(2) post_to_mastodon(title, link, tags)
post_to_bluesky(message, link) time.sleep(2)
if POST_TARGETS in ("bluesky", "both"):
post_to_bluesky(title, link, tags)
save_seen_id(post_id) save_seen_id(post_id)
logger.info("Successfully posted.") logger.info(f"✅ Post successfully published.")
if should_send_email(on_success=True): if should_send_email(on_success=True):
email_subject = f"✅ Erfolgreich gepostet: {title}" send_status_email(f"✅ Post published: {title}", generate_email_html("success", title, link))
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: except Exception as e:
logger.error(f"Error posting: {e}") logger.error(f"❌ Posting failed: {e}")
if should_send_email(on_success=False): if should_send_email(on_success=False):
email_subject = f"❌ Fehler beim Posten: {title}" send_status_email(f"❌ Error posting: {title}", generate_email_html("error", title, link, str(e)))
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) time.sleep(5)
if __name__ == "__main__": 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.") logger.info(f"🔁 Starting feed check every {INTERVAL_MINUTES} minutes.")
start_health_server()
start_health_server() # HTTP-Healthcheck starten
while True: while True:
try: try:
main() main()
except Exception as e: except Exception as e:
logger.error(f"Error in main execution: {e}") logger.error(f"Unhandled error during execution: {e}")
logger.info(f"Wait {INTERVAL_MINUTES} minutes until next execution...") logger.info(f"Waiting {INTERVAL_MINUTES} minutes until next run...")
time.sleep(INTERVAL_MINUTES * 60) time.sleep(INTERVAL_MINUTES * 60)

6
env
View File

@ -9,9 +9,15 @@ MASTODON_ACCESS_TOKEN=your_mastodon_access_token
BSKY_IDENTIFIER=your_handle.bsky.social BSKY_IDENTIFIER=your_handle.bsky.social
BSKY_PASSWORD=your_bluesky_password BSKY_PASSWORD=your_bluesky_password
# mögliche Werte: mastodon, bluesky, both
POST_TARGETS=both
# Intervall in Minuten für Feedprüfung # Intervall in Minuten für Feedprüfung
INTERVAL_MINUTES=30 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 # E-Mail Einstellungen
SMTP_HOST=smtp.example.com SMTP_HOST=smtp.example.com
SMTP_PORT=587 SMTP_PORT=587

View File

@ -5,3 +5,4 @@ python-dotenv
beautifulsoup4 beautifulsoup4
python-dateutil python-dateutil
requests requests