Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
7fe2a1de00
|
|||
8f5813b39c
|
|||
17357da659
|
|||
e4fc11405a
|
|||
6b52107afa
|
|||
539a8abf28
|
38
.chglog/CHANGELOG.tpl.md
Executable file
38
.chglog/CHANGELOG.tpl.md
Executable 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
37
.chglog/config.yml
Executable 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
100
.gitea/workflows/release.yml
Executable 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
9
.gitignore
vendored
@ -2,3 +2,12 @@
|
|||||||
data/*
|
data/*
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
|
||||||
|
# Config & meta
|
||||||
|
CHANGELOG.md
|
||||||
|
ENVIRONMENT.md
|
||||||
|
|
||||||
|
# IDEs / Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
LABEL version="0.9.0"
|
LABEL version="0.9.6"
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
89
README.md
89
README.md
@ -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_ |
|
||||||
|
216
bluemastofeed.py
216
bluemastofeed.py
@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@ -14,20 +13,23 @@ 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_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
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 = 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)
|
||||||
@ -43,22 +45,58 @@ 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.")
|
||||||
|
|
||||||
|
|
||||||
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
|
def generate_email_html(status: str, title: str, link: str, error_message: str = None) -> str:
|
||||||
if mode == "errors" and not on_success:
|
color = "#2e7d32" if status == "success" else "#d32f2f"
|
||||||
return True
|
bg_color = "#f5f5f5" if status == "success" else "#fff3f3"
|
||||||
return False
|
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):
|
||||||
@ -74,35 +112,40 @@ 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.")
|
|
||||||
|
logger.info(f"✅ Status email sent successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fehler beim Senden der E-Mail: {e}")
|
logger.error(f"❌ Error sending email: {e}")
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
def save_seen_id(post_id):
|
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):
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, timeout=10)
|
resp = requests.get(url, timeout=10)
|
||||||
@ -114,9 +157,10 @@ 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(message, link):
|
||||||
client = Client()
|
client = Client()
|
||||||
client.login(BSKY_HANDLE, BSKY_PASSWORD)
|
client.login(BSKY_HANDLE, BSKY_PASSWORD)
|
||||||
@ -131,99 +175,129 @@ def post_to_bluesky(message, link):
|
|||||||
"external": {
|
"external": {
|
||||||
"uri": link,
|
"uri": link,
|
||||||
"title": title,
|
"title": title,
|
||||||
"description": "", # Optional: Beschreibung kannst du per OG:description holen
|
"description": "",
|
||||||
"thumb": {
|
"thumb": {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"ref": None, # Wird vom Upload ersetzt
|
"ref": None,
|
||||||
"mimeType": "", # Wird vom Upload ersetzt
|
"mimeType": "",
|
||||||
"size": 0 # Wird vom Upload ersetzt
|
"size": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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)
|
|
||||||
embed["external"]["thumb"] = blob.blob # Automatisch ersetzt
|
|
||||||
|
|
||||||
client.send_post(text=text, embed=embed)
|
client.send_post(text=text, embed=embed)
|
||||||
logger.info("Posted with OG preview.")
|
logger.info(f"✅ Posted to Bluesky with 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(f"{text}\n{link}")
|
client.send_post(f"{text}\n{link}")
|
||||||
logger.info("Posted without OG preview.")
|
logger.info(f"💡 Posted to Bluesky without preview.")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_post_date(entry):
|
||||||
|
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"⚠️ 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)
|
||||||
|
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 ({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}")
|
tags = []
|
||||||
|
if "tags" in entry:
|
||||||
|
tags = [tag["term"] for tag in entry.tags if "term" in tag]
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
hashtags = " ".join(f"#{tag}" for tag in tags)
|
||||||
|
message = f"{link} {hashtags}"
|
||||||
|
else:
|
||||||
|
message = link
|
||||||
|
|
||||||
|
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(f"{title}\n{link}", link)
|
||||||
|
|
||||||
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(
|
||||||
email_body = f"""
|
f"✅ Post published: {title}",
|
||||||
<html>
|
generate_email_html("success", title, link)
|
||||||
<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(
|
||||||
email_body = f"""
|
f"❌ Error posting: {title}",
|
||||||
<html>
|
generate_email_html("error", title, link, str(e))
|
||||||
<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__":
|
|
||||||
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
|
if __name__ == "__main__":
|
||||||
|
INTERVAL_MINUTES = int(os.getenv("INTERVAL_MINUTES", 30))
|
||||||
|
logger.info(f"🔁 Starting feed check every {INTERVAL_MINUTES} minutes.")
|
||||||
|
|
||||||
|
start_health_server()
|
||||||
|
|
||||||
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
6
env
@ -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
|
||||||
|
@ -5,3 +5,4 @@ python-dotenv
|
|||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
python-dateutil
|
python-dateutil
|
||||||
requests
|
requests
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user