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

@ -0,0 +1,61 @@
import unittest
from backend.app.categorize import CATEGORY_SLUGS, category_slug, classify
class TestCategorize(unittest.TestCase):
def test_tags_decide_the_category(self) -> None:
self.assertEqual(
classify("Neuer Platz am Wasser", ["Campingplatz", "5-Sterne-Campingplätze"]),
"campingplaetze",
)
self.assertEqual(classify("Unterwegs im Norden", ["Nordsee", "Niedersachsen"]), "reiseziele")
self.assertEqual(classify("Update vom Van", ["Wohnmobil", "Truma", "Solar"]), "fahrzeug-technik")
def test_title_alone_is_enough(self) -> None:
self.assertEqual(classify("Dometic CFX5 45 im Test: Top-Kühlbox", []), "ausruestung-tests")
self.assertEqual(classify("Führerscheine müssen getauscht werden", []), "recht-vorschriften")
def test_discount_signal_beats_the_product_topic(self) -> None:
# Without the override this lands in "Ausruestung & Tests", because the
# product tags outnumber the single discount signal.
self.assertEqual(
classify("Campingzelte bei Amazon im Sale: bis zu 55 %", ["Zelte", "Schlafsack", "Rabatt"]),
"angebote-rabatte",
)
def test_keywords_respect_word_boundaries(self) -> None:
# "klage" must not match the city name "Klagenfurt" - plain substring
# matching filed this campsite opening under "Recht & Vorschriften".
self.assertEqual(
classify("Falkensteiner Camping Wörthersee startet in die erste Saison",
["Campingplatz", "Klagenfurt", "Saisonstart"]),
"campingplaetze",
)
def test_compound_nouns_only_match_with_opt_in(self) -> None:
# "campingplatz*" is marked as a compound, so this still matches.
self.assertEqual(classify("Was Campingplatzbetreiber jetzt planen", []), "campingplaetze")
def test_returns_none_when_nothing_matches(self) -> None:
self.assertIsNone(classify("Weihnachten 2021", ["Weihnachten"]))
self.assertIsNone(category_slug("Weihnachten 2021", ["Weihnachten"]))
def test_umlauts_and_entities_are_normalised(self) -> None:
self.assertEqual(classify("Stellplätze an der Küste", []), "stellplaetze")
self.assertEqual(classify("Ausr&amp;uuml;stung", ["Schlafsack"]), "ausruestung-tests")
def test_renamed_categories_keep_their_original_slug(self) -> None:
# Both were renamed in WordPress but kept the indexed archive URL.
self.assertEqual(CATEGORY_SLUGS["fahrzeug-technik"], "fahrzeug")
self.assertEqual(CATEGORY_SLUGS["news-branche"], "news")
self.assertEqual(category_slug("Update vom Van", ["Wohnmobil", "Truma"]), "fahrzeug")
def test_every_rule_maps_to_a_known_slug(self) -> None:
self.assertEqual(len(CATEGORY_SLUGS), 11)
for key, slug in CATEGORY_SLUGS.items():
self.assertTrue(slug and slug.islower(), f"{key} has a suspicious slug: {slug!r}")
if __name__ == "__main__":
unittest.main()

View file

@ -3,6 +3,7 @@ import unittest
from unittest.mock import patch
from backend.app import config as config_module
from backend.app import wordpress as wordpress_module
from backend.app.wordpress import publish_article_draft
@ -12,6 +13,9 @@ class TestWordpressPublish(unittest.TestCase):
os.environ["WORDPRESS_USERNAME"] = "wp-user"
os.environ["WORDPRESS_APP_PASSWORD"] = "wp-pass"
config_module.get_settings.cache_clear()
# The category lookup is cached for the process lifetime; without this
# one test would resolve a slug that the next one expects to be missing.
wordpress_module._category_id_cache.clear()
def tearDown(self) -> None:
for key in ("WORDPRESS_BASE_URL", "WORDPRESS_USERNAME", "WORDPRESS_APP_PASSWORD"):
@ -134,6 +138,78 @@ class TestWordpressPublish(unittest.TestCase):
self.assertIn("<!-- wp:list -->", content)
self.assertNotIn("<!-- wp:html -->", content)
@patch("backend.app.wordpress._upload_featured_media")
@patch("backend.app.wordpress._wp_request")
def test_publish_sets_category_from_rules(self, mock_wp_request, mock_upload_media) -> None:
def _fake_wp_request(**kwargs):
endpoint = kwargs.get("endpoint", "")
method = kwargs.get("method", "")
if method == "GET" and endpoint.startswith("tags?search="):
return [{"id": 21, "name": "Kühlbox"}]
if method == "GET" and endpoint.startswith("categories?slug=ausruestung-tests"):
return [{"id": 77, "slug": "ausruestung-tests"}]
if method == "POST" and endpoint == "posts":
return {"id": 901, "link": "https://example.org/?p=901"}
return {}
mock_wp_request.side_effect = _fake_wp_request
article = {
"title": "Dometic CFX5 45 im Test: Top-Kühlbox für unterwegs",
"content_raw": "Inhalt",
"source_url": "https://example.com/source",
"canonical_url": "https://example.com/source",
"meta_json": '{"generated_tags":["Kühlbox"]}',
}
post_id, _ = publish_article_draft(article)
self.assertEqual(post_id, 901)
payload = [c for c in mock_wp_request.call_args_list if c.kwargs.get("endpoint") == "posts"][0].kwargs["payload"]
self.assertEqual(payload.get("categories"), [77])
@patch("backend.app.wordpress._upload_featured_media")
@patch("backend.app.wordpress._wp_request")
def test_publish_omits_category_when_no_rule_matches(self, mock_wp_request, mock_upload_media) -> None:
def _fake_wp_request(**kwargs):
if kwargs.get("method") == "POST" and kwargs.get("endpoint") == "posts":
return {"id": 902, "link": "https://example.org/?p=902"}
return {}
mock_wp_request.side_effect = _fake_wp_request
article = {
"title": "Weihnachten 2021",
"content_raw": "Inhalt",
"source_url": "https://example.com/source",
"canonical_url": "https://example.com/source",
"meta_json": '{"generated_tags":[]}',
}
publish_article_draft(article)
payload = [c for c in mock_wp_request.call_args_list if c.kwargs.get("endpoint") == "posts"][0].kwargs["payload"]
self.assertNotIn("categories", payload)
self.assertFalse(any("categories?slug=" in (c.kwargs.get("endpoint") or "")
for c in mock_wp_request.call_args_list))
@patch("backend.app.wordpress._upload_featured_media")
@patch("backend.app.wordpress._wp_request")
def test_publish_skips_category_when_slug_is_missing_in_wordpress(self, mock_wp_request, mock_upload_media) -> None:
def _fake_wp_request(**kwargs):
endpoint = kwargs.get("endpoint", "")
if kwargs.get("method") == "GET" and endpoint.startswith("categories?slug="):
return []
if kwargs.get("method") == "POST" and endpoint == "posts":
return {"id": 903, "link": "https://example.org/?p=903"}
return {}
mock_wp_request.side_effect = _fake_wp_request
article = {
"title": "Dometic CFX5 45 im Test: Top-Kühlbox für unterwegs",
"content_raw": "Inhalt",
"source_url": "https://example.com/source",
"canonical_url": "https://example.com/source",
"meta_json": '{"generated_tags":[]}',
}
publish_article_draft(article)
payload = [c for c in mock_wp_request.call_args_list if c.kwargs.get("endpoint") == "posts"][0].kwargs["payload"]
self.assertNotIn("categories", payload)
if __name__ == "__main__":
unittest.main()