10 Commits

Author SHA1 Message Date
dbcb624abf chore(base): Release 0.9.8
All checks were successful
Build and Push Docker Image on Tag / build_and_push (push) Successful in 30s
Create Release / release (push) Successful in 8s
2025-06-02 20:07:12 +02:00
b20103fbc3 test(app): Debugging features added and README.md extended 2025-06-02 19:53:09 +02:00
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
9 changed files with 374 additions and 89 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/.gitkeep
# Config & meta
CHANGELOG.md
ENVIRONMENT.md
# IDEs / Editor
.vscode/
.idea/
.DS_Store

View File

@ -1,5 +1,5 @@
FROM python:3.11-slim
LABEL version="0.9.0"
LABEL version="0.9.8"
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

View File

@ -2,7 +2,19 @@
**BlueMastoFeed** ist ein Docker-basiertes Tool, das regelmäßig einen RSS-Feed ausliest und neue Beiträge automatisch auf **Mastodon** und **Bluesky** veröffentlicht.
Es prüft, ob ein Beitrag bereits gepostet wurde, und speichert diese Information lokal in einer Datei (`/data/seen_posts.txt`). Optional werden OpenGraph-Daten (Titel, Vorschau-Bild etc.) der verlinkten Seiten extrahiert, um die Posts ansprechender zu gestalten.
## 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
@ -87,6 +99,7 @@ Die folgenden Umgebungsvariablen steuern das Verhalten des Containers. Sie könn
| ----------------------- | ------------------------------------------------------------ | -------------------------- | -------------- |
| `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_ |
@ -99,3 +112,5 @@ Die folgenden Umgebungsvariablen steuern das Verhalten des Containers. Sie könn
| `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_ |
| `LOG_LEVEL` | Logging-Level: `DEBUG`, `INFO`, `WARNING`, `ERROR` | `DEBUG` | `ÌNFO` |

View File

@ -1,11 +1,12 @@
import os
import time
import feedparser
import json
import logging
import requests
import threading
import smtplib
import re
import unicodedata
from bs4 import BeautifulSoup
from io import BytesIO
from mastodon import Mastodon
@ -17,7 +18,10 @@ from email.mime.multipart import MIMEMultipart
from dateutil import parser as date_parser
from datetime import datetime, timezone, timedelta
# Load environment variables
load_dotenv()
# Configuration
FEED_URL = os.getenv("FEED_URL")
SEEN_POSTS_FILE = "/data/seen_posts.txt"
MASTODON_BASE_URL = os.getenv("MASTODON_API_BASE_URL")
@ -25,47 +29,90 @@ 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()
# Logger setup
logger = logging.getLogger()
logger.setLevel(logging.INFO)
log_level = os.getenv("LOG_LEVEL", "INFO").upper() # Enable DEBUG level via env variable
logger.setLevel(getattr(logging, log_level, logging.INFO))
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
# Configuration overview (INFO level)
logger.info(f"📡 Feed URL: {FEED_URL}")
logger.info(f"📤 Posting targets: {POST_TARGETS}")
logger.info(f"🕒 Max post age: {MAX_POST_AGE_DAYS} days")
logger.info(f"📨 Email mode: {os.getenv('EMAIL_MODE', 'errors')}")
logger.debug(f"🛠 Full environment variables: {dict(os.environ)}") # DEBUG: All environment variables
# Healthcheck server 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)
self.end_headers()
self.wfile.write(b"OK")
logger.debug("🏥 Healthcheck requested and responded OK.") # DEBUG Healthcheck log
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
"""Suppress default HTTP request logging."""
# 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 is running on port 8000.")
logger.info(f"Healthcheck server running on port 8000.")
# Email helper functions
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()
return (mode == "all") or (mode == "errors" and not on_success)
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 Details:</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):
"""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,6 +121,8 @@ def send_status_email(subject, html_content):
email_from = os.getenv("EMAIL_FROM")
email_to = os.getenv("EMAIL_TO")
logger.debug(f"📧 Preparing to send email to {email_to} with subject: {subject}") # DEBUG
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = email_from
@ -84,35 +133,69 @@ def send_status_email(subject, html_content):
server.starttls()
server.login(smtp_user, smtp_password)
server.sendmail(email_from, email_to, msg.as_string())
logger.info("Status email sent.")
except Exception as e:
logger.error(f"Error sending status email: {e}")
logger.info(f"✅ Status email sent successfully.")
except Exception:
logger.exception(f"❌ Error sending email:") # Full stack trace on error
# 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
# Extract hashtags
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}]
})
# Extract links
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}]
})
logger.debug(f"🏷 Extracted facets: {facets}") # DEBUG
return facets
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):
open(SEEN_POSTS_FILE, "w").close()
with open(SEEN_POSTS_FILE, "r") as f:
return set(line.strip() for line in f)
seen = set(line.strip() for line in f)
logger.debug(f"🗂 Loaded {len(seen)} seen post IDs.") # DEBUG
return seen
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")
logger.debug(f"📝 Saved post ID as seen: {post_id}") # DEBUG
def post_to_mastodon(message):
"""Posts a message to Mastodon."""
def post_to_mastodon(title, link, tags):
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}"
logger.debug(f"🐘 Posting to Mastodon: {message}") # DEBUG
mastodon.toot(message)
logger.info("✅ Posted to Mastodon.")
def fetch_og_data(url):
"""Fetches Open Graph title and image URL from a web page."""
try:
logger.debug(f"🔍 Fetching OpenGraph data from {url}") # DEBUG
resp = requests.get(url, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
@ -120,63 +203,54 @@ def fetch_og_data(url):
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
if not title or not image_url:
logger.debug(f"⚠️ OpenGraph data incomplete. Title: {title}, Image: {image_url}") # DEBUG
return title, image_url
except Exception as e:
logger.error(f"Error loading OG data: {e}")
except Exception:
logger.exception(f"Error fetching OpenGraph data:")
return None, None
def post_to_bluesky(message, link):
"""Posts a message and optional preview to Bluesky."""
def post_to_bluesky(title, link, tags):
client = Client()
logger.debug(f"🔑 Logging in to Bluesky as {BSKY_HANDLE}") # DEBUG
client.login(BSKY_HANDLE, BSKY_PASSWORD)
title, image_url = fetch_og_data(link)
text = title or message
hashtags = " ".join(f"#{tag}" for tag in tags) if tags else ""
message = f"{title}\n\n{link}"
if hashtags:
message += f"\n\n{hashtags}"
if title and image_url:
try:
facets = extract_facets_utf8(message)
try:
og_title, image_url = fetch_og_data(link)
if og_title and image_url:
embed = {
"$type": "app.bsky.embed.external",
"external": {
"uri": link,
"title": title,
"description": "",
"thumb": {
"$type": "blob",
"ref": None,
"mimeType": "",
"size": 0
}
"thumb": {"$type": "blob", "ref": None, "mimeType": "", "size": 0}
}
}
logger.debug(f"📸 Attempting to upload preview image from: {image_url}") # DEBUG
img_resp = requests.get(image_url, timeout=10)
img_resp.raise_for_status()
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.")
client.send_post(text=message, embed=embed, facets=facets)
logger.info(f"✅ Posted to Bluesky with preview.")
return
except Exception as e:
logger.error(f"Error uploading OG preview: {e}")
client.send_post(f"{text}\n{link}")
logger.info("Posted without preview.")
except Exception:
logger.exception(f"Error uploading preview to Bluesky:")
client.send_post(text=message, facets=facets)
logger.info(f"💡 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")
]
date_fields = [entry.get(k) for k in ("published", "updated", "date_published", "date_modified", "pubDate")]
dates = []
for d in date_fields:
if d:
try:
@ -185,74 +259,80 @@ def extract_post_date(entry):
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)
logger.warning(f"⚠️ Could not parse date: {d} ({e})")
chosen_date = min(dates) if dates else datetime.now(timezone.utc)
logger.debug(f"📅 Extracted post date: {chosen_date}") # DEBUG
return chosen_date
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)
logger.debug(f"📰 Number of feed entries found: {len(feed.entries)}") # DEBUG
for entry in feed.entries:
post_id = entry.get("id") or entry.get("link")
logger.debug(f"🆔 Checking post ID: {post_id}") # DEBUG
if post_id in seen_ids:
logger.debug(f"⏭️ Post already processed: {post_id}") # DEBUG
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
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.debug(f"🏷 Extracted tags: {tags}") # DEBUG
logger.info(f"💡 New post found: {title}")
try:
post_to_mastodon(message)
time.sleep(2)
post_to_bluesky(message, link)
if POST_TARGETS in ("mastodon", "both"):
post_to_mastodon(title, link, tags)
time.sleep(2)
if POST_TARGETS in ("bluesky", "both"):
post_to_bluesky(title, link, tags)
save_seen_id(post_id)
logger.info("Successfully posted.")
logger.info(f"Post successfully published.")
if should_send_email(on_success=True):
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>"
)
send_status_email(f"✅ Post published: {title}", generate_email_html("success", title, link))
except Exception as e:
logger.error(f"Error posting: {e}")
except Exception:
logger.exception(f"Posting failed for post: {post_id}")
if should_send_email(on_success=False):
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>"
)
send_status_email(f"❌ Error posting: {title}", generate_email_html("error", title, link, str(Exception)))
time.sleep(5)
if __name__ == "__main__":
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()
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...")
except Exception:
logger.exception("Unhandled error during execution:")
logger.info(f"Waiting {INTERVAL_MINUTES} minutes until next run...")
time.sleep(INTERVAL_MINUTES * 60)

3
env
View File

@ -9,6 +9,9 @@ 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

View File

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