From 50f29cc9481803b8f6c5d0ce25e79ac0d99dee69 Mon Sep 17 00:00:00 2001 From: Oliver G Date: Fri, 24 Jul 2026 10:17:03 +0200 Subject: [PATCH] Fix publish scheduling and slot spacing --- backend/.env.example | 10 ++- backend/app/config.py | 5 +- backend/app/scheduler.py | 94 ++++++++++++++++++++-------- backend/tests/test_scheduler.py | 106 ++++++++++++++++++++++++++++++++ docs/AUTOMATION.md | 11 +++- 5 files changed, 193 insertions(+), 33 deletions(-) create mode 100644 backend/tests/test_scheduler.py diff --git a/backend/.env.example b/backend/.env.example index c2dd235..00e6782 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,7 +39,11 @@ N8N_API_KEY=replace-with-strong-random-key PIPELINE_RELEVANCE_AUTO=80 # Relevanz-Score >= dieser Wert, aber < AUTO: Telegram-Warnung senden PIPELINE_RELEVANCE_WARN=60 -# Maximale Drafts/Veröffentlichungen pro Tag -PIPELINE_MAX_DRAFTS_PER_DAY=2 -# Bevorzugte Veröffentlichungszeiten (Stunden, kommagetrennt, CET) +# Maximale Veröffentlichungen pro Tag innerhalb des Fensters unten +PIPELINE_MAX_DRAFTS_PER_DAY=3 +# Bevorzugte Veröffentlichungszeiten (Stunden, kommagetrennt, Europe/Berlin) PIPELINE_PUBLISH_HOURS=9,14 +# Frühester/spätester Slot sowie Mindestabstand für zusätzliche Slots +PIPELINE_PUBLISH_START_HOUR=9 +PIPELINE_PUBLISH_END_HOUR=19 +PIPELINE_PUBLISH_MIN_GAP_HOURS=3 diff --git a/backend/app/config.py b/backend/app/config.py index 24c3902..685b6d7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -44,8 +44,11 @@ class Settings(BaseSettings): # Pipeline behaviour pipeline_relevance_auto: int = 80 # >= this: auto-process pipeline_relevance_warn: int = 60 # >= this: Telegram warning, else reject - pipeline_max_drafts_per_day: int = 2 + pipeline_max_drafts_per_day: int = 3 pipeline_publish_hours: str = "9,14" # comma-separated preferred publish hours (CET) + pipeline_publish_start_hour: int = 9 + pipeline_publish_end_hour: int = 19 + pipeline_publish_min_gap_hours: int = 3 pipeline_min_words_raw: int = 120 # minimum words in raw content before rewrite (else reject) pipeline_min_words_rewritten: int = 150 # minimum words in rewritten content (else reject) pipeline_max_article_age_days: int = 7 # skip articles older than N days during ingestion (0 = no limit) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index d5ea5bf..20f54d3 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -2,8 +2,9 @@ Calculates suggested publish slots for new WordPress drafts. Rules: -- Maximum N drafts per day (configurable, default 2) -- Preferred slots: configurable hours (default 09:00 and 14:00 CET) +- Preferred slots: configurable hours (default 09:00 and 14:00 CET/CEST) +- When the queue grows, additional slots are inserted with a minimum gap +- Scheduling only happens between the configured start/end hours - New articles queue up after the last already-scheduled article - Checks both local DB AND WordPress future posts to avoid double-booking """ @@ -13,8 +14,9 @@ import base64 import json import threading import urllib.request -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timedelta from typing import Any +from zoneinfo import ZoneInfo from .config import get_settings from .db import get_conn @@ -22,21 +24,53 @@ from .db import get_conn # Ensures that concurrent pipeline runs (two threads) never assign the same slot. _slot_lock = threading.Lock() - -# CET offset (UTC+1 winter / UTC+2 summer – fixed +1 for simplicity) -_CET_OFFSET = timedelta(hours=1) +_BERLIN_TZ = ZoneInfo("Europe/Berlin") def _today_cet() -> date: - return (datetime.now(timezone.utc) + _CET_OFFSET).date() + return datetime.now(_BERLIN_TZ).date() + + +def _schedule_bounds() -> tuple[int, int, int]: + settings = get_settings() + start_hour = max(0, min(23, int(settings.pipeline_publish_start_hour))) + end_hour = max(start_hour, min(23, int(settings.pipeline_publish_end_hour))) + min_gap = max(1, int(settings.pipeline_publish_min_gap_hours)) + return start_hour, end_hour, min_gap def _preferred_hours() -> list[int]: settings = get_settings() + start_hour, end_hour, min_gap = _schedule_bounds() + max_per_day = max(1, int(settings.pipeline_max_drafts_per_day)) + try: - return [int(h.strip()) for h in settings.pipeline_publish_hours.split(",") if h.strip()] + preferred = [int(h.strip()) for h in settings.pipeline_publish_hours.split(",") if h.strip()] except Exception: - return [9, 14] + preferred = [9, 14] + + anchors: list[int] = [] + seen: set[int] = set() + for hour in preferred: + if hour < start_hour or hour > end_hour or hour in seen: + continue + anchors.append(hour) + seen.add(hour) + + if not anchors: + anchors = [start_hour] + + candidates = anchors.copy() + for hour in range(start_hour, end_hour + 1): + if hour in seen: + continue + if all(abs(hour - existing) >= min_gap for existing in candidates): + candidates.append(hour) + seen.add(hour) + if len(candidates) >= max_per_day: + break + + return candidates[:max_per_day] def _fetch_wp_occupied_slots() -> set[tuple[str, int]]: @@ -51,20 +85,25 @@ def _fetch_wp_occupied_slots() -> set[tuple[str, int]]: auth = base64.b64encode( f"{settings.wordpress_username}:{settings.wordpress_app_password}".encode() ).decode() - url = ( - f"{settings.wordpress_base_url}/wp-json/wp/v2/posts" - f"?status=future&per_page=100&orderby=date&order=asc&_fields=id,date" - ) - req = urllib.request.Request(url, headers={"Authorization": f"Basic {auth}"}) - with urllib.request.urlopen(req, timeout=10) as resp: - posts = json.loads(resp.read()) occupied: set[tuple[str, int]] = set() - for p in posts: - try: - dt = datetime.fromisoformat(p["date"]) - occupied.add((dt.date().isoformat(), dt.hour)) - except Exception: - pass + for page in range(1, 21): + url = ( + f"{settings.wordpress_base_url}/wp-json/wp/v2/posts" + f"?status=future&per_page=100&page={page}&orderby=date&order=asc&_fields=id,date" + ) + req = urllib.request.Request(url, headers={"Authorization": f"Basic {auth}"}) + with urllib.request.urlopen(req, timeout=10) as resp: + posts = json.loads(resp.read()) + if not isinstance(posts, list) or not posts: + break + for p in posts: + try: + dt = datetime.fromisoformat(p["date"]) + occupied.add((dt.date().isoformat(), dt.hour)) + except Exception: + pass + if len(posts) < 100: + break return occupied except Exception: return set() @@ -109,8 +148,9 @@ def _get_last_future_scheduled_date(wp_occupied: set[tuple[str, int]]) -> date | def _next_free_hour(target_date: date, wp_occupied: set[tuple[str, int]]) -> int | None: - """Return first preferred hour not yet used on target_date (DB + WP), or None if day is full.""" + """Return first allowed hour with enough spacing to existing slots, or None if day is full.""" hours = _preferred_hours() + _, _, min_gap = _schedule_bounds() date_str = target_date.isoformat() # Hours used in local DB @@ -138,7 +178,7 @@ def _next_free_hour(target_date: date, wp_occupied: set[tuple[str, int]]) -> int used_hours.add(h) for h in hours: - if h not in used_hours: + if all(abs(h - used_hour) >= min_gap for used_hour in used_hours): return h return None @@ -166,7 +206,8 @@ def _find_next_free_slot( if hour is not None: return candidate, hour - return tomorrow, _preferred_hours()[0] if _preferred_hours() else 9 + hours = _preferred_hours() + return tomorrow, hours[0] if hours else 9 def get_schedule_overview(lookahead_days: int = 60) -> list[dict]: @@ -252,7 +293,8 @@ def suggest_publish_slot() -> str: d, hour = result return _format_slot(d, hour) tomorrow = _today_cet() + timedelta(days=1) - return _format_slot(tomorrow, _preferred_hours()[0] if _preferred_hours() else 9) + hours = _preferred_hours() + return _format_slot(tomorrow, hours[0] if hours else 9) def reserve_publish_slot(article_id: int) -> str: diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py new file mode 100644 index 0000000..ecd079b --- /dev/null +++ b/backend/tests/test_scheduler.py @@ -0,0 +1,106 @@ +import json +import os +import tempfile +import unittest +from unittest.mock import patch + +from backend.app import config as config_module +from backend.app.db import get_conn, init_db +from backend.app.scheduler import _fetch_wp_occupied_slots, _preferred_hours, reserve_publish_slot + + +class _MockResponse: + def __init__(self, payload): + self._payload = json.dumps(payload).encode("utf-8") + + def read(self): + return self._payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class TestScheduler(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + os.environ["APP_DB_PATH"] = os.path.join(self.tmp_dir.name, "scheduler.db") + os.environ["WORDPRESS_BASE_URL"] = "https://example.org" + os.environ["WORDPRESS_USERNAME"] = "wp-user" + os.environ["WORDPRESS_APP_PASSWORD"] = "wp-pass" + config_module.get_settings.cache_clear() + init_db() + + def tearDown(self) -> None: + config_module.get_settings.cache_clear() + for key in ( + "APP_DB_PATH", + "WORDPRESS_BASE_URL", + "WORDPRESS_USERNAME", + "WORDPRESS_APP_PASSWORD", + "PIPELINE_PUBLISH_HOURS", + "PIPELINE_PUBLISH_START_HOUR", + "PIPELINE_PUBLISH_END_HOUR", + "PIPELINE_PUBLISH_MIN_GAP_HOURS", + ): + os.environ.pop(key, None) + self.tmp_dir.cleanup() + + def _insert_article(self, idx: int) -> int: + with get_conn() as conn: + cur = conn.execute( + """ + INSERT INTO articles (title, source_url, status) + VALUES (?, ?, 'approved') + """, + (f"Artikel {idx}", f"https://example.org/article/{idx}"), + ) + return int(cur.lastrowid) + + def test_preferred_hours_expand_with_gap_and_window(self) -> None: + self.assertEqual(_preferred_hours(), [9, 14, 17]) + + @patch("backend.app.scheduler._today_cet") + @patch("backend.app.scheduler._fetch_wp_occupied_slots", return_value=set()) + def test_reserve_publish_slot_uses_overflow_hours(self, _mock_wp, mock_today) -> None: + from datetime import date + + mock_today.return_value = date(2026, 7, 24) + article_ids = [self._insert_article(idx) for idx in range(1, 5)] + + slots = [] + for article_id in article_ids: + reserve_publish_slot(article_id) + with get_conn() as conn: + row = conn.execute( + "SELECT scheduled_publish_at FROM articles WHERE id = ?", + (article_id,), + ).fetchone() + slots.append(row["scheduled_publish_at"]) + + self.assertEqual( + slots, + [ + "2026-07-25T09:00:00", + "2026-07-25T14:00:00", + "2026-07-25T17:00:00", + "2026-07-26T09:00:00", + ], + ) + + @patch("backend.app.scheduler.urllib.request.urlopen") + def test_fetch_wp_occupied_slots_reads_multiple_pages(self, mock_urlopen) -> None: + page1 = [{"id": idx, "date": f"2026-08-{(idx % 28) + 1:02d}T09:00:00"} for idx in range(1, 101)] + page2 = [{"id": 101, "date": "2026-10-31T17:00:00"}] + mock_urlopen.side_effect = [_MockResponse(page1), _MockResponse(page2)] + + occupied = _fetch_wp_occupied_slots() + + self.assertIn(("2026-10-31", 17), occupied) + self.assertEqual(len(occupied), 29) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 8008857..b3c9f30 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -135,16 +135,21 @@ PIPELINE_RELEVANCE_WARN=60 ## Veröffentlichungsplan -- Maximal **2 Beiträge pro Tag** -- Bevorzugte Zeiten: **09:00 und 14:00 Uhr** (CET) +- Standardmäßig **09:00 und 14:00 Uhr**, bei hohem Backlog zusätzlich weitere Slots +- Maximal **3 Beiträge pro Tag** im Standard-Setup +- Zusätzliche Slots nur mit **mindestens 3 Stunden Abstand** +- Veröffentlichungsfenster: **09:00 bis 19:00 Uhr** (`Europe/Berlin`) - Gleichmäßig über die Woche verteilt - Der Vorschlag erscheint in der Telegram-Nachricht - Manuell in WordPress setzen oder über WP Scheduling-Plugin automatisieren Einstellbar via: ``` -PIPELINE_MAX_DRAFTS_PER_DAY=2 +PIPELINE_MAX_DRAFTS_PER_DAY=3 PIPELINE_PUBLISH_HOURS=9,14 +PIPELINE_PUBLISH_START_HOUR=9 +PIPELINE_PUBLISH_END_HOUR=19 +PIPELINE_PUBLISH_MIN_GAP_HOURS=3 ``` ---