Fix publish scheduling and slot spacing
Some checks are pending
🚀 Deploy to Hetzner / deploy (push) Waiting to run
Backend Tests / backend-tests (push) Waiting to run

This commit is contained in:
Oliver 2026-07-24 10:17:03 +02:00
parent f710141828
commit 50f29cc948
No known key found for this signature in database
5 changed files with 193 additions and 33 deletions

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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()