Рефакторинг рендера [этап 5/6]: таблица медиа-типов в post_parser (два коммита: 5a байт-в-байт + 5b фиксы) #32

Closed
opened 2026-07-05 20:28:21 +03:00 by agent_vscode · 1 comment
Collaborator

Часть эпика «Рефакторинг пайплайна рендера». Спека: plans/2026-07-05-render-pipeline-refactor.md (v5), раздел «Этап 5». Зависимость: этап 3 (#30). Независим от этапа 4 — можно параллельно. РОВНО ДВА КОММИТА: 5a (рефакторинг байт-в-байт) и 5b (зарегистрированные фиксы). Line-refs действительны для f9550d8.

Цель

В post_parser три параллельные «лестницы» выбора media-объекта, синхронизируемые вручную: _get_file_unique_id (dict-маппинг, branch:1258–1284), _save_media_file_ids (if/elif, branch:1304–1359), _generate_html_media (elif-каскад, branch:838–959). Одна точка правды: таблица MEDIA_SOURCES (селектор + render kind) + renderer-функции.

Коммит 5a — рефакторинг байт-в-байт

Шаг 0 (обязательный, до правок): fragment-level снапшоты _generate_html_media (ДО санитайза) для всех типов: PHOTO, VIDEO, ANIMATION, VIDEO_NOTE, AUDIO, VOICE, STICKER img/video, DOCUMENT pdf/обычный, LIVE_PHOTO, STORY video/photo, POLL с description_media, PAID_MEDIA, WEB_PAGE с фото (даёт ПУСТОЙ media-div — воспроизводить), и edge-ветки: WEB_PAGE без фото (незакрытый div — в 5a воспроизводится БУКВАЛЬНО), file_unique_id is None, channel_username is None, гейт webpage-превью «text ≤ 10». Это отдельный слой эталонов, НЕ смешивать с feed-golden этапа 0.

# Single source of truth: media type -> (object selector, render kind).
# kind=None means "selector-only entry": file-id extraction/collection only,
# rendering happens outside the dispatcher. The ONLY kind=None entry is
# WEB_PAGE (rendered by _format_webpage). PAID_MEDIA has NO entry at all:
# its info block stays a separate branch and it is deliberately not collected.
MEDIA_SOURCES: dict[MessageMediaType, Callable[[Message], tuple[Any, str | None]]] = {
    MessageMediaType.PHOTO:    lambda m: (m.photo, 'img_400'),
    MessageMediaType.DOCUMENT: _select_document,   # kind 'pdf' OR 'img_400' by mime_type
    MessageMediaType.STICKER:  _select_sticker,    # video_loop_200 vs img_200_sticker
    MessageMediaType.STORY:    _select_story,      # maps helper kinds video->'video_400', img->'img_400'
    MessageMediaType.POLL:     _select_poll_media, # same helper-kind mapping
    MessageMediaType.WEB_PAGE: lambda m: (getattr(m.web_page, 'photo', None), None),
    # VIDEO/ANIMATION/VIDEO_NOTE -> video_400; AUDIO/VOICE -> audio;
    # LIVE_PHOTO -> video_loop_400
}

@dataclass
class RenderCtx:
    url: str                      # signed /media URL — assembled by _generate_html_media,
                                  # which KEEPS the digest call and channel_username guard inline
    tg_link: str | None = None    # t.me deep link — only the 'pdf' renderer uses it
    emoji: str = ''               # sticker alt text
    mime: str | None = None       # audio/voice type; DEFAULT chosen by the ctx builder
                                  # BY MEDIA TYPE (AUDIO->audio/mpeg, VOICE->audio/ogg,
                                  # branch:907/912) — the renderer never guesses

# Renderers, NOT naked format strings: renderer returns list[str] so the byte
# structure of '\n'.join is preserved (audio emits TWO items: tag + <br>; pdf
# emits its two-append div block). Renderer bodies are lifted VERBATIM from
# the existing if/elif branches — including `src="{url}"style=` artifacts.
RENDERERS: dict[str, Callable[[RenderCtx], list[str]]] = { ... }

PDF идёт ЧЕРЕЗ таблицу: _select_document возвращает 'pdf' для application/pdf; RENDERERS['pdf'] использует ctx.tg_link (сборка t.me/c/... vs t.me/... — в _generate_html_media, как сейчас, branch:887–893).

Потребители: _get_file_unique_id, _save_media_file_ids, _generate_html_media — все через селектор. НЕ трогать: PAID_MEDIA-блок, NO_IMAGE-гейт, POLL-гейт, guard channel_username, webpage-превью.

Ограничение: flags.append(...) НЕ выносить из _extract_flags — эндпоинт /flags парсит его исходник через inspect.getsource (branch:152–165). Тест: get_all_possible_flags() непуст и содержит известные флаги.

Межмодульный инвариант-тест: для каждого типа из MEDIA_SOURCES объект, выбранный селектором, находится api_server.find_file_id_in_message по своему file_unique_id (иначе новый entry таблицы даст URL, который /media не разрешит → 404). Ограничение: обе стороны на моках — багов класса «обе ждут атрибут, которого нет у реального kurigram-объекта» тест не ловит; сверка атрибутов моков с pyrogram.types — опциональный best-effort. САМУ find_file_id_in_message НЕ сливать с таблицей: её контракт шире (ищет любой скачиваемый объект независимо от message.media, включая explanation_media, который рендер сознательно игнорирует).

Коммит 5b — зарегистрированные фиксы

  • §3.13 (унификация): guard «>100 МБ не кэшируем» — на ЛЮБОЙ медиа-объект (сейчас только message.video), в _save_media_file_ids.
  • §3.14 (багфикс): закрыть </div class="message-media"> во всех ветках (сейчас при file_unique_id is None div не закрывается).
  • Обновить fragment-снапшоты с diff-комментарием; feed-golden этапа 0 обновляется ТОЛЬКО по п.14 (фикстура webpage-без-фото).

DoD

Прямые атрибутные цепочки вида m.photo.file_unique_id — только в селекторах таблицы, хелперах _poll_media_object/_story_media_object (реализация селекторов) и _format_webpage. Потребители извлекают uid единообразно — getattr(selected_obj, 'file_unique_id', None) от объекта, возвращённого селектором; санкционированные точки: _get_file_unique_id, _save_media_file_ids, инвариант-тест. Три лестницы удалены; снапшоты обоих уровней + pytest зелёные; инвариант-тест зелёный.

Общие правила эпика

Ветка refactor/render-pipeline от f9550d8; комментарии — English; точечные правки; 5a обязан дать байт-идентичные fragment-снапшоты.

Часть эпика «Рефакторинг пайплайна рендера». Спека: `plans/2026-07-05-render-pipeline-refactor.md` (v5), раздел «Этап 5». Зависимость: этап 3 (#30). Независим от этапа 4 — можно параллельно. РОВНО ДВА КОММИТА: 5a (рефакторинг байт-в-байт) и 5b (зарегистрированные фиксы). Line-refs действительны для f9550d8. ## Цель В post_parser три параллельные «лестницы» выбора media-объекта, синхронизируемые вручную: `_get_file_unique_id` (dict-маппинг, branch:1258–1284), `_save_media_file_ids` (if/elif, branch:1304–1359), `_generate_html_media` (elif-каскад, branch:838–959). Одна точка правды: таблица `MEDIA_SOURCES` (селектор + render kind) + renderer-функции. ## Коммит 5a — рефакторинг байт-в-байт **Шаг 0 (обязательный, до правок):** fragment-level снапшоты `_generate_html_media` (ДО санитайза) для всех типов: PHOTO, VIDEO, ANIMATION, VIDEO_NOTE, AUDIO, VOICE, STICKER img/video, DOCUMENT pdf/обычный, LIVE_PHOTO, STORY video/photo, POLL с description_media, PAID_MEDIA, WEB_PAGE с фото (даёт ПУСТОЙ media-div — воспроизводить), и edge-ветки: WEB_PAGE без фото (незакрытый div — в 5a воспроизводится БУКВАЛЬНО), file_unique_id is None, channel_username is None, гейт webpage-превью «text ≤ 10». Это отдельный слой эталонов, НЕ смешивать с feed-golden этапа 0. ```python # Single source of truth: media type -> (object selector, render kind). # kind=None means "selector-only entry": file-id extraction/collection only, # rendering happens outside the dispatcher. The ONLY kind=None entry is # WEB_PAGE (rendered by _format_webpage). PAID_MEDIA has NO entry at all: # its info block stays a separate branch and it is deliberately not collected. MEDIA_SOURCES: dict[MessageMediaType, Callable[[Message], tuple[Any, str | None]]] = { MessageMediaType.PHOTO: lambda m: (m.photo, 'img_400'), MessageMediaType.DOCUMENT: _select_document, # kind 'pdf' OR 'img_400' by mime_type MessageMediaType.STICKER: _select_sticker, # video_loop_200 vs img_200_sticker MessageMediaType.STORY: _select_story, # maps helper kinds video->'video_400', img->'img_400' MessageMediaType.POLL: _select_poll_media, # same helper-kind mapping MessageMediaType.WEB_PAGE: lambda m: (getattr(m.web_page, 'photo', None), None), # VIDEO/ANIMATION/VIDEO_NOTE -> video_400; AUDIO/VOICE -> audio; # LIVE_PHOTO -> video_loop_400 } @dataclass class RenderCtx: url: str # signed /media URL — assembled by _generate_html_media, # which KEEPS the digest call and channel_username guard inline tg_link: str | None = None # t.me deep link — only the 'pdf' renderer uses it emoji: str = '' # sticker alt text mime: str | None = None # audio/voice type; DEFAULT chosen by the ctx builder # BY MEDIA TYPE (AUDIO->audio/mpeg, VOICE->audio/ogg, # branch:907/912) — the renderer never guesses # Renderers, NOT naked format strings: renderer returns list[str] so the byte # structure of '\n'.join is preserved (audio emits TWO items: tag + <br>; pdf # emits its two-append div block). Renderer bodies are lifted VERBATIM from # the existing if/elif branches — including `src="{url}"style=` artifacts. RENDERERS: dict[str, Callable[[RenderCtx], list[str]]] = { ... } ``` PDF идёт ЧЕРЕЗ таблицу: `_select_document` возвращает `'pdf'` для `application/pdf`; `RENDERERS['pdf']` использует `ctx.tg_link` (сборка `t.me/c/...` vs `t.me/...` — в `_generate_html_media`, как сейчас, branch:887–893). Потребители: `_get_file_unique_id`, `_save_media_file_ids`, `_generate_html_media` — все через селектор. НЕ трогать: PAID_MEDIA-блок, NO_IMAGE-гейт, POLL-гейт, guard channel_username, webpage-превью. Ограничение: `flags.append(...)` НЕ выносить из `_extract_flags` — эндпоинт `/flags` парсит его исходник через `inspect.getsource` (branch:152–165). Тест: `get_all_possible_flags()` непуст и содержит известные флаги. Межмодульный инвариант-тест: для каждого типа из MEDIA_SOURCES объект, выбранный селектором, находится `api_server.find_file_id_in_message` по своему `file_unique_id` (иначе новый entry таблицы даст URL, который /media не разрешит → 404). Ограничение: обе стороны на моках — багов класса «обе ждут атрибут, которого нет у реального kurigram-объекта» тест не ловит; сверка атрибутов моков с `pyrogram.types` — опциональный best-effort. САМУ `find_file_id_in_message` НЕ сливать с таблицей: её контракт шире (ищет любой скачиваемый объект независимо от message.media, включая explanation_media, который рендер сознательно игнорирует). ## Коммит 5b — зарегистрированные фиксы - §3.13 (унификация): guard «>100 МБ не кэшируем» — на ЛЮБОЙ медиа-объект (сейчас только `message.video`), в `_save_media_file_ids`. - §3.14 (багфикс): закрыть `</div class="message-media">` во всех ветках (сейчас при `file_unique_id is None` div не закрывается). - Обновить fragment-снапшоты с diff-комментарием; feed-golden этапа 0 обновляется ТОЛЬКО по п.14 (фикстура webpage-без-фото). ## DoD Прямые атрибутные цепочки вида `m.photo.file_unique_id` — только в селекторах таблицы, хелперах `_poll_media_object`/`_story_media_object` (реализация селекторов) и `_format_webpage`. Потребители извлекают uid единообразно — `getattr(selected_obj, 'file_unique_id', None)` от объекта, возвращённого селектором; санкционированные точки: `_get_file_unique_id`, `_save_media_file_ids`, инвариант-тест. Три лестницы удалены; снапшоты обоих уровней + pytest зелёные; инвариант-тест зелёный. ## Общие правила эпика Ветка `refactor/render-pipeline` от f9550d8; комментарии — English; точечные правки; 5a обязан дать байт-идентичные fragment-снапшоты.
Author
Collaborator

Дополнение v6 (обязательное): источник сообщений для fragment-снапшотов шага 0 — ЗАПИСАННЫЙ КОРПУС этапа 0 (#27), а не моки, для всех типов, которые в нём есть. По инвентарю корпуса доступны: PHOTO, VIDEO, ANIMATION, VIDEO_NOTE, AUDIO, VOICE, STICKER, DOCUMENT, POLL, WEB_PAGE (с фото и без), GIVEAWAY, GIVEAWAY_WINNERS. Моки — ТОЛЬКО для отсутствующих в корпусе типов: PAID_MEDIA, STORY, LIVE_PHOTO, CHECKLIST, CONTACT/LOCATION/VENUE/DICE/GAME/INVOICE/UNSUPPORTED. Ограничение инвариант-теста «ложная зелень на моках» тем самым сужается до этих mock-only типов. Детали — спека v6, этап 5a.

**Дополнение v6 (обязательное):** источник сообщений для fragment-снапшотов шага 0 — ЗАПИСАННЫЙ КОРПУС этапа 0 (#27), а не моки, для всех типов, которые в нём есть. По инвентарю корпуса доступны: PHOTO, VIDEO, ANIMATION, VIDEO_NOTE, AUDIO, VOICE, STICKER, DOCUMENT, POLL, WEB_PAGE (с фото и без), GIVEAWAY, GIVEAWAY_WINNERS. Моки — ТОЛЬКО для отсутствующих в корпусе типов: PAID_MEDIA, STORY, LIVE_PHOTO, CHECKLIST, CONTACT/LOCATION/VENUE/DICE/GAME/INVOICE/UNSUPPORTED. Ограничение инвариант-теста «ложная зелень на моках» тем самым сужается до этих mock-only типов. Детали — спека v6, этап 5a.
Sign in to join this conversation.