refactor(render): этап 5 — таблица медиа-типов (5a байт-в-байт + 5b фиксы) (#32) #41

Merged
agent_vscode merged 3 commits from refactor/render-stage5-media into main 2026-07-07 16:46:48 +03:00
11 changed files with 1020 additions and 134 deletions
+181 -122
View File
@@ -14,8 +14,9 @@ import re
import os
import html
import inspect
from dataclasses import dataclass
from datetime import datetime
from typing import Union, Dict, Any, List, Optional
from typing import Union, Dict, Any, List, Optional, Callable, Tuple
from pyrogram.types import Message
from pyrogram.enums import MessageMediaType
from sanitizer import sanitize_html
@@ -107,6 +108,138 @@ def _story_media_object(message):
return None, None
# --------------------------------------------------------------------------- #
# Single source of truth for media handling (render-pipeline refactor, stage 5).
#
# MEDIA_SOURCES maps a media type to a selector returning (media object, render
# kind). It replaces the three hand-synced ladders that used to live in
# _get_file_unique_id (dict map), _save_media_file_ids (if/elif) and
# _generate_html_media (elif cascade). All three now go through this table.
#
# kind=None means "selector-only entry": the object participates in file-id
# extraction/collection but is NOT rendered by the dispatcher. The ONLY kind=None
# entry is WEB_PAGE (its preview is rendered by _format_webpage). PAID_MEDIA has
# NO entry at all: its info block is a dedicated branch in _generate_html_media
# and it is deliberately never collected/downloaded (paid content).
# --------------------------------------------------------------------------- #
def _select_document(message):
"""DOCUMENT selector: PDF documents render as a t.me link ('pdf'), everything
else as an image ('img_400'). The selected object is always message.document."""
document = message.document
if (document is not None and hasattr(document, 'mime_type')
and document.mime_type == 'application/pdf'):
return document, 'pdf'
return document, 'img_400'
def _select_sticker(message):
"""STICKER selector: video stickers loop as <video> ('video_loop_200'), image
stickers render as <img> ('img_200_sticker')."""
sticker = message.sticker
if getattr(sticker, 'is_video', False):
return sticker, 'video_loop_200'
return sticker, 'img_200_sticker'
# Helper kinds returned by _story_media_object / _poll_media_object map to render kinds.
_HELPER_KIND_TO_RENDER = {'video': 'video_400', 'img': 'img_400'}
def _select_story(message):
"""STORY selector: object choice (video over photo) is delegated to
_story_media_object; its helper kind is mapped to a render kind."""
media_obj, helper_kind = _story_media_object(message)
return media_obj, _HELPER_KIND_TO_RENDER.get(helper_kind)
def _select_poll_media(message):
"""POLL selector: object comes from _poll_media_object (description_media); its
helper kind is mapped to a render kind."""
media_obj, helper_kind = _poll_media_object(message)
return media_obj, _HELPER_KIND_TO_RENDER.get(helper_kind)
MEDIA_SOURCES: Dict[Any, Callable[[Message], Tuple[Any, Optional[str]]]] = {
MessageMediaType.PHOTO: lambda m: (m.photo, 'img_400'),
MessageMediaType.VIDEO: lambda m: (m.video, 'video_400'),
MessageMediaType.ANIMATION: lambda m: (m.animation, 'video_400'),
MessageMediaType.VIDEO_NOTE: lambda m: (m.video_note, 'video_400'),
MessageMediaType.AUDIO: lambda m: (m.audio, 'audio'),
MessageMediaType.VOICE: lambda m: (m.voice, 'audio'),
MessageMediaType.DOCUMENT: _select_document,
MessageMediaType.STICKER: _select_sticker,
MessageMediaType.LIVE_PHOTO: lambda m: (getattr(m, 'live_photo', None), 'video_loop_400'),
MessageMediaType.STORY: _select_story,
MessageMediaType.POLL: _select_poll_media,
MessageMediaType.WEB_PAGE: lambda m: (getattr(m.web_page, 'photo', None), None),
}
@dataclass
class RenderCtx:
"""Everything a renderer needs. _generate_html_media assembles it (URL signing,
channel_username guard, and the mime/emoji/tg_link values chosen BY MEDIA TYPE);
a renderer only formats the string and never inspects the Message."""
url: str
tg_link: Optional[str] = None # t.me deep link — only the 'pdf' renderer uses it
emoji: str = '' # sticker alt text
mime: Optional[str] = None # audio/voice <source> type; default chosen by media type
# Renderers return list[str] so the byte structure of the '\n'.join in
# _generate_html_media is preserved (audio emits TWO items: <audio> tag + <br>;
# pdf emits its two-append div block). Bodies are lifted VERBATIM from the old
# if/elif branches, including the `src="{url}"style=` concatenation artifacts —
# byte-for-byte fidelity is the stage-5a contract.
def _render_img_400(ctx: 'RenderCtx') -> List[str]:
return [f'<img src="{ctx.url}" style="max-width:100%; width:auto; height:auto;'
f'max-height:400px; object-fit:contain;">']
def _render_video_400(ctx: 'RenderCtx') -> List[str]:
return [f'<video controls src="{ctx.url}" style="max-width:100%; width:auto;'
f'height:auto; max-height:400px;"></video>']
def _render_audio(ctx: 'RenderCtx') -> List[str]:
return [f'<audio controls style="width:100%; max-width:400px;">'
f'<source src="{ctx.url}" type="{ctx.mime}"></audio>',
'<br>']
def _render_video_loop_200(ctx: 'RenderCtx') -> List[str]:
return [f'<video controls autoplay loop muted src="{ctx.url}"'
f'style="max-width:100%; width:auto; height:auto; max-height:200px;'
f'object-fit:contain;"></video>']
def _render_img_200_sticker(ctx: 'RenderCtx') -> List[str]:
return [f'<img src="{ctx.url}" alt="Sticker {ctx.emoji}" style="max-width:100%;'
f'width:auto; height:auto; max-height:200px; object-fit:contain;">']
def _render_video_loop_400(ctx: 'RenderCtx') -> List[str]:
return [f'<video controls autoplay loop muted src="{ctx.url}"'
f'style="max-width:100%; width:auto; height:auto; max-height:400px;'
f'object-fit:contain;"></video>']
def _render_pdf(ctx: 'RenderCtx') -> List[str]:
return [f'<div class="document-pdf" style="padding: 10px;">',
f'<a href="{ctx.tg_link}" target="_blank">[PDF-файл]</a></div>']
RENDERERS: Dict[str, Callable[['RenderCtx'], List[str]]] = {
'img_400': _render_img_400,
'video_400': _render_video_400,
'audio': _render_audio,
'video_loop_200': _render_video_loop_200,
'img_200_sticker': _render_img_200_sticker,
'video_loop_400': _render_video_loop_400,
'pdf': _render_pdf,
}
#tests
#http://127.0.0.1:8000/post/html/DragorWW_space/114 — video
#http://127.0.0.1:8000/post/html/DragorWW_space/20 - many photos
@@ -840,7 +973,6 @@ class PostParser:
# Guard: channel_username may be None for private chats without a username
if not channel_username:
logger.warning(f"Could not generate media URL for message {message.id}: channel username is missing.")
content_media.append('</div>')
else:
file = f"{channel_username}/{message.id}/{file_unique_id}"
digest = generate_media_digest(file)
@@ -848,72 +980,35 @@ class PostParser:
logger.debug(f"Collected media file: {channel_username}/{message.id}/{file_unique_id}")
# Check if document is a PDF file
if (message.media == MessageMediaType.DOCUMENT and
message.document is not None and hasattr(message.document, 'mime_type') and
message.document.mime_type == 'application/pdf'):
# Only attempt to create link if channel_username is available
if channel_username.startswith('-100'):
tg_link = f"https://t.me/c/{channel_username[4:]}/{message.id}"
else:
tg_link = f"https://t.me/{channel_username}/{message.id}"
content_media.append(f'<div class="document-pdf" style="padding: 10px;">')
content_media.append(f'<a href="{tg_link}" target="_blank">[PDF-файл]</a></div>')
elif message.media in [MessageMediaType.PHOTO, MessageMediaType.DOCUMENT]:
content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;'
f'max-height:400px; object-fit:contain;">')
elif message.media == MessageMediaType.VIDEO:
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
f'height:auto; max-height:400px;"></video>')
elif message.media == MessageMediaType.ANIMATION:
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
f'height:auto; max-height:400px;"></video>')
elif message.media == MessageMediaType.VIDEO_NOTE:
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
f'height:auto; max-height:400px;"></video>')
elif message.media == MessageMediaType.AUDIO:
mime_type = getattr(message.audio, 'mime_type', 'audio/mpeg')
content_media.append(f'<audio controls style="width:100%; max-width:400px;">'
f'<source src="{url}" type="{mime_type}"></audio>')
content_media.append('<br>')
elif message.media == MessageMediaType.VOICE:
mime_type = getattr(message.voice, 'mime_type', 'audio/ogg')
content_media.append(f'<audio controls style="width:100%; max-width:400px;">'
f'<source src="{url}" type="{mime_type}"></audio>')
content_media.append('<br>')
elif message.media == MessageMediaType.STICKER:
emoji = getattr(message.sticker, 'emoji', '')
if getattr(message.sticker, 'is_video', False):
content_media.append(f'<video controls autoplay loop muted src="{url}"'
f'style="max-width:100%; width:auto; height:auto; max-height:200px;'
f'object-fit:contain;"></video>')
else:
content_media.append(f'<img src="{url}" alt="Sticker {emoji}" style="max-width:100%;'
f'width:auto; height:auto; max-height:200px; object-fit:contain;">')
elif message.media == MessageMediaType.LIVE_PHOTO:
# Live photo is effectively a short video clip.
content_media.append(f'<video controls autoplay loop muted src="{url}"'
f'style="max-width:100%; width:auto; height:auto; max-height:400px;'
f'object-fit:contain;"></video>')
elif message.media == MessageMediaType.STORY:
# Choose the tag from the SAME helper that produced the URL's
# file_unique_id, so the tag type always matches the URL object.
story_media_obj, story_media_kind = _story_media_object(message)
if story_media_obj is not None and story_media_kind == 'video':
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
f'height:auto; max-height:400px;"></video>')
elif story_media_obj is not None:
content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;'
f'max-height:400px; object-fit:contain;">')
elif message.media == MessageMediaType.POLL and poll_media_obj is not None:
if poll_media_kind == 'video':
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
f'height:auto; max-height:400px;"></video>')
else:
content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;'
f'max-height:400px; object-fit:contain;">')
content_media.append('</div>')
# Dispatch through the media table: the selector gives the render
# kind, the RENDERERS map gives the fragment. mime/emoji/tg_link
# defaults are chosen here BY MEDIA TYPE (the renderer never guesses);
# kind None (WEB_PAGE) emits no element — the empty container matches
# the old cascade where no branch matched.
selector = MEDIA_SOURCES.get(message.media)
kind = selector(message)[1] if selector is not None else None
renderer = RENDERERS.get(kind) if kind else None
if renderer is not None:
ctx = RenderCtx(url=url)
if kind == 'pdf':
if channel_username.startswith('-100'):
ctx.tg_link = f"https://t.me/c/{channel_username[4:]}/{message.id}"
else:
ctx.tg_link = f"https://t.me/{channel_username}/{message.id}"
elif kind == 'audio':
if message.media == MessageMediaType.VOICE:
ctx.mime = getattr(message.voice, 'mime_type', 'audio/ogg')
else:
ctx.mime = getattr(message.audio, 'mime_type', 'audio/mpeg')
elif kind == 'img_200_sticker':
ctx.emoji = getattr(message.sticker, 'emoji', '')
content_media.extend(renderer(ctx))
# Registry §3.14: close the message-media container in EVERY branch. It used
# to be closed only when a URL was built (or the username guard fired), so a
# None file_unique_id (e.g. WEB_PAGE without photo) left an open <div> that
# html5lib later swallowed the following posts into.
content_media.append('</div>')
if webpage := getattr(message, "web_page", None): # Web page preview
if webpage_html := self._format_webpage(webpage, message):
if len((message.text or message.caption or '').strip()) <= 10:
@@ -1231,28 +1326,12 @@ class PostParser:
def _get_file_unique_id(self, message: Message) -> Union[str, None]:
try:
media_mapping = {
MessageMediaType.PHOTO: lambda m: m.photo.file_unique_id,
MessageMediaType.VIDEO: lambda m: m.video.file_unique_id,
MessageMediaType.DOCUMENT: lambda m: m.document.file_unique_id,
MessageMediaType.AUDIO: lambda m: m.audio.file_unique_id,
MessageMediaType.VOICE: lambda m: m.voice.file_unique_id,
MessageMediaType.VIDEO_NOTE: lambda m: m.video_note.file_unique_id,
MessageMediaType.ANIMATION: lambda m: m.animation.file_unique_id,
MessageMediaType.STICKER: lambda m: m.sticker.file_unique_id,
MessageMediaType.WEB_PAGE: lambda m: m.web_page.photo.file_unique_id if m.web_page and m.web_page.photo else None,
# New media types (Kurigram 2.2.23): getattr-only access, the
# attributes do not exist on older Message objects/mocks.
MessageMediaType.LIVE_PHOTO: lambda m: getattr(getattr(m, 'live_photo', None), 'file_unique_id', None),
MessageMediaType.STORY: lambda m: getattr(_story_media_object(m)[0], 'file_unique_id', None),
MessageMediaType.POLL: lambda m: getattr(_poll_media_object(m)[0], 'file_unique_id', None),
}
if message.media in media_mapping:
return media_mapping[message.media](message)
return None
selector = MEDIA_SOURCES.get(message.media)
if selector is None:
return None
selected_obj, _kind = selector(message)
return getattr(selected_obj, 'file_unique_id', None)
except Exception as e:
logger.error(f"file_id_extraction_error: media_type {message.media}, error {str(e)}")
return None
@@ -1289,40 +1368,20 @@ class PostParser:
return
if message.media:
# Skip large videos - they shouldn't be cached permanently
if message.video and message.video.file_size and message.video.file_size > 100 * 1024 * 1024:
# Object selection goes through the single MEDIA_SOURCES table instead
# of the old truthy-attribute if/elif ladder. paid_media has no table
# entry and is deliberately NOT collected (paid content).
selector = MEDIA_SOURCES.get(message.media)
selected_obj = selector(message)[0] if selector is not None else None
# Registry §3.13: the ">100MB don't cache" rule applies to ANY selected
# media object, not just message.video (the old code guarded only the
# video type here, plus the newer live_photo/story/poll sources).
file_size = getattr(selected_obj, 'file_size', None)
if isinstance(file_size, int) and file_size > 100 * 1024 * 1024:
return
file_unique_id = ''
new_media_obj = None # selected object among the Kurigram 2.2.23 media sources
if message.photo: file_unique_id = message.photo.file_unique_id
elif message.video: file_unique_id = message.video.file_unique_id
elif message.document: file_unique_id = message.document.file_unique_id
elif message.audio: file_unique_id = message.audio.file_unique_id
elif message.voice: file_unique_id = message.voice.file_unique_id
elif message.video_note: file_unique_id = message.video_note.file_unique_id
elif message.animation: file_unique_id = message.animation.file_unique_id
elif message.sticker: file_unique_id = message.sticker.file_unique_id
elif message.web_page and message.web_page.photo:
file_unique_id = message.web_page.photo.file_unique_id
# New media types (Kurigram 2.2.23): getattr-only, the attributes do
# not exist on older Message objects/mocks. paid_media is deliberately
# NOT collected — it cannot be downloaded (paid content).
elif getattr(message, 'live_photo', None):
new_media_obj = message.live_photo
elif (story_media := _story_media_object(message)[0]) is not None:
new_media_obj = story_media
elif (poll_media := _poll_media_object(message)[0]) is not None:
new_media_obj = poll_media
if new_media_obj is not None:
# The >100MB message.video guard above does not cover these
# sources (live photo, story video, poll description video) —
# apply the same "don't cache large videos" rule here.
file_size = getattr(new_media_obj, 'file_size', None)
if isinstance(file_size, int) and file_size > 100 * 1024 * 1024:
return
file_unique_id = getattr(new_media_obj, 'file_unique_id', '') or ''
file_unique_id = getattr(selected_obj, 'file_unique_id', '') or ''
if file_unique_id:
added_ts = datetime.now().timestamp()
+211
View File
@@ -0,0 +1,211 @@
# flake8: noqa
# pylint: disable=import-outside-toplevel, missing-function-docstring, line-too-long
"""Stage-5 fragment-level snapshot helpers (render-pipeline refactor epic, issue #32/#34).
Captures the raw `_generate_html_media` output (BEFORE sanitize) for every media
render kind and edge branch, as a SEPARATE oracle layer from the stage-0 feed goldens
(spec §4: "два слоя эталонов, не смешивать").
`media_fragments.json` is the PRE-REFACTOR BASE reference: it was captured by running
THIS harness against the BASE `post_parser.py` checkout (the pre-refactor code), NOT
against stage-5 code — so it is a genuine base-anchored golden layer, not a circular
self-snapshot of the refactor. The 5a refactor (MEDIA_SOURCES table + renderers) must
reproduce these base fragments byte-for-byte. The ONLY 5b-registered changes vs the base
are the two entries in REGISTERED_DELTAS (§3.14 unclosed-div close); the §3.13 large-file
guard is collection-only and changes no fragment bytes.
Cases whose type exists in the recorded corpus could be pulled from it, but the media
FRAGMENT is a pure function of a single Message, so deterministic hand-built mocks give
the same bytes with far less machinery and also reach the mock-only exotic types
(PAID_MEDIA, LIVE_PHOTO, STORY) — exactly the set the spec allows mocks for.
Run `python -m tests.media_fragment_replay` from the repo root to (re)generate the snapshot.
"""
import os
import json
from types import SimpleNamespace
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(TESTS_DIR)
SNAPSHOT_PATH = os.path.join(TESTS_DIR, "test_data", "media_fragments.json")
# Fixed signing key so digests in the snapshot reproduce on any checkout (mirrors the
# stage-0 golden pin). Applied by the generator and by the comparison test.
FRAGMENT_SIGNING_KEY = "stage5-fragment-fixed-signing-key-000000000000"
class _Str(str):
"""Minimal stand-in for Pyrogram's Str: .html returns the raw string unchanged."""
@property
def html(self):
return str(self)
def _msg(mid, media, *, text=None, username="testchan", chat_id=-1001234567890, **extra):
"""Build a Message-like mock with the attributes _generate_html_media touches.
Top-level media attributes default to None (truthy checks in _save_media_file_ids);
new Kurigram 2.2.23 attributes (live_photo/story/giveaway/...) are intentionally
absent unless passed, so getattr-only production code is exercised as on real objects.
"""
m = SimpleNamespace()
m.id = mid
m.media = media
m.text = _Str(text) if text is not None else None
m.caption = None
m.web_page = None
m.poll = None
m.paid_media = None
m.forward_origin = None
m.show_caption_above_media = False
if chat_id is None and username is None:
m.chat = None
else:
m.chat = SimpleNamespace(id=chat_id, username=username)
for attr in ("photo", "video", "document", "audio", "voice",
"video_note", "animation", "sticker"):
setattr(m, attr, None)
for key, value in extra.items():
setattr(m, key, value)
return m
def _obj(**kw):
return SimpleNamespace(**kw)
# --------------------------------------------------------------------------- #
# Fragment cases. Each entry: name -> Message factory.
# Covers the full spec §5a "Шаг 0" list + edge branches.
# --------------------------------------------------------------------------- #
def _webpage(photo=None, url="https://example.com", title="Example",
description=None, site_name=None, wp_type=""):
return _obj(photo=photo, url=url, title=title, description=description,
site_name=site_name, type=wp_type, display_url=None)
def build_cases():
from pyrogram.enums import MessageMediaType as T
cases = {}
cases["photo"] = lambda: _msg(1, T.PHOTO, photo=_obj(file_unique_id="ph_uid", file_id="ph_fid"))
cases["video"] = lambda: _msg(2, T.VIDEO, video=_obj(file_unique_id="vid_uid", file_id="vid_fid", file_size=1024))
cases["animation"] = lambda: _msg(3, T.ANIMATION, animation=_obj(file_unique_id="ani_uid", file_id="ani_fid"))
cases["video_note"] = lambda: _msg(4, T.VIDEO_NOTE, video_note=_obj(file_unique_id="vn_uid", file_id="vn_fid"))
cases["audio_default_mime"] = lambda: _msg(5, T.AUDIO, audio=_obj(file_unique_id="au_uid", file_id="au_fid"))
cases["audio_explicit_mime"] = lambda: _msg(6, T.AUDIO, audio=_obj(file_unique_id="au2_uid", file_id="au2_fid", mime_type="audio/flac"))
cases["voice_default_mime"] = lambda: _msg(7, T.VOICE, voice=_obj(file_unique_id="vo_uid", file_id="vo_fid"))
cases["voice_explicit_mime"] = lambda: _msg(8, T.VOICE, voice=_obj(file_unique_id="vo2_uid", file_id="vo2_fid", mime_type="audio/wav"))
cases["sticker_img"] = lambda: _msg(9, T.STICKER, sticker=_obj(file_unique_id="st_uid", file_id="st_fid", emoji="😀", is_video=False))
cases["sticker_video"] = lambda: _msg(10, T.STICKER, sticker=_obj(file_unique_id="stv_uid", file_id="stv_fid", emoji="🎬", is_video=True))
cases["document_pdf_public"] = lambda: _msg(11, T.DOCUMENT, username="pubchan", chat_id=None,
document=_obj(file_unique_id="pdf_uid", file_id="pdf_fid", mime_type="application/pdf"))
cases["document_pdf_private"] = lambda: _msg(12, T.DOCUMENT, username=None, chat_id=-1009876543210,
document=_obj(file_unique_id="pdf2_uid", file_id="pdf2_fid", mime_type="application/pdf"))
cases["document_normal"] = lambda: _msg(13, T.DOCUMENT, document=_obj(file_unique_id="doc_uid", file_id="doc_fid", mime_type="image/png"))
cases["live_photo"] = lambda: _msg(14, T.LIVE_PHOTO, live_photo=_obj(file_unique_id="lp_uid", file_id="lp_fid"))
cases["story_video"] = lambda: _msg(15, T.STORY, story=_obj(video=_obj(file_unique_id="sv_uid", file_id="sv_fid"), photo=None))
cases["story_photo"] = lambda: _msg(16, T.STORY, story=_obj(video=None, photo=_obj(file_unique_id="sp_uid", file_id="sp_fid")))
cases["poll_media_img"] = lambda: _msg(17, T.POLL, poll=_obj(question=_Str("Q?"), options=[],
description_media=_obj(photo=_obj(file_unique_id="pl_uid", file_id="pl_fid"))))
cases["poll_media_video"] = lambda: _msg(18, T.POLL, poll=_obj(question=_Str("Q?"), options=[],
description_media=_obj(video=_obj(file_unique_id="plv_uid", file_id="plv_fid"))))
cases["paid_media"] = lambda: _msg(19, T.PAID_MEDIA,
paid_media=_obj(stars_amount=50, media=[_obj(), _obj()]))
# WEB_PAGE with photo: opens an EMPTY message-media div (no elif matches WEB_PAGE),
# plus the webpage-preview block (short text gate <=10).
cases["webpage_with_photo"] = lambda: _msg(20, T.WEB_PAGE, text="hi",
web_page=_webpage(photo=_obj(file_unique_id="wp_uid", file_id="wp_fid")))
# WEB_PAGE without photo: file_unique_id is None -> message-media div opened and
# left UNCLOSED (§3.14 target). Short text so the preview block renders.
cases["webpage_without_photo"] = lambda: _msg(21, T.WEB_PAGE, text="hi",
web_page=_webpage(photo=None))
# WEB_PAGE with photo but long text (>10): preview gate closed, only the empty media div.
cases["webpage_photo_long_text"] = lambda: _msg(22, T.WEB_PAGE,
text="this text is definitely longer than ten characters",
web_page=_webpage(photo=_obj(file_unique_id="wpl_uid", file_id="wpl_fid")))
# file_unique_id is None on a normal media type -> unclosed div (§3.14 target).
cases["file_unique_id_none"] = lambda: _msg(23, T.PHOTO, photo=_obj(file_unique_id=None, file_id="x_fid"))
# channel_username is None but uid present -> the div IS closed on the guard branch.
cases["channel_username_none"] = lambda: _msg(24, T.PHOTO, username=None, chat_id=555,
photo=_obj(file_unique_id="cu_uid", file_id="cu_fid"))
return cases
# --------------------------------------------------------------------------- #
# Registered 5b deltas vs the pre-refactor base snapshot.
#
# Spec §3.14: the empty <div class="message-media"> container is now CLOSED in every
# render branch. The base (pre-refactor) code left it OPEN whenever the selected media
# object had no usable file_unique_id, so the base snapshot captured an unbalanced div
# for exactly these two cases. `collected` is byte-identical to the base; only `html`
# differs by the added `</div>`. These are the ONLY fragments the 5b registered fixes
# change relative to the pre-refactor base — every other case reproduces base bytes.
REGISTERED_DELTAS = {
"file_unique_id_none": {
"collected": [],
"html": "<div class=\"message-media\">\n</div>",
},
"webpage_without_photo": {
"collected": [],
"html": "<div class=\"message-media\">\n</div>\n<div class=\"webpage-preview\">\n<div class=\"webpage-preview\" style=\"border-left: 3px solid #ccc; padding-left: 10px; margin: 10px 0;\">\n<div class=\"webpage-title\" style=\"font-weight:bold; margin:5px 0;\">\n<a href=\"https://example.com\" target=\"_blank\">Example</a></div>\n<div class=\"webpage-url\" style=\"color:#666; font-size:0.9em;margin-bottom:5px;\">https://example.com</div>\n</div>\n</div>",
},
}
# --------------------------------------------------------------------------- #
# Capture / compare.
# --------------------------------------------------------------------------- #
def capture_fragments():
"""Return {case_name: {"html": fragment, "collected": [[chan, id, fuid], ...]}}.
Covers all three ladders in one shot: _generate_html_media renders the fragment
(render ladder + _get_file_unique_id ladder) and calls _save_media_file_ids
(collection ladder), whose result is read off _pending_media_ids."""
from post_parser import PostParser
parser = PostParser(SimpleNamespace())
out = {}
for name, factory in build_cases().items():
parser._pending_media_ids = []
html = parser._generate_html_media(factory())
collected = [[c, p, f] for c, p, f, _ in parser._pending_media_ids]
out[name] = {"html": html, "collected": collected}
return out
def pin_signing_key(monkeypatch):
from url_signer import KeyManager
monkeypatch.setattr(KeyManager, "signing_key", FRAGMENT_SIGNING_KEY)
def load_snapshot():
with open(SNAPSHOT_PATH, encoding="utf-8") as f:
return json.load(f)
def _bootstrap_standalone():
import sys
import time
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
import tests.mock_config as _mock_config
sys.modules["config"] = _mock_config
os.environ["TZ"] = "UTC"
time.tzset()
def generate_snapshot():
# WARNING: this MUST be run against the BASE (pre-refactor) `post_parser.py`, never
# against stage-5 code. Regenerating from stage-5 output would make the oracle a
# circular self-snapshot instead of a base-anchored golden layer (spec §4).
from url_signer import KeyManager
KeyManager.signing_key = FRAGMENT_SIGNING_KEY
data = capture_fragments()
os.makedirs(os.path.dirname(SNAPSHOT_PATH), exist_ok=True)
with open(SNAPSHOT_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
print(f"wrote {len(data)} fragment snapshots to {SNAPSHOT_PATH}")
if __name__ == "__main__":
_bootstrap_standalone()
generate_snapshot()
@@ -982,33 +982,36 @@
<hr class="post-divider">
<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
Я тут одним днем был в Екатеринбурге. На конференции по кибербезопасности. На уральском ТВ записали короткое интервью. Не для экспертов, для обычных жителей планеты Земля - что такое кибербез простыми словами, какие угрозы сейчас и что делать - быть может кому-то будет полезно. <br><br>*** по каким-то мистическим причинам, не всегда с мобилы проигрывается, с десктопа - точно работает. Не знаю, почему. <br><br>p.s. На Мэта Деймона времени не хватило (с) :))<br><br><a href="https://www.obltv.ru/broadcasting/programs/479443-sobytija_akcent/releases/4840574-evgeniy-chereshnev-seychas-khakery-eto-ne-studenty-idealisty-a-organizovannye-nayemniki/" target="_blank">https://www.obltv.ru/broadcasting/programs/479443-sobytija_akcent/releases/4840574-evgeniy-chereshnev-seychas-khakery-eto-ne-studenty-idealisty-a-organizovannye-nayemniki/</a>
<br>
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">👍 867&nbsp;&nbsp;</span><span class="reaction">❤ 233&nbsp;&nbsp;</span><span class="reaction">🔥 51&nbsp;&nbsp;</span><span class="reaction">👏 42&nbsp;&nbsp;</span><span class="reaction">🦄 10&nbsp;&nbsp;</span><span class="reaction">🎉 5&nbsp;&nbsp;</span><span class="reaction">💩 4&nbsp;&nbsp;</span><span class="reaction">🥰 3&nbsp;&nbsp;</span><span class="reaction">😁 3&nbsp;&nbsp;</span><span class="reaction">🌚 2&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">121878 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">20/02/24, 14:01:23</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#306</span><br><a href="tg://resolve?domain=bladerunnerblues&amp;post=306">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/bladerunnerblues/306">Open in Web</a>
<br><div class="message-flags"> 🏷 link </div></div></div>
<br><div class="message-flags"> 🏷 link </div></div>
<hr class="post-divider">
<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
<b>[ХОРОШИЕ НОВОСТИ ОБ ИННОВАЦИЯХ НА НЕСКОЛЬКО МИЛЛИАРДОВ ЗА ГОД И ДЕТЯХ, РОЖДЕННЫХ В СССР]</b><br><br>Дорогой экипаж!<br><br>Вдохновения и мотивации пост.<br>Не собирался писать именно это и так эмоционально, но потом перечитал и решил, что не буду редактировать.<br>И, даже если кто-то отпишется, – я никого не держу. Просто в это турбулентное время, хочется продолжать дарить вам надежду в лучшее, а не поощрять самоуничижение, как это делают многие.<br> <br>Хочу констатировать факты: не все уехали, не все плохо, нет, мы умеем делать сильные инженерные рывки в России (а не только в ВПК), нам есть, чем гордится и да, я считаю, что мы единый народ. <br>Хочу, чтобы кому-то этот пост помог улыбнуться и ступать легко и уверенно. <br><br><a href="https://blog.cheresh.me/2024/02/20/billions-in-innovations-and-ussr/" target="_blank">https://blog.cheresh.me/2024/02/20/billions-in-innovations-and-ussr/</a><br><br>P.S. Репосты приветствуются. А вот воровать не надо. Кто ворует мои посты - вы выглядите не очень умно:) Вы думаете, люди не видят, что ли?:) Карма is a bitch.
<br>
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">👍 2200&nbsp;&nbsp;</span><span class="reaction">❤ 916&nbsp;&nbsp;</span><span class="reaction">🔥 408&nbsp;&nbsp;</span><span class="reaction">🍾 70&nbsp;&nbsp;</span><span class="reaction">⚡ 23&nbsp;&nbsp;</span><span class="reaction">🤣 16&nbsp;&nbsp;</span><span class="reaction">🥰 12&nbsp;&nbsp;</span><span class="reaction">👌 11&nbsp;&nbsp;</span><span class="reaction">👎 10&nbsp;&nbsp;</span><span class="reaction">👻 5&nbsp;&nbsp;</span><span class="reaction">🥱 3&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">690677 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">20/02/24, 10:10:22</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#305</span><br><a href="tg://resolve?domain=bladerunnerblues&amp;post=305">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/bladerunnerblues/305">Open in Web</a>
<br><div class="message-flags"> 🏷 link </div></div></div>
<br><div class="message-flags"> 🏷 link </div></div>
<hr class="post-divider">
<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
Экипаж!<br><br>Менять мир непросто. Иногда нам нужна правильная музыка. Приказ по радиорубке - включаем с утра на всех палубах и заряжаемся. Нас ждет прыжок в созвездие Ориона. <br><br><a href="https://youtu.be/9N_AinKeT_g?si=SFAulfGSNuGmh9QU" target="_blank">https://youtu.be/9N_AinKeT_g?si=SFAulfGSNuGmh9QU</a><br><br>P.S. если сегодня будете где-то слышать этот трек - знайте, что его поставил ваш брат или сестра с этого корабля:) и улыбнитесь:) <br>с пятницей!
<br>
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">👍 1208&nbsp;&nbsp;</span><span class="reaction">❤ 447&nbsp;&nbsp;</span><span class="reaction">🔥 255&nbsp;&nbsp;</span><span class="reaction">❤‍🔥 55&nbsp;&nbsp;</span><span class="reaction">🫡 49&nbsp;&nbsp;</span><span class="reaction">😁 37&nbsp;&nbsp;</span><span class="reaction">🤔 9&nbsp;&nbsp;</span><span class="reaction">🤷‍♂ 6&nbsp;&nbsp;</span><span class="reaction">🗿 5&nbsp;&nbsp;</span><span class="reaction">🤡 4&nbsp;&nbsp;</span><span class="reaction">👾 4&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">110203 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">16/02/24, 05:23:23</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#304</span><br><a href="tg://resolve?domain=bladerunnerblues&amp;post=304">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/bladerunnerblues/304">Open in Web</a>
<br><div class="message-flags"> 🏷 link </div></div></div>
<br><div class="message-flags"> 🏷 link </div></div>
<hr class="post-divider">
<div class="message-body"><div class="post">
<div class="message-media">
@@ -1087,13 +1090,14 @@
<hr class="post-divider">
<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
<b>[ПРОГНОЗЫ 2024, Часть 1/14: Выборы на Тайване] <br><br></b>Дорогой экипаж! <b><br></b>Завтра очень важный день для планеты Земля - пройдут выборы президента Тайваня. Почему они критичны для всех и каждого и причем тут Искусственный Интеллект и триллионы долларов - постарался описать максимально коротко. Прогнозы делать - дело неблагодарное, но я решил начать цикл постов о том, что нас ждет в 2024, что важно и что имеет все шансы навсегда изменить мир. <br><br><a href="https://blog.cheresh.me/2024/01/12/really-unreal-forecasts-2024_114/" target="_blank">https://blog.cheresh.me/2024/01/12/really-unreal-forecasts-2024_114/</a>
<br>
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">👍 3107&nbsp;&nbsp;</span><span class="reaction">❤ 788&nbsp;&nbsp;</span><span class="reaction">🔥 382&nbsp;&nbsp;</span><span class="reaction">🤔 52&nbsp;&nbsp;</span><span class="reaction">👏 24&nbsp;&nbsp;</span><span class="reaction">⚡ 21&nbsp;&nbsp;</span><span class="reaction">🐳 10&nbsp;&nbsp;</span><span class="reaction">👎 9&nbsp;&nbsp;</span><span class="reaction">😎 9&nbsp;&nbsp;</span><span class="reaction">👌 4&nbsp;&nbsp;</span><span class="reaction">🤣 3&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">120670 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">12/01/24, 16:52:43</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#296</span><br><a href="tg://resolve?domain=bladerunnerblues&amp;post=296">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/bladerunnerblues/296">Open in Web</a>
<br><div class="message-flags"> 🏷 link </div></div></div>
<br><div class="message-flags"> 🏷 link </div></div>
<hr class="post-divider">
<div class="message-body"><div class="post">
<div class="message-media">
@@ -32,13 +32,14 @@
<description>[ПРОГНОЗЫ 2024, Часть 1/14: Выборы на Тайване] Дорогой экипаж! Завтра очень важный день для планеты Земля - пройдут выборы президента Тайваня. Почему они критичны для всех и каждого и причем тут Искусственный Интеллект и триллионы долларов - постарался описать максимально коротко. Прогнозы делать - дело неблагодарное, но я решил начать цикл постов о том, что нас ждет в 2024, что важно и что имеет все шансы навсегда изменить мир. https://blog.cheresh.me/2024/01/12/really-unreal-forecasts-2024_114/</description>
<content:encoded><![CDATA[<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
<b>[ПРОГНОЗЫ 2024, Часть 1/14: Выборы на Тайване] <br><br></b>Дорогой экипаж! <b><br></b>Завтра очень важный день для планеты Земля - пройдут выборы президента Тайваня. Почему они критичны для всех и каждого и причем тут Искусственный Интеллект и триллионы долларов - постарался описать максимально коротко. Прогнозы делать - дело неблагодарное, но я решил начать цикл постов о том, что нас ждет в 2024, что важно и что имеет все шансы навсегда изменить мир. <br><br><a href="https://blog.cheresh.me/2024/01/12/really-unreal-forecasts-2024_114/" target="_blank">https://blog.cheresh.me/2024/01/12/really-unreal-forecasts-2024_114/</a>
<br>
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">👍 3107&nbsp;&nbsp;</span><span class="reaction">❤ 788&nbsp;&nbsp;</span><span class="reaction">🔥 382&nbsp;&nbsp;</span><span class="reaction">🤔 52&nbsp;&nbsp;</span><span class="reaction">👏 24&nbsp;&nbsp;</span><span class="reaction">⚡ 21&nbsp;&nbsp;</span><span class="reaction">🐳 10&nbsp;&nbsp;</span><span class="reaction">👎 9&nbsp;&nbsp;</span><span class="reaction">😎 9&nbsp;&nbsp;</span><span class="reaction">👌 4&nbsp;&nbsp;</span><span class="reaction">🤣 3&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">120670 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">12/01/24, 16:52:43</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#296</span><br><a href="tg://resolve?domain=bladerunnerblues&amp;post=296">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/bladerunnerblues/296">Open in Web</a>
<br><div class="message-flags"> 🏷 link </div></div></div>]]></content:encoded>
<br><div class="message-flags"> 🏷 link </div></div>]]></content:encoded>
<guid isPermaLink="true">https://t.me/bladerunnerblues/296</guid>
<pubDate>Fri, 12 Jan 2024 16:52:43 +0000</pubDate>
</item>
@@ -153,13 +154,14 @@
<description>Экипаж! Менять мир непросто. Иногда нам нужна правильная музыка. Приказ по радиорубке - включаем с утра на всех палубах и заряжаемся. Нас ждет прыжок в созвездие Ориона. https://youtu.be/9N_AinKeT_g?si=SFAulfGSNuGmh9QU P.S. если сегодня будете где-то слышать этот трек - знайте, что его поставил ваш брат или сестра с этого корабля:) и улыбнитесь:) с пятницей!</description>
<content:encoded><![CDATA[<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
Экипаж!<br><br>Менять мир непросто. Иногда нам нужна правильная музыка. Приказ по радиорубке - включаем с утра на всех палубах и заряжаемся. Нас ждет прыжок в созвездие Ориона. <br><br><a href="https://youtu.be/9N_AinKeT_g?si=SFAulfGSNuGmh9QU" target="_blank">https://youtu.be/9N_AinKeT_g?si=SFAulfGSNuGmh9QU</a><br><br>P.S. если сегодня будете где-то слышать этот трек - знайте, что его поставил ваш брат или сестра с этого корабля:) и улыбнитесь:) <br>с пятницей!
<br>
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">👍 1208&nbsp;&nbsp;</span><span class="reaction">❤ 447&nbsp;&nbsp;</span><span class="reaction">🔥 255&nbsp;&nbsp;</span><span class="reaction">❤‍🔥 55&nbsp;&nbsp;</span><span class="reaction">🫡 49&nbsp;&nbsp;</span><span class="reaction">😁 37&nbsp;&nbsp;</span><span class="reaction">🤔 9&nbsp;&nbsp;</span><span class="reaction">🤷‍♂ 6&nbsp;&nbsp;</span><span class="reaction">🗿 5&nbsp;&nbsp;</span><span class="reaction">🤡 4&nbsp;&nbsp;</span><span class="reaction">👾 4&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">110203 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">16/02/24, 05:23:23</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#304</span><br><a href="tg://resolve?domain=bladerunnerblues&amp;post=304">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/bladerunnerblues/304">Open in Web</a>
<br><div class="message-flags"> 🏷 link </div></div></div>]]></content:encoded>
<br><div class="message-flags"> 🏷 link </div></div>]]></content:encoded>
<guid isPermaLink="true">https://t.me/bladerunnerblues/304</guid>
<pubDate>Fri, 16 Feb 2024 05:23:23 +0000</pubDate>
</item>
@@ -169,13 +171,14 @@
<description>[ХОРОШИЕ НОВОСТИ ОБ ИННОВАЦИЯХ НА НЕСКОЛЬКО МИЛЛИАРДОВ ЗА ГОД И ДЕТЯХ, РОЖДЕННЫХ В СССР] Дорогой экипаж! Вдохновения и мотивации пост. Не собирался писать именно это и так эмоционально, но потом перечитал и решил, что не буду редактировать. И, даже если кто-то отпишется, – я никого не держу. Просто в это турбулентное время, хочется продолжать дарить вам надежду в лучшее, а не поощрять самоуничижение, как это делают многие. Хочу констатировать факты: не все уехали, не все плохо, нет, мы умеем делать сильные инженерные рывки в России (а не только в ВПК), нам есть, чем гордится и да, я считаю, что мы единый народ. Хочу, чтобы кому-то этот пост помог улыбнуться и ступать легко и уверенно. https://blog.cheresh.me/2024/02/20/billions-in-innovations-and-ussr/ P.S. Репосты приветствуются. А вот воровать не надо. Кто ворует мои посты - вы выглядите не очень умно:) Вы думаете, люди не видят, что ли?:) Карма is a bitch.</description>
<content:encoded><![CDATA[<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
<b>[ХОРОШИЕ НОВОСТИ ОБ ИННОВАЦИЯХ НА НЕСКОЛЬКО МИЛЛИАРДОВ ЗА ГОД И ДЕТЯХ, РОЖДЕННЫХ В СССР]</b><br><br>Дорогой экипаж!<br><br>Вдохновения и мотивации пост.<br>Не собирался писать именно это и так эмоционально, но потом перечитал и решил, что не буду редактировать.<br>И, даже если кто-то отпишется, – я никого не держу. Просто в это турбулентное время, хочется продолжать дарить вам надежду в лучшее, а не поощрять самоуничижение, как это делают многие.<br> <br>Хочу констатировать факты: не все уехали, не все плохо, нет, мы умеем делать сильные инженерные рывки в России (а не только в ВПК), нам есть, чем гордится и да, я считаю, что мы единый народ. <br>Хочу, чтобы кому-то этот пост помог улыбнуться и ступать легко и уверенно. <br><br><a href="https://blog.cheresh.me/2024/02/20/billions-in-innovations-and-ussr/" target="_blank">https://blog.cheresh.me/2024/02/20/billions-in-innovations-and-ussr/</a><br><br>P.S. Репосты приветствуются. А вот воровать не надо. Кто ворует мои посты - вы выглядите не очень умно:) Вы думаете, люди не видят, что ли?:) Карма is a bitch.
<br>
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">👍 2200&nbsp;&nbsp;</span><span class="reaction">❤ 916&nbsp;&nbsp;</span><span class="reaction">🔥 408&nbsp;&nbsp;</span><span class="reaction">🍾 70&nbsp;&nbsp;</span><span class="reaction">⚡ 23&nbsp;&nbsp;</span><span class="reaction">🤣 16&nbsp;&nbsp;</span><span class="reaction">🥰 12&nbsp;&nbsp;</span><span class="reaction">👌 11&nbsp;&nbsp;</span><span class="reaction">👎 10&nbsp;&nbsp;</span><span class="reaction">👻 5&nbsp;&nbsp;</span><span class="reaction">🥱 3&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">690677 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">20/02/24, 10:10:22</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#305</span><br><a href="tg://resolve?domain=bladerunnerblues&amp;post=305">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/bladerunnerblues/305">Open in Web</a>
<br><div class="message-flags"> 🏷 link </div></div></div>]]></content:encoded>
<br><div class="message-flags"> 🏷 link </div></div>]]></content:encoded>
<guid isPermaLink="true">https://t.me/bladerunnerblues/305</guid>
<pubDate>Tue, 20 Feb 2024 10:10:22 +0000</pubDate>
</item>
@@ -185,13 +188,14 @@
<description>Я тут одним днем был в Екатеринбурге. На конференции по кибербезопасности. На уральском ТВ записали короткое интервью. Не для экспертов, для обычных жителей планеты Земля - что такое кибербез простыми словами, какие угрозы сейчас и что делать - быть может кому-то будет полезно. *** по каким-то мистическим причинам, не всегда с мобилы проигрывается, с десктопа - точно работает. Не знаю, почему. p.s. На Мэта Деймона времени не хватило (с) :)) https://www.obltv.ru/broadcasting/programs/479443-sobytija_akcent/releases/4840574-evgeniy-chereshnev-seychas-khakery-eto-ne-studenty-idealisty-a-organizovannye-nayemniki/</description>
<content:encoded><![CDATA[<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
Я тут одним днем был в Екатеринбурге. На конференции по кибербезопасности. На уральском ТВ записали короткое интервью. Не для экспертов, для обычных жителей планеты Земля - что такое кибербез простыми словами, какие угрозы сейчас и что делать - быть может кому-то будет полезно. <br><br>*** по каким-то мистическим причинам, не всегда с мобилы проигрывается, с десктопа - точно работает. Не знаю, почему. <br><br>p.s. На Мэта Деймона времени не хватило (с) :))<br><br><a href="https://www.obltv.ru/broadcasting/programs/479443-sobytija_akcent/releases/4840574-evgeniy-chereshnev-seychas-khakery-eto-ne-studenty-idealisty-a-organizovannye-nayemniki/" target="_blank">https://www.obltv.ru/broadcasting/programs/479443-sobytija_akcent/releases/4840574-evgeniy-chereshnev-seychas-khakery-eto-ne-studenty-idealisty-a-organizovannye-nayemniki/</a>
<br>
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">👍 867&nbsp;&nbsp;</span><span class="reaction">❤ 233&nbsp;&nbsp;</span><span class="reaction">🔥 51&nbsp;&nbsp;</span><span class="reaction">👏 42&nbsp;&nbsp;</span><span class="reaction">🦄 10&nbsp;&nbsp;</span><span class="reaction">🎉 5&nbsp;&nbsp;</span><span class="reaction">💩 4&nbsp;&nbsp;</span><span class="reaction">🥰 3&nbsp;&nbsp;</span><span class="reaction">😁 3&nbsp;&nbsp;</span><span class="reaction">🌚 2&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">121878 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">20/02/24, 14:01:23</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#306</span><br><a href="tg://resolve?domain=bladerunnerblues&amp;post=306">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/bladerunnerblues/306">Open in Web</a>
<br><div class="message-flags"> 🏷 link </div></div></div>]]></content:encoded>
<br><div class="message-flags"> 🏷 link </div></div>]]></content:encoded>
<guid isPermaLink="true">https://t.me/bladerunnerblues/306</guid>
<pubDate>Tue, 20 Feb 2024 14:01:23 +0000</pubDate>
</item>
+2 -1
View File
@@ -1014,6 +1014,7 @@ The SM9 module boasts <b>hardware compatibility</b> with NVIDIA Jetson series mo
<div class="message-forward">--- Forwarded from асоциальный пикотранзистор ᶘಠᴥಠᶅ ---</div>
<br>
<div class="message-media">
</div>
<br>
Парсинг через описание структуры мне сразу напомнил про <a href="https://kaitai.io/," target="_blank">https://kaitai.io/,</a> но кажется проект больше мёртв, чем жив.
<br>
@@ -1021,7 +1022,7 @@ The SM9 module boasts <b>hardware compatibility</b> with NVIDIA Jetson series mo
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">✍ 6&nbsp;&nbsp;</span><span class="reaction">👍 2&nbsp;&nbsp;</span><span class="reaction">❤ 1&nbsp;&nbsp;</span><span class="reaction">💊 1&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">4876 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">13/03/25, 17:42:49</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#1347</span><br><a href="tg://resolve?domain=embedoka&amp;post=1347">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/embedoka/1347">Open in Web</a>
<br><div class="message-flags"> 🏷 fwd 🏷 link </div></div></div>
<br><div class="message-flags"> 🏷 fwd 🏷 link </div></div>
<hr class="post-divider">
<div class="message-body"><div class="post">
<div class="message-forward">--- Forwarded from <a href="https://t.me/kitnerlive">kitnerlive (@kitnerlive)</a> ---</div>
+2 -1
View File
@@ -168,6 +168,7 @@
<div class="message-forward">--- Forwarded from асоциальный пикотранзистор ᶘಠᴥಠᶅ ---</div>
<br>
<div class="message-media">
</div>
<br>
Парсинг через описание структуры мне сразу напомнил про <a href="https://kaitai.io/," target="_blank">https://kaitai.io/,</a> но кажется проект больше мёртв, чем жив.
<br>
@@ -175,7 +176,7 @@
</div><br></div>
<div class="message-footer"><br>
<span class="reaction">✍ 6&nbsp;&nbsp;</span><span class="reaction">👍 2&nbsp;&nbsp;</span><span class="reaction">❤ 1&nbsp;&nbsp;</span><span class="reaction">💊 1&nbsp;&nbsp;</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="views">4876 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">13/03/25, 17:42:49</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#1347</span><br><a href="tg://resolve?domain=embedoka&amp;post=1347">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/embedoka/1347">Open in Web</a>
<br><div class="message-flags"> 🏷 fwd 🏷 link </div></div></div>]]></content:encoded>
<br><div class="message-flags"> 🏷 fwd 🏷 link </div></div>]]></content:encoded>
<guid isPermaLink="true">https://t.me/embedoka/1347</guid>
<pubDate>Thu, 13 Mar 2025 17:42:49 +0000</pubDate>
</item>
+2 -1
View File
@@ -609,6 +609,7 @@
<hr class="post-divider">
<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
Илья Бирман в последнем выпуске своего подкаста вместе с Мишей Нозиком обсудили исследование, которое я проводил для внедрения изменений в шрифт Карт (<a href="https://t.me/meow_design/2090)." target="_blank">https://t.me/meow_design/2090).</a> <br><br>Фрагмент начинается с 56:45.
<br>
@@ -623,7 +624,7 @@
</div><br></div>
<div class="message-footer"><br>
<span class="views">1187 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">22/12/25, 15:22:09</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#2183</span><br><a href="tg://resolve?domain=meow_design&amp;post=2183">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/meow_design/2183">Open in Web</a>
<br><div class="message-flags"> 🏷 fwd 🏷 no_image 🏷 link 🏷 mention 🏷 foreign_channel 🏷 merged </div></div></div>
<br><div class="message-flags"> 🏷 fwd 🏷 no_image 🏷 link 🏷 mention 🏷 foreign_channel 🏷 merged </div></div>
<hr class="post-divider">
<div class="message-body"><div class="post">
<div class="message-media">
+2 -1
View File
@@ -311,6 +311,7 @@
<description>Илья Бирман в последнем выпуске своего подкаста вместе с Мишей Нозиком обсудили исследование, которое я проводил для внедрения изменений в шрифт Карт (https://t.me/meow_design/2090). Фрагмент начинается с 56:45. Думаем дальше № 51 — «Три варианта апокалипсиса» c Мишей Нозиком С Мишей Нозиком обсуждаем хороший дизайн, хороший маркетинг, хорошее исследование (!) и пару вопросов по нейросетям. 0:00 — Миша оказался против горизонтальной прокрутки на мобиле 7:06 — Обсуждение: аналогия с теоремой о попапах, когда подойдёт, топ дизайнерских костылей 20:20 — Критерий хорошего дизайна 27:43 — Разбираем на примере полководцев, учителя русского языка, авиакомпании и чего-то ещё. Репутация и долгосрочное планирование 45:29 — Про маркетинг в бюро 52:41 — «Пипл хавает» против «дизайна для дизайнеров» 56:45 — Яндекс исследовал размер строчных букв в шрифте на картах 1:03:01 — ЧатГПТ и непонятная кнопка «Скачать» 1:13:26 — Как нейросеть решила убить человека Эпл · Ютюб · Я.Музыка · Мейв Но лучше подписаться по РСС в подкастном приложении: https://cloud.mave.digital/51724</description>
<content:encoded><![CDATA[<div class="message-body"><div class="post">
<div class="message-media">
</div>
<br>
Илья Бирман в последнем выпуске своего подкаста вместе с Мишей Нозиком обсудили исследование, которое я проводил для внедрения изменений в шрифт Карт (<a href="https://t.me/meow_design/2090)." target="_blank">https://t.me/meow_design/2090).</a> <br><br>Фрагмент начинается с 56:45.
<br>
@@ -325,7 +326,7 @@
</div><br></div>
<div class="message-footer"><br>
<span class="views">1187 👁</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="date">22/12/25, 15:22:09</span>&nbsp;&nbsp;|&nbsp;&nbsp;<span class="message-id">#2183</span><br><a href="tg://resolve?domain=meow_design&amp;post=2183">Open in Telegram</a>&nbsp;|&nbsp;<a href="https://t.me/meow_design/2183">Open in Web</a>
<br><div class="message-flags"> 🏷 fwd 🏷 no_image 🏷 link 🏷 mention 🏷 foreign_channel 🏷 merged </div></div></div>]]></content:encoded>
<br><div class="message-flags"> 🏷 fwd 🏷 no_image 🏷 link 🏷 mention 🏷 foreign_channel 🏷 merged </div></div>]]></content:encoded>
<guid isPermaLink="true">https://t.me/meow_design/2183</guid>
<pubDate>Mon, 22 Dec 2025 15:22:09 +0000</pubDate>
</item>
+218
View File
@@ -0,0 +1,218 @@
{
"animation": {
"collected": [
[
"testchan",
3,
"ani_uid"
]
],
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/3/ani_uid/ef4319c9\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
},
"audio_default_mime": {
"collected": [
[
"testchan",
5,
"au_uid"
]
],
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/5/au_uid/be14c993\" type=\"audio/mpeg\"></audio>\n<br>\n</div>"
},
"audio_explicit_mime": {
"collected": [
[
"testchan",
6,
"au2_uid"
]
],
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/6/au2_uid/015e2737\" type=\"audio/flac\"></audio>\n<br>\n</div>"
},
"channel_username_none": {
"collected": [],
"html": "<div class=\"message-media\">\n</div>"
},
"document_normal": {
"collected": [
[
"testchan",
13,
"doc_uid"
]
],
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/13/doc_uid/4c9075c7\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
},
"document_pdf_private": {
"collected": [
[
"-1009876543210",
12,
"pdf2_uid"
]
],
"html": "<div class=\"message-media\">\n<div class=\"document-pdf\" style=\"padding: 10px;\">\n<a href=\"https://t.me/c/9876543210/12\" target=\"_blank\">[PDF-файл]</a></div>\n</div>"
},
"document_pdf_public": {
"collected": [
[
"pubchan",
11,
"pdf_uid"
]
],
"html": "<div class=\"message-media\">\n<div class=\"document-pdf\" style=\"padding: 10px;\">\n<a href=\"https://t.me/pubchan/11\" target=\"_blank\">[PDF-файл]</a></div>\n</div>"
},
"file_unique_id_none": {
"collected": [],
"html": "<div class=\"message-media\">"
},
"live_photo": {
"collected": [
[
"testchan",
14,
"lp_uid"
]
],
"html": "<div class=\"message-media\">\n<video controls autoplay loop muted src=\"http://test.example.com/media/testchan/14/lp_uid/7cbfcf53\"style=\"max-width:100%; width:auto; height:auto; max-height:400px;object-fit:contain;\"></video>\n</div>"
},
"paid_media": {
"collected": [],
"html": "<div class=\"message-media\">\n<div class=\"paid-media\">⭐ Paid media (50 stars, 2 item(s)) — available in Telegram</div>\n</div>"
},
"photo": {
"collected": [
[
"testchan",
1,
"ph_uid"
]
],
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/1/ph_uid/08e6b2ef\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
},
"poll_media_img": {
"collected": [
[
"testchan",
17,
"pl_uid"
]
],
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/17/pl_uid/492d238c\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
},
"poll_media_video": {
"collected": [
[
"testchan",
18,
"plv_uid"
]
],
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/18/plv_uid/abb609a3\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
},
"sticker_img": {
"collected": [
[
"testchan",
9,
"st_uid"
]
],
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/9/st_uid/03dc35d2\" alt=\"Sticker 😀\" style=\"max-width:100%;width:auto; height:auto; max-height:200px; object-fit:contain;\">\n</div>"
},
"sticker_video": {
"collected": [
[
"testchan",
10,
"stv_uid"
]
],
"html": "<div class=\"message-media\">\n<video controls autoplay loop muted src=\"http://test.example.com/media/testchan/10/stv_uid/cedd7494\"style=\"max-width:100%; width:auto; height:auto; max-height:200px;object-fit:contain;\"></video>\n</div>"
},
"story_photo": {
"collected": [
[
"testchan",
16,
"sp_uid"
]
],
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/16/sp_uid/d8a5d9c9\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
},
"story_video": {
"collected": [
[
"testchan",
15,
"sv_uid"
]
],
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/15/sv_uid/1aba6362\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
},
"video": {
"collected": [
[
"testchan",
2,
"vid_uid"
]
],
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/2/vid_uid/e0c6ba2e\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
},
"video_note": {
"collected": [
[
"testchan",
4,
"vn_uid"
]
],
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/4/vn_uid/a5ee9944\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
},
"voice_default_mime": {
"collected": [
[
"testchan",
7,
"vo_uid"
]
],
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/7/vo_uid/f16627e0\" type=\"audio/ogg\"></audio>\n<br>\n</div>"
},
"voice_explicit_mime": {
"collected": [
[
"testchan",
8,
"vo2_uid"
]
],
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/8/vo2_uid/30fed86a\" type=\"audio/wav\"></audio>\n<br>\n</div>"
},
"webpage_photo_long_text": {
"collected": [
[
"testchan",
22,
"wpl_uid"
]
],
"html": "<div class=\"message-media\">\n</div>"
},
"webpage_with_photo": {
"collected": [
[
"testchan",
20,
"wp_uid"
]
],
"html": "<div class=\"message-media\">\n</div>\n<div class=\"webpage-preview\">\n<div class=\"webpage-preview\" style=\"border-left: 3px solid #ccc; padding-left: 10px; margin: 10px 0;\">\n<div class=\"webpage-title\" style=\"font-weight:bold; margin:5px 0;\">\n<a href=\"https://example.com\" target=\"_blank\">Example</a></div>\n<div class=\"webpage-url\" style=\"color:#666; font-size:0.9em;margin-bottom:5px;\">https://example.com</div>\n<div class=\"webpage-photo\" style=\"margin-top:10px;\">\n<a href=\"https://example.com\" target=\"_blank\">\n<img src=\"http://test.example.com/media/testchan/20/wp_uid/a3dc7111\" style=\"max-width:100%; width:auto;height:auto; max-height:200px; object-fit:contain;\"></a></div>\n</div>\n</div>"
},
"webpage_without_photo": {
"collected": [],
"html": "<div class=\"message-media\">\n<div class=\"webpage-preview\">\n<div class=\"webpage-preview\" style=\"border-left: 3px solid #ccc; padding-left: 10px; margin: 10px 0;\">\n<div class=\"webpage-title\" style=\"font-weight:bold; margin:5px 0;\">\n<a href=\"https://example.com\" target=\"_blank\">Example</a></div>\n<div class=\"webpage-url\" style=\"color:#666; font-size:0.9em;margin-bottom:5px;\">https://example.com</div>\n</div>\n</div>"
}
}
+238
View File
@@ -0,0 +1,238 @@
# flake8: noqa
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
# pylint: disable=redefined-outer-name, line-too-long
"""Stage-5 media table tests (render-pipeline refactor epic, issue #32/#34).
Two layers:
* 5a — the MEDIA_SOURCES table + renderers reproduce the three old ladders
(_get_file_unique_id, _save_media_file_ids, _generate_html_media). The fragment
snapshot (tests/test_data/media_fragments.json) is the PRE-REFACTOR BASE reference,
captured against the base post_parser.py; the 5a code reproduces it byte-for-byte
(two fragments carry registered §3.14 deltas — see fr.REGISTERED_DELTAS). Plus
table/selector/invariant unit tests.
* 5b — the registered fixes (§3.13 large-file guard for every object, §3.14 the
message-media div is closed in every branch) live in test_stage5b_media_fixes.py.
The cross-module invariant test pins the boundary with api_server.find_file_id_in_message:
every object a selector returns must be resolvable there by its file_unique_id, or a new
table entry would mint a /media URL the download path answers with 404.
"""
from types import SimpleNamespace
import pytest
from pyrogram.enums import MessageMediaType
from post_parser import (
PostParser, MEDIA_SOURCES, RENDERERS, RenderCtx,
_select_document, _select_sticker, _select_story, _select_poll_media,
)
from api_server import find_file_id_in_message
from url_signer import KeyManager
from tests import media_fragment_replay as fr
@pytest.fixture(autouse=True)
def _pin_signing_key(monkeypatch):
# Deterministic media-URL digests regardless of checkout / cwd.
monkeypatch.setattr(KeyManager, "signing_key", fr.FRAGMENT_SIGNING_KEY)
@pytest.fixture
def parser():
return PostParser(SimpleNamespace())
# --------------------------------------------------------------------------- #
# 1. Fragment-level snapshot oracle — the 5a byte-for-byte contract.
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("case_name", sorted(fr.build_cases().keys()))
def test_media_fragment_matches_snapshot(parser, case_name):
"""_generate_html_media (render ladder + _get_file_unique_id ladder) and the
_save_media_file_ids collection ladder reproduce the frozen pre-refactor bytes."""
snapshot = fr.load_snapshot()
factory = fr.build_cases()[case_name]
parser._pending_media_ids = []
html = parser._generate_html_media(factory())
collected = [[c, p, f] for c, p, f, _ in parser._pending_media_ids]
# The snapshot is the pre-refactor BASE reference; the two §3.14 fragments carry a
# registered 5b delta (the now-closed message-media div), so expect their 5b bytes.
expected = fr.REGISTERED_DELTAS.get(case_name, snapshot[case_name])
assert html == expected["html"], f"fragment HTML diverged for {case_name}"
assert collected == expected["collected"], f"collection diverged for {case_name}"
def test_snapshot_covers_every_spec_case():
"""Guard: the snapshot and the case builders stay in lockstep (a dropped case
would otherwise silently reduce coverage)."""
snapshot = fr.load_snapshot()
assert set(snapshot.keys()) == set(fr.build_cases().keys())
# The spec §5a "Шаг 0" edge branches must all be present.
for required in ("photo", "video", "animation", "video_note", "audio_default_mime",
"voice_default_mime", "sticker_img", "sticker_video",
"document_pdf_public", "document_pdf_private", "document_normal",
"live_photo", "story_video", "story_photo", "poll_media_img",
"poll_media_video", "paid_media", "webpage_with_photo",
"webpage_without_photo", "webpage_photo_long_text",
"file_unique_id_none", "channel_username_none"):
assert required in snapshot, f"missing spec fragment case: {required}"
# --------------------------------------------------------------------------- #
# 2. Table structure: every render kind has a renderer; the only kind=None entry
# is WEB_PAGE; PAID_MEDIA has no entry.
# --------------------------------------------------------------------------- #
def test_every_render_kind_has_a_renderer():
# Fixed-kind lambda entries (branchy selectors are covered by the selector unit
# tests below and the fragment oracle).
static = {
MessageMediaType.PHOTO: 'img_400',
MessageMediaType.VIDEO: 'video_400',
MessageMediaType.ANIMATION: 'video_400',
MessageMediaType.VIDEO_NOTE: 'video_400',
MessageMediaType.AUDIO: 'audio',
MessageMediaType.VOICE: 'audio',
MessageMediaType.LIVE_PHOTO: 'video_loop_400',
}
for mt, expected_kind in static.items():
assert expected_kind in RENDERERS, f"{mt} kind {expected_kind} has no renderer"
# Selector-produced kinds (document/sticker/story/poll) all resolve to renderers
# or, for WEB_PAGE, to None.
for kind in ('img_400', 'video_400', 'audio', 'pdf', 'video_loop_200',
'img_200_sticker', 'video_loop_400'):
assert kind in RENDERERS
def test_web_page_is_the_only_kind_none_entry():
assert MEDIA_SOURCES[MessageMediaType.WEB_PAGE](SimpleNamespace(web_page=None))[1] is None
def test_paid_media_has_no_table_entry():
assert MessageMediaType.PAID_MEDIA not in MEDIA_SOURCES
# --------------------------------------------------------------------------- #
# 3. Selector unit tests (the branchy selectors).
# --------------------------------------------------------------------------- #
def test_select_document_pdf_vs_image():
pdf = SimpleNamespace(document=SimpleNamespace(mime_type="application/pdf", file_unique_id="d1"))
png = SimpleNamespace(document=SimpleNamespace(mime_type="image/png", file_unique_id="d2"))
assert _select_document(pdf)[1] == 'pdf'
assert _select_document(png)[1] == 'img_400'
assert _select_document(pdf)[0] is pdf.document
def test_select_sticker_video_vs_image():
vid = SimpleNamespace(sticker=SimpleNamespace(is_video=True, file_unique_id="s1"))
img = SimpleNamespace(sticker=SimpleNamespace(is_video=False, file_unique_id="s2"))
assert _select_sticker(vid)[1] == 'video_loop_200'
assert _select_sticker(img)[1] == 'img_200_sticker'
def test_select_story_maps_helper_kind():
vid = SimpleNamespace(story=SimpleNamespace(video=SimpleNamespace(file_unique_id="v"), photo=None))
pic = SimpleNamespace(story=SimpleNamespace(video=None, photo=SimpleNamespace(file_unique_id="p")))
none = SimpleNamespace(story=None)
assert _select_story(vid)[1] == 'video_400'
assert _select_story(pic)[1] == 'img_400'
assert _select_story(none) == (None, None)
def test_select_poll_media_maps_helper_kind():
img = SimpleNamespace(poll=SimpleNamespace(description_media=SimpleNamespace(photo=SimpleNamespace(file_unique_id="p"))))
vid = SimpleNamespace(poll=SimpleNamespace(description_media=SimpleNamespace(video=SimpleNamespace(file_unique_id="v"))))
none = SimpleNamespace(poll=None)
assert _select_poll_media(img)[1] == 'img_400'
assert _select_poll_media(vid)[1] == 'video_400'
assert _select_poll_media(none) == (None, None)
# --------------------------------------------------------------------------- #
# 4. Renderer byte structure (audio emits two items; pdf emits its two-append block).
# --------------------------------------------------------------------------- #
def test_audio_renderer_emits_tag_and_br():
out = RENDERERS['audio'](RenderCtx(url="U", mime="audio/mpeg"))
assert len(out) == 2 and out[1] == '<br>'
assert 'type="audio/mpeg"' in out[0]
def test_pdf_renderer_emits_two_item_block():
out = RENDERERS['pdf'](RenderCtx(url="U", tg_link="https://t.me/x/1"))
assert len(out) == 2
assert out[0] == '<div class="document-pdf" style="padding: 10px;">'
assert out[1] == '<a href="https://t.me/x/1" target="_blank">[PDF-файл]</a></div>'
# --------------------------------------------------------------------------- #
# 5. Cross-module invariant: every selector object is resolvable in api_server.
# --------------------------------------------------------------------------- #
def _invariant_messages():
"""One mock per MEDIA_SOURCES type whose selected object carries a real
file_unique_id/file_id, so find_file_id_in_message can resolve it."""
def base(**extra):
m = SimpleNamespace(id=1, chat=SimpleNamespace(id=-100, username="c"), web_page=None, poll=None)
for a in ("photo", "video", "document", "audio", "voice", "video_note",
"animation", "sticker"):
setattr(m, a, None)
for k, v in extra.items():
setattr(m, k, v)
return m
media = SimpleNamespace(file_unique_id="UID", file_id="FID")
cases = {
MessageMediaType.PHOTO: base(media=MessageMediaType.PHOTO, photo=media),
MessageMediaType.VIDEO: base(media=MessageMediaType.VIDEO, video=media),
MessageMediaType.ANIMATION: base(media=MessageMediaType.ANIMATION, animation=media),
MessageMediaType.VIDEO_NOTE: base(media=MessageMediaType.VIDEO_NOTE, video_note=media),
MessageMediaType.AUDIO: base(media=MessageMediaType.AUDIO, audio=media),
MessageMediaType.VOICE: base(media=MessageMediaType.VOICE, voice=media),
MessageMediaType.DOCUMENT: base(media=MessageMediaType.DOCUMENT,
document=SimpleNamespace(mime_type="image/png", file_unique_id="UID", file_id="FID")),
MessageMediaType.STICKER: base(media=MessageMediaType.STICKER,
sticker=SimpleNamespace(is_video=False, file_unique_id="UID", file_id="FID")),
MessageMediaType.LIVE_PHOTO: base(media=MessageMediaType.LIVE_PHOTO, live_photo=media),
MessageMediaType.STORY: base(media=MessageMediaType.STORY,
story=SimpleNamespace(video=media, photo=None)),
MessageMediaType.POLL: base(media=MessageMediaType.POLL,
poll=SimpleNamespace(description_media=SimpleNamespace(photo=media))),
MessageMediaType.WEB_PAGE: base(media=MessageMediaType.WEB_PAGE,
web_page=SimpleNamespace(photo=media)),
}
return cases
@pytest.mark.parametrize("media_type", list(MEDIA_SOURCES.keys()))
async def test_selector_object_is_resolvable_in_api_server(media_type):
"""The object MEDIA_SOURCES selects is found by api_server.find_file_id_in_message
via its file_unique_id — the /media download path can always resolve a URL the
table produced (spec §5a inter-module invariant)."""
message = _invariant_messages()[media_type]
selected_obj, _kind = MEDIA_SOURCES[media_type](message)
file_unique_id = getattr(selected_obj, "file_unique_id", None)
assert file_unique_id, f"{media_type} selector returned no usable file_unique_id"
resolved = await find_file_id_in_message(message, file_unique_id)
assert resolved == getattr(selected_obj, "file_id", None), \
f"{media_type}: selected object not resolvable in find_file_id_in_message"
def test_media_sources_covers_all_old_ladder_types():
"""Every type the pre-refactor _get_file_unique_id dict handled is in the table."""
old_types = {
MessageMediaType.PHOTO, MessageMediaType.VIDEO, MessageMediaType.DOCUMENT,
MessageMediaType.AUDIO, MessageMediaType.VOICE, MessageMediaType.VIDEO_NOTE,
MessageMediaType.ANIMATION, MessageMediaType.STICKER, MessageMediaType.WEB_PAGE,
MessageMediaType.LIVE_PHOTO, MessageMediaType.STORY, MessageMediaType.POLL,
}
assert old_types <= set(MEDIA_SOURCES.keys())
# --------------------------------------------------------------------------- #
# 6. /flags endpoint constraint: flags.append(...) stays inside _extract_flags so
# inspect.getsource still discovers them.
# --------------------------------------------------------------------------- #
def test_get_all_possible_flags_nonempty_and_known():
flags = PostParser.get_all_possible_flags()
assert flags, "flag introspection returned nothing"
for known in ("video", "audio", "no_image", "sticker", "poll", "fwd"):
assert known in flags, f"known flag {known} not discovered by /flags introspection"
+148
View File
@@ -0,0 +1,148 @@
# flake8: noqa
# pylint: disable=protected-access, missing-function-docstring, redefined-outer-name, line-too-long
"""Stage-5b registered media fixes (render-pipeline refactor epic, issue #32/#34).
Two registry items, both routed through the single MEDIA_SOURCES table:
* §3.13 — the ">100MB don't cache" rule now applies to ANY selected media object,
not just message.video (previously only video, plus live_photo/story/poll).
* §3.14 — the <div class="message-media"> container is CLOSED in every branch;
a None file_unique_id used to leave it open (html5lib then swallowed following
posts / webpage previews into it).
The fragment snapshot (tests/test_data/media_fragments.json) was regenerated in the 5b
changeset: exactly two cases moved — `file_unique_id_none` and `webpage_without_photo`
each gained the now-balanced `</div>` (see test_stage5_media_table for the oracle).
The feed goldens moved only by the same `</div>` relocation on webpage-without-photo posts.
"""
from types import SimpleNamespace
import pytest
from pyrogram.enums import MessageMediaType
from post_parser import PostParser
from url_signer import KeyManager
from tests import media_fragment_replay as fr
@pytest.fixture(autouse=True)
def _pin_signing_key(monkeypatch):
monkeypatch.setattr(KeyManager, "signing_key", fr.FRAGMENT_SIGNING_KEY)
@pytest.fixture
def parser():
return PostParser(SimpleNamespace())
def _msg(mid, media, **extra):
return fr._msg(mid, media, **extra)
# --------------------------------------------------------------------------- #
# §3.14 — the message-media div is closed in every branch.
# --------------------------------------------------------------------------- #
def test_none_file_unique_id_closes_media_div(parser):
# A media type whose object has no usable file_unique_id: the container used to
# be left open. Now it must be closed.
msg = _msg(1, MessageMediaType.PHOTO, photo=SimpleNamespace(file_unique_id=None, file_id="x"))
html = parser._generate_html_media(msg)
assert html == '<div class="message-media">\n</div>'
def test_webpage_without_photo_closes_media_div_before_preview(parser):
# WEB_PAGE without photo: the empty media div must close BEFORE the webpage-preview
# block (previously the open div swallowed the preview and, at feed level, following
# posts). Every <div class="message-media"> is now paired with a </div>.
msg = _msg(2, MessageMediaType.WEB_PAGE, text="hi",
web_page=SimpleNamespace(photo=None, url="https://e.com", title="E",
description=None, site_name=None, type="", display_url=None))
html = parser._generate_html_media(msg)
assert html.startswith('<div class="message-media">\n</div>\n<div class="webpage-preview">')
assert html.count('<div class="message-media">') == 1
# The media container is now empty and closed, not wrapping the preview.
assert '<div class="message-media">\n</div>' in html
def test_channel_username_missing_still_closes_div(parser):
# The username-guard branch also closes the div (unchanged behavior, asserted so a
# future refactor cannot regress it).
msg = _msg(3, MessageMediaType.PHOTO, username=None, chat_id=555,
photo=SimpleNamespace(file_unique_id="u", file_id="f"))
html = parser._generate_html_media(msg)
assert html == '<div class="message-media">\n</div>'
@pytest.mark.parametrize("case_name", ["file_unique_id_none", "webpage_without_photo"])
def test_5b_fragment_cases_are_now_balanced(parser, case_name):
"""The two fragments that 5b intentionally changed must have a balanced media div."""
factory = fr.build_cases()[case_name]
html = parser._generate_html_media(factory())
assert html.count('<div class="message-media">') == 1
# There is at least one closing tag for the media container now.
assert '</div>' in html
# --------------------------------------------------------------------------- #
# §3.13 — the >100MB guard applies to ANY selected object, not only video.
# --------------------------------------------------------------------------- #
BIG = 200 * 1024 * 1024
SMALL = 5 * 1024 * 1024
@pytest.mark.parametrize("media_type,attr", [
(MessageMediaType.PHOTO, "photo"),
(MessageMediaType.DOCUMENT, "document"),
(MessageMediaType.AUDIO, "audio"),
(MessageMediaType.ANIMATION, "animation"),
])
def test_large_non_video_object_not_collected(parser, media_type, attr):
"""§3.13: a >100MB photo/document/audio/animation is no longer collected for the
download cache (before 5b only large VIDEO objects were skipped)."""
obj = SimpleNamespace(file_unique_id="big", file_id="f", file_size=BIG, mime_type="image/png")
msg = _msg(10, media_type, **{attr: obj})
parser._pending_media_ids = []
parser._save_media_file_ids(msg)
assert parser._pending_media_ids == []
@pytest.mark.parametrize("media_type,attr", [
(MessageMediaType.PHOTO, "photo"),
(MessageMediaType.DOCUMENT, "document"),
(MessageMediaType.AUDIO, "audio"),
])
def test_small_non_video_object_still_collected(parser, media_type, attr):
obj = SimpleNamespace(file_unique_id="small", file_id="f", file_size=SMALL, mime_type="image/png")
msg = _msg(11, media_type, **{attr: obj})
parser._pending_media_ids = []
parser._save_media_file_ids(msg)
assert [(c, p, f) for c, p, f, _ in parser._pending_media_ids] == [("testchan", 11, "small")]
def test_exactly_100mb_object_is_collected(parser):
# §3.13 boundary: the guard is strictly `>` 100MB, so an object of exactly 100MB is
# still collected. Pins the operator — a mutation to `>=` would drop this and fail.
obj = SimpleNamespace(file_unique_id="edge", file_id="f",
file_size=100 * 1024 * 1024, mime_type="image/png")
msg = _msg(14, MessageMediaType.PHOTO, photo=obj)
parser._pending_media_ids = []
parser._save_media_file_ids(msg)
assert [(c, p, f) for c, p, f, _ in parser._pending_media_ids] == [("testchan", 14, "edge")]
def test_large_video_still_not_collected(parser):
# Regression: the video case (the only one guarded before 5b) still works.
msg = _msg(12, MessageMediaType.VIDEO,
video=SimpleNamespace(file_unique_id="v", file_id="f", file_size=BIG))
parser._pending_media_ids = []
parser._save_media_file_ids(msg)
assert parser._pending_media_ids == []
def test_object_without_file_size_is_collected(parser):
# No file_size attribute -> not guarded (the common case for photos).
msg = _msg(13, MessageMediaType.PHOTO, photo=SimpleNamespace(file_unique_id="nofs", file_id="f"))
parser._pending_media_ids = []
parser._save_media_file_ids(msg)
assert [(c, p, f) for c, p, f, _ in parser._pending_media_ids] == [("testchan", 13, "nofs")]