Merge pull request 'refactor(render): этап 5 — таблица медиа-типов (5a байт-в-байт + 5b фиксы) (#32)' (#41) from refactor/render-stage5-media into main
Docker Image CI / build (push) Waiting to run
Docker Image CI / build (push) Waiting to run
This commit was merged in pull request #41.
This commit is contained in:
+181
-122
@@ -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()
|
||||
|
||||
@@ -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 </span><span class="reaction">❤ 233 </span><span class="reaction">🔥 51 </span><span class="reaction">👏 42 </span><span class="reaction">🦄 10 </span><span class="reaction">🎉 5 </span><span class="reaction">💩 4 </span><span class="reaction">🥰 3 </span><span class="reaction">😁 3 </span><span class="reaction">🌚 2 </span> | <span class="views">121878 👁</span> | <span class="date">20/02/24, 14:01:23</span> | <span class="message-id">#306</span><br><a href="tg://resolve?domain=bladerunnerblues&post=306">Open in Telegram</a> | <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 </span><span class="reaction">❤ 916 </span><span class="reaction">🔥 408 </span><span class="reaction">🍾 70 </span><span class="reaction">⚡ 23 </span><span class="reaction">🤣 16 </span><span class="reaction">🥰 12 </span><span class="reaction">👌 11 </span><span class="reaction">👎 10 </span><span class="reaction">👻 5 </span><span class="reaction">🥱 3 </span> | <span class="views">690677 👁</span> | <span class="date">20/02/24, 10:10:22</span> | <span class="message-id">#305</span><br><a href="tg://resolve?domain=bladerunnerblues&post=305">Open in Telegram</a> | <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 </span><span class="reaction">❤ 447 </span><span class="reaction">🔥 255 </span><span class="reaction">❤🔥 55 </span><span class="reaction">🫡 49 </span><span class="reaction">😁 37 </span><span class="reaction">🤔 9 </span><span class="reaction">🤷♂ 6 </span><span class="reaction">🗿 5 </span><span class="reaction">🤡 4 </span><span class="reaction">👾 4 </span> | <span class="views">110203 👁</span> | <span class="date">16/02/24, 05:23:23</span> | <span class="message-id">#304</span><br><a href="tg://resolve?domain=bladerunnerblues&post=304">Open in Telegram</a> | <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 </span><span class="reaction">❤ 788 </span><span class="reaction">🔥 382 </span><span class="reaction">🤔 52 </span><span class="reaction">👏 24 </span><span class="reaction">⚡ 21 </span><span class="reaction">🐳 10 </span><span class="reaction">👎 9 </span><span class="reaction">😎 9 </span><span class="reaction">👌 4 </span><span class="reaction">🤣 3 </span> | <span class="views">120670 👁</span> | <span class="date">12/01/24, 16:52:43</span> | <span class="message-id">#296</span><br><a href="tg://resolve?domain=bladerunnerblues&post=296">Open in Telegram</a> | <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 </span><span class="reaction">❤ 788 </span><span class="reaction">🔥 382 </span><span class="reaction">🤔 52 </span><span class="reaction">👏 24 </span><span class="reaction">⚡ 21 </span><span class="reaction">🐳 10 </span><span class="reaction">👎 9 </span><span class="reaction">😎 9 </span><span class="reaction">👌 4 </span><span class="reaction">🤣 3 </span> | <span class="views">120670 👁</span> | <span class="date">12/01/24, 16:52:43</span> | <span class="message-id">#296</span><br><a href="tg://resolve?domain=bladerunnerblues&post=296">Open in Telegram</a> | <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 </span><span class="reaction">❤ 447 </span><span class="reaction">🔥 255 </span><span class="reaction">❤🔥 55 </span><span class="reaction">🫡 49 </span><span class="reaction">😁 37 </span><span class="reaction">🤔 9 </span><span class="reaction">🤷♂ 6 </span><span class="reaction">🗿 5 </span><span class="reaction">🤡 4 </span><span class="reaction">👾 4 </span> | <span class="views">110203 👁</span> | <span class="date">16/02/24, 05:23:23</span> | <span class="message-id">#304</span><br><a href="tg://resolve?domain=bladerunnerblues&post=304">Open in Telegram</a> | <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 </span><span class="reaction">❤ 916 </span><span class="reaction">🔥 408 </span><span class="reaction">🍾 70 </span><span class="reaction">⚡ 23 </span><span class="reaction">🤣 16 </span><span class="reaction">🥰 12 </span><span class="reaction">👌 11 </span><span class="reaction">👎 10 </span><span class="reaction">👻 5 </span><span class="reaction">🥱 3 </span> | <span class="views">690677 👁</span> | <span class="date">20/02/24, 10:10:22</span> | <span class="message-id">#305</span><br><a href="tg://resolve?domain=bladerunnerblues&post=305">Open in Telegram</a> | <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 </span><span class="reaction">❤ 233 </span><span class="reaction">🔥 51 </span><span class="reaction">👏 42 </span><span class="reaction">🦄 10 </span><span class="reaction">🎉 5 </span><span class="reaction">💩 4 </span><span class="reaction">🥰 3 </span><span class="reaction">😁 3 </span><span class="reaction">🌚 2 </span> | <span class="views">121878 👁</span> | <span class="date">20/02/24, 14:01:23</span> | <span class="message-id">#306</span><br><a href="tg://resolve?domain=bladerunnerblues&post=306">Open in Telegram</a> | <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>
|
||||
|
||||
@@ -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 </span><span class="reaction">👍 2 </span><span class="reaction">❤ 1 </span><span class="reaction">💊 1 </span> | <span class="views">4876 👁</span> | <span class="date">13/03/25, 17:42:49</span> | <span class="message-id">#1347</span><br><a href="tg://resolve?domain=embedoka&post=1347">Open in Telegram</a> | <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>
|
||||
|
||||
@@ -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 </span><span class="reaction">👍 2 </span><span class="reaction">❤ 1 </span><span class="reaction">💊 1 </span> | <span class="views">4876 👁</span> | <span class="date">13/03/25, 17:42:49</span> | <span class="message-id">#1347</span><br><a href="tg://resolve?domain=embedoka&post=1347">Open in Telegram</a> | <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>
|
||||
|
||||
@@ -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> | <span class="date">22/12/25, 15:22:09</span> | <span class="message-id">#2183</span><br><a href="tg://resolve?domain=meow_design&post=2183">Open in Telegram</a> | <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">
|
||||
|
||||
@@ -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> | <span class="date">22/12/25, 15:22:09</span> | <span class="message-id">#2183</span><br><a href="tg://resolve?domain=meow_design&post=2183">Open in Telegram</a> | <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>
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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")]
|
||||
Reference in New Issue
Block a user