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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue