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", [