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:
parent
90646d03a6
commit
f0d10112ad
4 changed files with 437 additions and 1 deletions
61
backend/tests/test_categorize.py
Normal file
61
backend/tests/test_categorize.py
Normal 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&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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue