feat(wordpress): set post category from title and tag rules

Published articles carried no category, so WordPress filed all of them
under the catch-all "Allgemein" - 941 of 955 posts by the time this was
noticed. The rules here mirror the one-off backfill of 2026-07-31 and
reproduce its result on all 955 posts exactly.

Matching is done on word boundaries rather than plain substrings, which
otherwise filed a campsite in Klagenfurt under "Recht & Vorschriften" via
the keyword "klage". German compounds opt in explicitly with a trailing
"*". A clear discount signal takes precedence over the product topic,
because product tags otherwise outnumber it.

Unknown slugs are never auto-created: unlike tags, the category set is
curated in WordPress, so a mismatch should surface rather than spawn a
new category. Articles that match no rule stay uncategorised on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Oliver 2026-07-31 08:00:32 +02:00
parent 90646d03a6
commit f0d10112ad
No known key found for this signature in database
4 changed files with 437 additions and 1 deletions

View file

@ -12,6 +12,7 @@ from html import unescape as _html_unescape
from urllib.parse import quote_plus, urlparse
from urllib.request import Request, urlopen
from . import categorize
from .config import get_settings
@ -137,6 +138,48 @@ def _resolve_wp_tag_ids(*, base_url: str, auth_header: str, tags: list[str]) ->
return ids
_category_id_cache: dict[str, int | None] = {}
def _resolve_wp_category_id(*, base_url: str, auth_header: str, slug: str) -> int | None:
"""Look up a WordPress category by slug.
Unlike tags, categories are never created on the fly: the set is curated in
WordPress, so an unknown slug means the rules and the site drifted apart and
should be fixed there, not silently papered over with a new category.
"""
if slug in _category_id_cache:
return _category_id_cache[slug]
try:
result = _wp_request(
base_url=base_url,
auth_header=auth_header,
method="GET",
endpoint=f"categories?slug={quote_plus(slug)}&per_page=1",
)
except Exception as exc:
# Do not cache transient failures - the next article should retry.
_logger.warning("Kategorie-Abfrage für '%s' fehlgeschlagen: %s", slug, exc)
return None
category_id: int | None = None
if isinstance(result, list):
for row in result:
if not isinstance(row, dict):
continue
rid = int(row.get("id", 0) or 0)
if rid > 0:
category_id = rid
break
if category_id is None:
_logger.warning("Kategorie mit Slug '%s' existiert nicht in WordPress", slug)
_category_id_cache[slug] = category_id
return category_id
_BLOCKED_IMAGE_EXTS = {".svg", ".gif", ".ico", ".webp"}
_logger = logging.getLogger(__name__)
@ -508,14 +551,33 @@ def publish_article_draft(article: dict[str, Any]) -> tuple[int, str | None]:
pass
wp_post_id = article.get("wp_post_id")
tag_names = _selected_tags_from_meta(article.get("meta_json"))
tag_ids = _resolve_wp_tag_ids(
base_url=settings.wordpress_base_url,
auth_header=auth,
tags=_selected_tags_from_meta(article.get("meta_json")),
tags=tag_names,
)
if tag_ids:
payload["tags"] = tag_ids
# Without an explicit category WordPress files everything under "Allgemein".
# Leaving it unset when no rule matches is deliberate: a wrong category is
# harder to spot later than a handful of posts in the catch-all.
slug = categorize.category_slug(title, tag_names)
if slug:
category_id = _resolve_wp_category_id(
base_url=settings.wordpress_base_url,
auth_header=auth,
slug=slug,
)
if category_id:
payload["categories"] = [category_id]
else:
_logger.info(
"Keine Kategorie-Regel für Artikel #%s (%s) - bleibt in der Standardkategorie",
article.get("id"), title[:60],
)
if wp_post_id:
result = _wp_request(
base_url=settings.wordpress_base_url,