From f2c626c649bd156af012ae0fd9fb2b6a4985f915 Mon Sep 17 00:00:00 2001 From: k0te1ch Date: Sun, 26 Jul 2026 19:46:38 +0300 Subject: [PATCH] feat(wordpress): fix podcast draft fields and add recording date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WordPress publisher rendered several wrong fields in the podcast draft: - episode title showed the generic "Разговорный жанр — N" instead of the real episode name; it now comes from the parsed template title - the description was wrapped in ; all spacers are dropped so the summary renders as plain text - the legacy big_post custom field is no longer sent - the recording date was never pushed to Podlove and the post body defaulted to today; it is now supplied via an optional "Recording Date" line in the upload template, threaded through the WordPressEvent schema to the Podlove REST payload and the post body, falling back to today when omitted --- app/bot/handlers/wordpress_handler.py | 1 + app/bot/locales/en.ftl | 2 + app/bot/locales/ru.ftl | 2 + app/bot/utils/validators.py | 27 ++++++++++ app/publishers/WordPress/main.py | 1 + app/publishers/WordPress/wordpress.py | 28 +++++++---- app/shared/kafka/models/wordpress_event.py | 1 + app/shared/kafka/schemas/wordpress_event.avsc | 1 + .../wordpress/test_wordpress_client.py | 39 +++++++++++++++ .../validators/test_validate_template.py | 50 ++++++++++++++++++- 10 files changed, 141 insertions(+), 11 deletions(-) diff --git a/app/bot/handlers/wordpress_handler.py b/app/bot/handlers/wordpress_handler.py index af00edf..292b0ec 100644 --- a/app/bot/handlers/wordpress_handler.py +++ b/app/bot/handlers/wordpress_handler.py @@ -67,6 +67,7 @@ async def upload_WP(callback: CallbackQuery, language: str, username: str) -> No tags=info["tags"], slug=info["slug"], duration=info["duration"], + recording_date=info.get("recording_date"), type_episode=type_episode, ) diff --git a/app/bot/locales/en.ftl b/app/bot/locales/en.ftl index 884355d..c0f6cd5 100644 --- a/app/bot/locales/en.ftl +++ b/app/bot/locales/en.ftl @@ -25,6 +25,7 @@ bot_commands = [["Turn off the bot", "shutdown_bot"], ["Send log files", "send_l ask_template = { .main = |
Number: 600
+        Recording Date: 26.07.2026
         Title: Episode title
         Comment: Episode description
         Tags: Tag1, Tag2, Tag3
@@ -35,6 +36,7 @@ ask_template = {
         02:17:25 - Patrons and aftershow announcement
.aftershow = |
Number: 600
+        Recording Date: 26.07.2026
         Title: Aftershow. Episode title
         Comment: Episode description
} diff --git a/app/bot/locales/ru.ftl b/app/bot/locales/ru.ftl index 39615ad..6be2e56 100644 --- a/app/bot/locales/ru.ftl +++ b/app/bot/locales/ru.ftl @@ -25,6 +25,7 @@ bot_commands = [["Выключить бота", "shutdown_bot"], ["Присла ask_template = { .main = |
Number: 600
+        Recording Date: 26.07.2026
         Title: Название эпизода
         Comment: Описание эпизода
         Tags: Окно, жесть, спина
@@ -35,6 +36,7 @@ ask_template = {
         02:17:25 - Озвучили наших патронов и анонсировали послешоу
.aftershow = |
Number: 600
+        Recording Date: 26.07.2026
         Title: Послешоу. Название эпизода
         Comment: Описание эпизода
} diff --git a/app/bot/utils/validators.py b/app/bot/utils/validators.py index 966f0a3..bc64069 100644 --- a/app/bot/utils/validators.py +++ b/app/bot/utils/validators.py @@ -1,8 +1,24 @@ import re +from datetime import datetime from pathlib import Path from loguru import logger +# Дата записи в шаблоне: принимаем DD.MM.YYYY (также /, - как разделители), +# нормализуем в ISO YYYY-MM-DD — именно этот формат ждёт Podlove. +_RECORDING_DATE_RE = re.compile(r"^[ \t]*Recording Date:[ \t]*(.+?)[ \t]*$\n?", re.MULTILINE) + + +def _parse_recording_date(raw: str) -> str | None: + """DD.MM.YYYY (./-/пробел как разделитель) -> ISO YYYY-MM-DD, иначе None.""" + for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d"): + try: + return datetime.strptime(raw.strip(), fmt).strftime("%Y-%m-%d") + except ValueError: + continue + logger.warning(f"Unrecognized recording date {raw!r}; ignoring") + return None + @logger.catch def validate_template(text: str) -> dict[str, str] | None: @@ -21,6 +37,14 @@ def validate_template(text: str) -> dict[str, str] | None: >>> validate_template(template) {'number': '1', 'title': '1. Example of a header', 'comment': 'Example of a comment'} """ + # Дату записи парсим отдельно и вырезаем строку до основного regex — + # так поле остаётся опциональным и не усложняет и без того капризный шаблон. + recording_date = None + date_match = _RECORDING_DATE_RE.search(text) + if date_match: + recording_date = _parse_recording_date(date_match.group(1)) + text = text[: date_match.start()] + text[date_match.end() :] + headers = ["number", "title", "comment"] if "chapters" in text.lower(): reg = r"(?:)?Number: (\d+)\nTitle: (.*?)\nComment: (.*?)\nTags: (.*?)\nChapters: \|\n((?:(?!<\/pre>).)*)(?:<\/pre>)?$" @@ -45,6 +69,9 @@ def validate_template(text: str) -> dict[str, str] | None: if "tags" in res: res["tags"] = list({tag.strip() for tag in re.split(r",\s*|,\s*|\s*,\s*", res["tags"])}) + if recording_date: + res["recording_date"] = recording_date + return res diff --git a/app/publishers/WordPress/main.py b/app/publishers/WordPress/main.py index 3fcb6f5..6e81652 100644 --- a/app/publishers/WordPress/main.py +++ b/app/publishers/WordPress/main.py @@ -40,6 +40,7 @@ async def publish(self, event: WordPressEvent) -> None: # type: ignore[override "tags": event.tags, "slug": event.slug, "duration": event.duration, + "recording_date": event.recording_date, } # WordPress.upload_post() — синхронный (requests-based). Запускаем diff --git a/app/publishers/WordPress/wordpress.py b/app/publishers/WordPress/wordpress.py index 01f0201..05a8188 100644 --- a/app/publishers/WordPress/wordpress.py +++ b/app/publishers/WordPress/wordpress.py @@ -292,13 +292,19 @@ def _format_duration(seconds) -> str: def _update_podlove_episode(self, episode_id: int, info: dict) -> None: payload = { - "title": f"Разговорный жанр — {info['number']}", + "title": info["title"], "summary": info["comment"], "number": int(info["number"]) if str(info["number"]).isdigit() else info["number"], "slug": info["slug"], "duration": self._format_duration(info["duration"]), "type": "full", } + # Дата записи задаётся при оформлении (info["recording_date"], ISO + # YYYY-MM-DD). Если её нет — не шлём ключ, чтобы Podlove не затирал + # значение пустотой. + recording_date = info.get("recording_date") + if recording_date: + payload["recording_date"] = recording_date self._rest_request("POST", f"/podlove/v2/episodes/{episode_id}", json_body=payload) logger.debug(f"Podlove episode {episode_id} metadata updated") @@ -356,7 +362,15 @@ def upload_post(self, info: dict) -> bool: for time_str, chapterName in info["chapters"]: chapters += f"[skipto time={time_str}]{time_str}[/skipto] — {chapterName}\n" + # Дата записи: если задана при оформлении (ISO YYYY-MM-DD) — берём её, + # иначе фолбэк на текущую дату (как было раньше). + recording_iso = info.get("recording_date") time = datetime.now(self._timezone) + if recording_iso: + try: + time = datetime.strptime(recording_iso, "%Y-%m-%d") + except ValueError as e: + logger.warning(f"Invalid recording_date {recording_iso!r} ({e}); falling back to today") months = ( "января", @@ -376,15 +390,12 @@ def upload_post(self, info: dict) -> bool: form = { "post_title": f"Разговорный жанр — {podcastID}", - "content": f"""{name.replace(podcastID + ". ", "")} - + "content": f"""{name.replace(podcastID + ". ", "")} Описание: -{summary} - +{summary} Таймлайн: {chapters} - -Всё это вы услышите в {podcastID}-м эпизоде подкаста «Разговорный жанр». +Всё это вы услышите в {podcastID}-м эпизоде подкаста «Разговорный жанр». [podlove-template template="subscriptions"] Дата записи: {timeStr}""", "post_name": f"Разговорный жанр — {podcastID}", @@ -392,9 +403,6 @@ def upload_post(self, info: dict) -> bool: "newcategory": "Название новой рубрики", "newcategory_parent": "-1", "trackback_url": "", - "metakeyselect": "big_post", - "metakeyinput": "", - "metavalue": "1", "comment_status": "open", "ping_status": "open", "post_author_override": "361", diff --git a/app/shared/kafka/models/wordpress_event.py b/app/shared/kafka/models/wordpress_event.py index 55de0c5..496996b 100644 --- a/app/shared/kafka/models/wordpress_event.py +++ b/app/shared/kafka/models/wordpress_event.py @@ -17,6 +17,7 @@ class WordPressEvent(BaseModel): tags: list[str] = Field(default_factory=list) slug: str = Field(..., description="Slug файла") duration: int | None = Field(None, description="Длительность в секундах") + recording_date: str | None = Field(None, description="Дата записи (ISO YYYY-MM-DD)") type_episode: str | None = Field( None, description="main | aftershow — определяет paywall для платных publisher'ов" diff --git a/app/shared/kafka/schemas/wordpress_event.avsc b/app/shared/kafka/schemas/wordpress_event.avsc index 768f2be..387e579 100644 --- a/app/shared/kafka/schemas/wordpress_event.avsc +++ b/app/shared/kafka/schemas/wordpress_event.avsc @@ -16,6 +16,7 @@ {"name": "tags", "type": {"type": "array", "items": "string"}}, {"name": "slug", "type": "string", "doc": "Slug файла (имя без .mp3)"}, {"name": "duration", "type": ["null", "long"], "default": null, "doc": "Длительность в секундах"}, + {"name": "recording_date", "type": ["null", "string"], "default": null, "doc": "Дата записи (ISO YYYY-MM-DD)"}, {"name": "type_episode", "type": ["null", "string"], "default": null, "doc": "main | aftershow — определяет paywall для платных publisher'ов"} ] } diff --git a/tests/unit/publishers/wordpress/test_wordpress_client.py b/tests/unit/publishers/wordpress/test_wordpress_client.py index d400e83..574aacd 100644 --- a/tests/unit/publishers/wordpress/test_wordpress_client.py +++ b/tests/unit/publishers/wordpress/test_wordpress_client.py @@ -136,6 +136,45 @@ def _request(method, url, **kwargs): mock_session.request.assert_called() wp._rest_session.request.assert_called() + def test_upload_post_form_and_rest_payload(self, mock_session, sample_post_info): + """Регресс на баги черновика: Title = имя эпизода, без и big_post, + recording_date уходит в Podlove и в тело поста.""" + sample_post_info["recording_date"] = "2026-06-05" + + get_resp = MagicMock(status_code=200, ok=True, content=_FORM_PAGE, text=_FORM_PAGE.decode()) + post_resp = MagicMock(status_code=302, ok=True, text="") + captured = {} + + def _request(method, url, **kwargs): + if method == "POST" and url.endswith("/wp-admin/post.php"): + captured["form"] = kwargs["data"] + return post_resp + return get_resp + + mock_session.request.side_effect = _request + + wp = _make_wp(mock_session) + with patch.object(wp, "_dump_cookies", return_value=True): + assert wp.upload_post(sample_post_info) is True + + content = captured["form"]["content"] + # #3 — никаких в теле + assert "" not in content + # описание присутствует как обычный текст + assert sample_post_info["comment"] in content + # #2 — дата записи из info, а не сегодняшняя + assert "Дата записи: 5 июня 2026" in content + # #4 — big_post убран + assert "metakeyselect" not in captured["form"] + assert "metavalue" not in captured["form"] + + # #1 + #2 — REST-обновление Podlove: title = имя эпизода, recording_date проброшен + episode_calls = [c for c in wp._rest_session.request.call_args_list if "/podlove/v2/episodes/" in c.args[1]] + assert episode_calls, "Podlove episode update not called" + payload = episode_calls[0].kwargs["json"] + assert payload["title"] == sample_post_info["title"] + assert payload["recording_date"] == "2026-06-05" + def test_upload_post_no_form_retries_login(self, mock_session, sample_post_info): empty_page = b"No form here" get_calls = {"n": 0} diff --git a/tests/unit/utils/validators/test_validate_template.py b/tests/unit/utils/validators/test_validate_template.py index 59d450c..3b785d9 100644 --- a/tests/unit/utils/validators/test_validate_template.py +++ b/tests/unit/utils/validators/test_validate_template.py @@ -11,7 +11,11 @@ ], ) def test_valid_template(template): - expected_output = {"number": "1", "title": "1. Example header", "comment": "Example comment"} + expected_output = { + "number": "1", + "title": "1. Example header", + "comment": "Example comment", + } assert validate_template(template) == expected_output @@ -39,6 +43,50 @@ def test_template_with_chapters_and_tags(template): assert result["chapters"] == expected_output["chapters"] +@pytest.mark.parametrize( + ("template", "expected_date"), + [ + # DD.MM.YYYY -> ISO + ( + "Number: 5\nRecording Date: 05.06.2026\nTitle: Header\nComment: Comment", + "2026-06-05", + ), + # разделитель "/" тоже принимаем + ( + "Number: 5\nRecording Date: 05/06/2026\nTitle: Header\nComment: Comment", + "2026-06-05", + ), + # с тегами/главами дата всё ещё вырезается корректно + ( + "Number: 5\nRecording Date: 05.06.2026\nTitle: Header\nComment: Comment" + "\nTags: t1, t2\nChapters: |\n00:00:00 - Intro", + "2026-06-05", + ), + ], +) +def test_template_with_recording_date(template, expected_date): + result = validate_template(template) + assert result is not None + assert result["recording_date"] == expected_date + # дата вырезана — Title/Comment распарсились штатно + assert result["title"] == "5. Header" + assert result["comment"] == "Comment" + + +def test_template_without_recording_date_has_no_key(): + result = validate_template("Number: 1\nTitle: Header\nComment: Comment") + assert result is not None + assert "recording_date" not in result + + +def test_template_with_unparseable_recording_date_is_ignored(): + # Некорректная дата не роняет шаблон — просто отбрасывается + result = validate_template("Number: 1\nRecording Date: не дата\nTitle: Header\nComment: Comment") + assert result is not None + assert "recording_date" not in result + assert result["title"] == "1. Header" + + @pytest.mark.parametrize( "template", [