diff --git a/post_parser.py b/post_parser.py
index db15bd9..bc08055 100644
--- a/post_parser.py
+++ b/post_parser.py
@@ -1055,7 +1055,12 @@ class PostParser:
else: emoji = "❓" # Default for unknown cases
reactions_html += f'{emoji} {reaction.count} '
reactions_html = reactions_html.rstrip()
- first_line_parts.append(reactions_html)
+ # An empty reactions object (reactions.reactions == []) produces no
+ # spans; appending the empty string would emit a leading
+ # ' | ' separator in the footer. Skip it
+ # (registry §3.15). Affects single posts too.
+ if reactions_html:
+ first_line_parts.append(reactions_html)
# Add views
if views := getattr(message, "views", None):
diff --git a/rss_generator.py b/rss_generator.py
index 1344e4d..80aa7ec 100644
--- a/rss_generator.py
+++ b/rss_generator.py
@@ -15,7 +15,6 @@ import asyncio
import re
import time
from datetime import datetime, timezone
-from types import SimpleNamespace
from typing import Optional
from feedgen.feed import FeedGenerator
from pyrogram import errors, Client
@@ -151,54 +150,6 @@ def _trim_messages_groups(messages_groups: list[list[Message]], limit: int):
return messages_groups
-def processed_message_to_tg_message(processed_message: dict) -> Message:
- """
- Convert processed message dictionary into a Message-like object
- containing only the attributes needed by generate_html_footer.
- """
- # Create a simple chat object
- chat_info = SimpleNamespace()
- channel_identifier = processed_message.get('channel')
- if isinstance(channel_identifier, str) and channel_identifier.startswith('-100'):
- setattr(chat_info, 'id', int(channel_identifier))
- setattr(chat_info, 'username', None)
- else:
- setattr(chat_info, 'id', None) # Or some placeholder if needed
- setattr(chat_info, 'username', channel_identifier)
-
-
- # Convert reactions dict to list of objects
- reactions_list = []
- if reactions_dict := processed_message.get('reactions'):
- for emoji, count in reactions_dict.items():
- # Assuming no custom/paid reactions in this simplified structure
- reactions_list.append(SimpleNamespace(emoji=emoji, count=count, is_paid=False, custom_emoji_id=None))
-
- # Recreate reactions structure expected by Pyrogram's reaction handling
- reactions_obj = SimpleNamespace(reactions=reactions_list) if reactions_list else None
-
- # Create the message-like object
- tg_message_mock = SimpleNamespace(
- id=processed_message.get('message_id'),
- date=datetime.fromtimestamp(processed_message['date'], tz=timezone.utc) if processed_message.get('date') else None,
- views=processed_message.get('views'),
- reactions=reactions_obj,
- chat=chat_info,
- # Add other attributes if generate_html_footer or its dependencies need them
- # For now, these seem sufficient based on the analysis of generate_html_footer
- # and _reactions_views_links.
- text=processed_message.get('text'), # Add text just in case
- caption=None, # Assume caption is merged into text by process_message
- forward_origin=None, # Not directly needed by footer generation logic itself
- reply_to_message=None, # Not directly needed by footer generation logic itself
- media=None, # Not needed by footer
- service=processed_message.get('service') # Potentially needed? Added just in case.
- )
-
- # Cast to Message type hint for static analysis, although it's a mock object
- return tg_message_mock # type: ignore
-
-
def _render_messages_groups(messages_groups: list[list[Message]],
post_parser: PostParser,
exclude_flags: str | None = None,
@@ -240,12 +191,14 @@ def _render_messages_groups(messages_groups: list[list[Message]],
else: # Multiple messages in group - merge text and html body
processed_messages = [post_parser.process_message(msg, include_raw=False, sanitize=False) for msg in group]
- # Determine main message for header/footer/title
- main_message = next(
- (msg for msg in processed_messages if msg['text']),
- processed_messages[0] # fallback if no message contains text
- )
-
+ # Determine the main message with the SAME criterion the processed dicts
+ # used: first message that has text or caption, else the first of the
+ # group. processed_messages[i] corresponds to group[i], so main_message
+ # (dict, for title/date/author) and main_raw (the real Message, for the
+ # footer) point at the same index.
+ main_idx = next((i for i, m in enumerate(group) if (m.text or m.caption)), 0)
+ main_raw = group[main_idx]
+ main_message = processed_messages[main_idx]
# Merge text fields from all messages
all_texts = [msg['text'] for msg in processed_messages if msg['text']]
@@ -255,19 +208,16 @@ def _render_messages_groups(messages_groups: list[list[Message]],
all_html_bodies = [msg['html']['body'] for msg in processed_messages if msg['html']['body']]
combined_html_body = '\n
\n'.join(all_html_bodies)
- # Collect all unique flags from all messages in the group
- all_flags = set()
- for msg in processed_messages:
- if msg.get('flags'): # Check if flags exist and are not empty
- all_flags.update(msg['flags'])
- all_flags.add("merged")
- merged_flags = list(all_flags) # Convert back to list if needed, or keep as set
+ # Deterministic merged flags: first-seen order across the group, then
+ # 'merged' (registry §3.8 — replaces the hash-ordered list(set(...))).
+ merged_flags = list(dict.fromkeys(f for msg in processed_messages for f in msg['flags']))
+ merged_flags.append("merged")
- # generate tg-message from processed message
- tg_message = processed_message_to_tg_message(main_message)
-
-
- footer_html = post_parser.generate_html_footer(tg_message, flags_list=merged_flags)
+ # Render the merged footer DIRECTLY from the real main Message — no
+ # dict->mock round-trip. The raw Message carries its real reactions
+ # (custom emojis get their own span, registry §3.6) and its naive-local
+ # date (registry §3.7), matching a single post of the same message.
+ footer_html = post_parser.generate_html_footer(main_raw, flags_list=merged_flags)
html_parts = [
f'
{combined_html_body}
',
diff --git a/tests/golden_replay.py b/tests/golden_replay.py
index ccedda4..d66dd88 100644
--- a/tests/golden_replay.py
+++ b/tests/golden_replay.py
@@ -118,27 +118,21 @@ def capture_html(channel):
_LASTBUILDDATE_RE = re.compile(r".*?", re.DOTALL)
# feedgen 1.0.0 emits no ; normalized anyway as cheap insurance vs. a lib upgrade.
_GENERATOR_RE = re.compile(r".*?", re.DOTALL)
-# Merged-post flags are built as list(set(...)) (rss_generator.py:260-265): order depends on
-# PYTHONHASHSEED, which conftest cannot pin. Sort the flag tokens inside each flags div.
-# (This normalization is removed by the stage-2 commit referencing §3.8.)
-_FLAGS_DIV_RE = re.compile(r'
(.*?)
', re.DOTALL)
-
-
-def _sort_flags_div(match):
- flags = sorted(p.strip() for p in match.group(1).split("🏷") if p.strip())
- inner = " ".join("🏷 " + f for f in flags)
- return f'
{inner}
'
+# NOTE: the stage-0 flag-sort normalization (_FLAGS_DIV_RE / _sort_flags_div) was
+# removed in stage 2 (§3.8). Merged-post flags are now built in deterministic
+# first-seen order (rss_generator._render_messages_groups: dict.fromkeys(...)),
+# so the golden stores the real order and no normalization is needed — keeping it
+# would only mask a real flag-order regression.
def normalize_rss(xml):
xml = _LASTBUILDDATE_RE.sub("", xml)
xml = _GENERATOR_RE.sub("", xml)
- xml = _FLAGS_DIV_RE.sub(_sort_flags_div, xml)
return xml
def normalize_html(html):
- return _FLAGS_DIV_RE.sub(_sort_flags_div, html)
+ return html
def golden_path(channel, kind):
diff --git a/tests/test_data/golden/bladerunnerblues.feed.html b/tests/test_data/golden/bladerunnerblues.feed.html
index bc51217..94063fb 100644
--- a/tests/test_data/golden/bladerunnerblues.feed.html
+++ b/tests/test_data/golden/bladerunnerblues.feed.html
@@ -154,7 +154,7 @@
+
@@ -768,7 +768,7 @@ The SM9 module boasts hardware compatibility with NVIDIA Jetson series mo
+
🏷 link 🏷 mention 🏷 merged
@@ -837,7 +837,7 @@ The SM9 module boasts hardware compatibility with NVIDIA Jetson series mo
📊 Poll: Стоит ли выкладывать копии релевантных видео в канал (для участников, у которых ограничен доступ к ютьюб)? И вообще дублировать какой-либо контент в тг, доступ к которому может быть ограничен? 1. да 2. нет 3. всё равно 4. напишу в комментариях
→ Vote in Telegram 🔗
@@ -984,7 +984,7 @@ The SM9 module boasts hardware compatibility with NVIDIA Jetson series mo
+
🏷 link 🏷 mention 🏷 merged
@@ -1084,7 +1084,7 @@ The SM9 module boasts hardware compatibility with NVIDIA Jetson series mo
📊 Poll: Стоит ли выкладывать копии релевантных видео в канал (для участников, у которых ограничен доступ к ютьюб)? И вообще дублировать какой-либо контент в тг, доступ к которому может быть ограничен? 1. да 2. нет 3. всё равно 4. напишу в комментариях
]]>
https://t.me/embedoka/1370Thu, 22 May 2025 07:49:49 +0000
@@ -1097,7 +1097,7 @@ The SM9 module boasts hardware compatibility with NVIDIA Jetson series mo
]]>
+
🏷 link 🏷 mention 🏷 merged
]]>
https://t.me/embedoka/1400Thu, 14 Aug 2025 08:10:58 +0000
@@ -1218,7 +1218,7 @@ The SM9 module boasts hardware compatibility with NVIDIA Jetson series mo
]]>
+
🏷 link 🏷 mention 🏷 merged
]]>
https://t.me/embedoka/1411Tue, 02 Sep 2025 10:40:26 +0000
@@ -1506,8 +1506,8 @@ The SM9 module boasts hardware compatibility with NVIDIA Jetson series mo
У каждого из нас есть своя разрешающая способность, различная от области к области:
— моя жена отличит плохое исполнение оперной арии от хорошего, для меня же оба будут лежать рядом на полочке «так поют в опере»
— орнитолог умеет распознавать виды птиц, которые для большинства людей — неотличимые «маленькые серые птички»
— кто-то даже по форме светящихся ночью фар способен определить марку и модель авто, для других же машинки отличаются лишь цветом и размером
— гроссмейстер видит на шахматной доске то, что не видит новичок
— ценители кофе третьей волны ловят нотки послевкусия и умеют отличать Эфиопию от Кении, для других же весь кофе одинаковый на вкус (будто горсть земли залили кипятком)
Этот список очевидных примеров можно продолжать бесконечно. Думаю, у каждого из нас есть область, в которой мы видим различия, нераспознаваемые большинством других людей. Наша разрешающая способность в этой области выше. Ну вы знаете, Фрукт-фрукт, сиська-сиська. They're The Same Picture точка жпг
Это как набор рецепторов, по одному на каждую область. Они имеют определенную чувствительность, примерно как улавливающий свет фоторецептор.
0. На нулевом уровне рецептор слепой и ни на что не способен. Как если дать мне искать пневмонию на рентгеновском снимке грудной клетки.
1. На первом уровне рецептор однобитный, он может отвечать только «да-нет», есть свет или нет. Хороший фильм или плохой, вкусный кофе или нет.
2. На втором уровне рецептор начинает различать уровни интенсивности сигнала. Вместо бинарного «плохо-хорошо» появляется шкала от «очень плохо» до «очень хорошо».
3. На третьем уровне рецептор выходит за пределы одной шкалы и уже способен раскладывать сигнал по нескольким измерениям, зрение становится цветным.
Вместо «классный логотип» — «логотип современный и даже пытается быть в тренде из-за использования гипертрофированных ловушек для краски; спрятанная в формеметафора остроумная и узконаправленная, ведь ее распознают только те кто в теме;форма динамичная, общая серьезность оттеняется игривостью в деталях».
Вместо «средний футболист» — «умный и техничный опорник с хорошим ударом, но при этом медленный и ленивый, плохо играет головой, склонен к излишнему риску»
Вместо «мерзавец» — «прекрасный музыкант с природным актерским талантом и харизмой, но паталогический лжец и приспособленец, готовый идти по головамради славы»
Дальнейшее повышение разрешающей способности идет через увеличение битности шкал и добавление всё новых измерений. В том числе — добавление оси времени, то есть способность распознавать изменение сигнала со временем: «раньше это обладало свойствами А, а теперь уже А*»
@@ -32,7 +32,7 @@
+
🏷 no_image 🏷 link 🏷 merged
@@ -139,7 +139,7 @@
+
🏷 no_image 🏷 link 🏷 merged
@@ -219,7 +219,7 @@
+
🏷 no_image 🏷 link 🏷 merged
Меня пожурили, что я на канале кормлю семечками «а-ха-ха, какой-то дизайнер лоханулся» вместо открытия каких-то глубинных глубин дизайна.
После этого я подвис больше чем на месяц, так как постить всё как обычно было уже как-то неловко, а глубинные глубины требуют время на созревание формулировок (если только не делегировать графоманию гптшкам).
@@ -291,7 +291,7 @@
]]>
https://t.me/theyforcedme/3549Sun, 09 Apr 2023 14:06:27 +0000
@@ -1245,7 +1245,7 @@ Grammarly, ты очень хорошо корректируешь имейлы,
]]>
+
🏷 audio 🏷 link 🏷 no_image 🏷 merged
]]>
https://t.me/theyforcedme/3561Mon, 17 Apr 2023 17:13:03 +0000
diff --git a/tests/test_stage0_golden.py b/tests/test_stage0_golden.py
index 1b87f4c..1df3384 100644
--- a/tests/test_stage0_golden.py
+++ b/tests/test_stage0_golden.py
@@ -3,9 +3,10 @@
"""Stage-0 golden oracle (render-pipeline refactor epic, issue #27/#34).
Regenerates the RSS + HTML feeds for the frozen recorded corpus and asserts
-byte-equality against the committed goldens after the spec's declared normalizations
-(strip volatile , sort the hash-ordered merged-flags div). Any other
-byte change = a render regression, and this test must catch it.
+byte-equality against the committed goldens after the spec's declared normalization
+(strip volatile ). Any other byte change = a render regression, and
+this test must catch it. (The stage-0 merged-flags sort normalization was removed in
+stage 2: flag order is now deterministic first-seen order — §3.8.)
Guardrails: this stage only ADDS a loader + goldens + this test. No render/pipeline
production code is touched; the goldens freeze CURRENT behavior including known bugs
@@ -60,14 +61,14 @@ def test_all_goldens_present_and_nonempty():
assert os.path.getsize(path) > 0, f"empty golden: {path}"
-def test_normalization_sorts_merged_flags():
- """Guard the load-bearing flag-sort normalization itself: merged-post flags are emitted
- as list(set(...)) (hash-ordered), so the normalizer must canonicalize their order."""
- unsorted = '
🏷 video 🏷 fwd 🏷 link
'
- resorted = '
🏷 link 🏷 fwd 🏷 video
'
- assert gr.normalize_html(unsorted) == gr.normalize_html(resorted)
- # ...and it is not a no-op that would let a real reordering slip through undetected.
- assert gr.normalize_html(unsorted) == '
🏷 fwd 🏷 link 🏷 video
'
+def test_normalization_preserves_flag_order():
+ """Stage 2 (§3.8): merged-post flags are now emitted in deterministic first-seen
+ order, so normalize_html no longer reorders the flags div — a real flag reordering
+ must reach the byte comparison instead of being masked."""
+ a = '
🏷 video 🏷 fwd 🏷 link
'
+ b = '
🏷 link 🏷 fwd 🏷 video
'
+ assert gr.normalize_html(a) != gr.normalize_html(b)
+ assert gr.normalize_html(a) == a
def test_normalization_strips_lastbuilddate():
diff --git a/tests/test_stage2_footer.py b/tests/test_stage2_footer.py
new file mode 100644
index 0000000..0982f2e
--- /dev/null
+++ b/tests/test_stage2_footer.py
@@ -0,0 +1,188 @@
+# flake8: noqa
+# pylint: disable=missing-function-docstring, redefined-outer-name, line-too-long
+# pylint: disable=protected-access, wrong-import-position, import-outside-toplevel
+"""Stage-2 footer tests (render-pipeline refactor epic, issue #29/#34).
+
+Stage 2 removed rss_generator.processed_message_to_tg_message and renders the merged
+footer DIRECTLY from the real main Message. These tests lock the registry §3 items that
+become visible as a consequence:
+
+ §3.6 custom-emoji reactions in the merged footer get a separate "❓ N" span each
+ (no more aggregation into one "❓ N"), matching single posts.
+ §3.7 the merged footer date comes from the naive-local date of the real Message,
+ not a UTC mock — verified with a NON-UTC TZ where the delta is visible (the
+ TZ=UTC golden cannot see it).
+ §3.15 an empty reactions object no longer emits a leading footer separator (single
+ posts too).
+
+Messages are built with the shared SimpleNamespace helper (naive dates where relevant,
+as kurigram emits on prod).
+"""
+import os
+import time
+from datetime import datetime
+from types import SimpleNamespace
+
+import pytest
+
+from pyrogram.enums import MessageMediaType
+from post_parser import PostParser
+from rss_generator import _render_messages_groups
+from tests.test_stage4_eventloop import make_message
+
+
+def _custom_reaction(count, custom_emoji_id):
+ # No `.emoji` attribute -> _reactions_views_links falls to the custom-emoji "❓" branch.
+ return SimpleNamespace(count=count, custom_emoji_id=custom_emoji_id)
+
+
+def _normal_reaction(count, emoji):
+ return SimpleNamespace(count=count, emoji=emoji)
+
+
+def _reactions(*items):
+ return SimpleNamespace(reactions=list(items))
+
+
+def _footer_of(post):
+ """Extract the inner html from a rendered post."""
+ html = post["html"]
+ marker = '