Fix publish scheduling and slot spacing
This commit is contained in:
parent
f710141828
commit
50f29cc948
5 changed files with 193 additions and 33 deletions
|
|
@ -39,7 +39,11 @@ N8N_API_KEY=replace-with-strong-random-key
|
||||||
PIPELINE_RELEVANCE_AUTO=80
|
PIPELINE_RELEVANCE_AUTO=80
|
||||||
# Relevanz-Score >= dieser Wert, aber < AUTO: Telegram-Warnung senden
|
# Relevanz-Score >= dieser Wert, aber < AUTO: Telegram-Warnung senden
|
||||||
PIPELINE_RELEVANCE_WARN=60
|
PIPELINE_RELEVANCE_WARN=60
|
||||||
# Maximale Drafts/Veröffentlichungen pro Tag
|
# Maximale Veröffentlichungen pro Tag innerhalb des Fensters unten
|
||||||
PIPELINE_MAX_DRAFTS_PER_DAY=2
|
PIPELINE_MAX_DRAFTS_PER_DAY=3
|
||||||
# Bevorzugte Veröffentlichungszeiten (Stunden, kommagetrennt, CET)
|
# Bevorzugte Veröffentlichungszeiten (Stunden, kommagetrennt, Europe/Berlin)
|
||||||
PIPELINE_PUBLISH_HOURS=9,14
|
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
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,11 @@ class Settings(BaseSettings):
|
||||||
# Pipeline behaviour
|
# Pipeline behaviour
|
||||||
pipeline_relevance_auto: int = 80 # >= this: auto-process
|
pipeline_relevance_auto: int = 80 # >= this: auto-process
|
||||||
pipeline_relevance_warn: int = 60 # >= this: Telegram warning, else reject
|
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_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_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_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)
|
pipeline_max_article_age_days: int = 7 # skip articles older than N days during ingestion (0 = no limit)
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
Calculates suggested publish slots for new WordPress drafts.
|
Calculates suggested publish slots for new WordPress drafts.
|
||||||
Rules:
|
Rules:
|
||||||
- Maximum N drafts per day (configurable, default 2)
|
- Preferred slots: configurable hours (default 09:00 and 14:00 CET/CEST)
|
||||||
- Preferred slots: configurable hours (default 09:00 and 14:00 CET)
|
- 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
|
- New articles queue up after the last already-scheduled article
|
||||||
- Checks both local DB AND WordPress future posts to avoid double-booking
|
- Checks both local DB AND WordPress future posts to avoid double-booking
|
||||||
"""
|
"""
|
||||||
|
|
@ -13,8 +14,9 @@ import base64
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import get_conn
|
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.
|
# Ensures that concurrent pipeline runs (two threads) never assign the same slot.
|
||||||
_slot_lock = threading.Lock()
|
_slot_lock = threading.Lock()
|
||||||
|
|
||||||
|
_BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||||||
# CET offset (UTC+1 winter / UTC+2 summer – fixed +1 for simplicity)
|
|
||||||
_CET_OFFSET = timedelta(hours=1)
|
|
||||||
|
|
||||||
|
|
||||||
def _today_cet() -> date:
|
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]:
|
def _preferred_hours() -> list[int]:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
start_hour, end_hour, min_gap = _schedule_bounds()
|
||||||
|
max_per_day = max(1, int(settings.pipeline_max_drafts_per_day))
|
||||||
|
|
||||||
try:
|
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:
|
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]]:
|
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(
|
auth = base64.b64encode(
|
||||||
f"{settings.wordpress_username}:{settings.wordpress_app_password}".encode()
|
f"{settings.wordpress_username}:{settings.wordpress_app_password}".encode()
|
||||||
).decode()
|
).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()
|
occupied: set[tuple[str, int]] = set()
|
||||||
for p in posts:
|
for page in range(1, 21):
|
||||||
try:
|
url = (
|
||||||
dt = datetime.fromisoformat(p["date"])
|
f"{settings.wordpress_base_url}/wp-json/wp/v2/posts"
|
||||||
occupied.add((dt.date().isoformat(), dt.hour))
|
f"?status=future&per_page=100&page={page}&orderby=date&order=asc&_fields=id,date"
|
||||||
except Exception:
|
)
|
||||||
pass
|
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
|
return occupied
|
||||||
except Exception:
|
except Exception:
|
||||||
return set()
|
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:
|
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()
|
hours = _preferred_hours()
|
||||||
|
_, _, min_gap = _schedule_bounds()
|
||||||
date_str = target_date.isoformat()
|
date_str = target_date.isoformat()
|
||||||
|
|
||||||
# Hours used in local DB
|
# 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)
|
used_hours.add(h)
|
||||||
|
|
||||||
for h in hours:
|
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 h
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -166,7 +206,8 @@ def _find_next_free_slot(
|
||||||
if hour is not None:
|
if hour is not None:
|
||||||
return candidate, hour
|
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]:
|
def get_schedule_overview(lookahead_days: int = 60) -> list[dict]:
|
||||||
|
|
@ -252,7 +293,8 @@ def suggest_publish_slot() -> str:
|
||||||
d, hour = result
|
d, hour = result
|
||||||
return _format_slot(d, hour)
|
return _format_slot(d, hour)
|
||||||
tomorrow = _today_cet() + timedelta(days=1)
|
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:
|
def reserve_publish_slot(article_id: int) -> str:
|
||||||
|
|
|
||||||
106
backend/tests/test_scheduler.py
Normal file
106
backend/tests/test_scheduler.py
Normal 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()
|
||||||
|
|
@ -135,16 +135,21 @@ PIPELINE_RELEVANCE_WARN=60
|
||||||
|
|
||||||
## Veröffentlichungsplan
|
## Veröffentlichungsplan
|
||||||
|
|
||||||
- Maximal **2 Beiträge pro Tag**
|
- Standardmäßig **09:00 und 14:00 Uhr**, bei hohem Backlog zusätzlich weitere Slots
|
||||||
- Bevorzugte Zeiten: **09:00 und 14:00 Uhr** (CET)
|
- 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
|
- Gleichmäßig über die Woche verteilt
|
||||||
- Der Vorschlag erscheint in der Telegram-Nachricht
|
- Der Vorschlag erscheint in der Telegram-Nachricht
|
||||||
- Manuell in WordPress setzen oder über WP Scheduling-Plugin automatisieren
|
- Manuell in WordPress setzen oder über WP Scheduling-Plugin automatisieren
|
||||||
|
|
||||||
Einstellbar via:
|
Einstellbar via:
|
||||||
```
|
```
|
||||||
PIPELINE_MAX_DRAFTS_PER_DAY=2
|
PIPELINE_MAX_DRAFTS_PER_DAY=3
|
||||||
PIPELINE_PUBLISH_HOURS=9,14
|
PIPELINE_PUBLISH_HOURS=9,14
|
||||||
|
PIPELINE_PUBLISH_START_HOUR=9
|
||||||
|
PIPELINE_PUBLISH_END_HOUR=19
|
||||||
|
PIPELINE_PUBLISH_MIN_GAP_HOURS=3
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue