Compare commits

..

3 Commits

Author SHA1 Message Date
bvn13
76d8272aac update-7 2026-03-23 12:45:37 +03:00
bvn13
324cd8a959 update-5, update-6 2026-03-23 12:12:55 +03:00
bvn13
9bd0ab2e72 update-4 2026-03-23 11:45:43 +03:00
7 changed files with 170 additions and 13 deletions

68
docs/update-4.md Normal file
View File

@ -0,0 +1,68 @@
# Обновление 4 — Исправление отображения картинок
## Контекст
В update-2 и update-3 была реализована отправка новостей через XHTML-IM (XEP-0071) с картинкой через тег `<img>`.
По результатам тестирования картинки не отображаются:
- **Gajim** — показывает URL картинки вместо изображения (использует plain-text fallback)
- **Monocle Chat (форк Conversations)** — показывает незагруженную заглушку
Причины установлены в ходе анализа лога stanza.
---
## Исправление 1 — двойной `<body>` в XHTML-IM
### Проблема
slixmpp xep_0071 при присвоении `msg["html"]["body"] = xhtml_body` самостоятельно оборачивает
содержимое в `<body xmlns="http://www.w3.org/1999/xhtml">`. Текущая реализация `_build_xhtml`
также включает этот тег, в результате в stanza попадает вложенный `<body>`:
```xml
<body xmlns="http://www.w3.org/1999/xhtml"> ← добавляет slixmpp
<body> ← добавляет наш код
<p>...</p>
</body>
</body>
```
Это невалидный XHTML-IM, что приводит к использованию plain-text fallback в строгих клиентах.
### Решение
В методе `_build_xhtml` убрать обёртку `<body xmlns="...">...</body>` — возвращать только
внутренние элементы (`<p>`, `<br/>`). slixmpp добавит `<body>` сам.
---
## Исправление 2 — OOB для отображения картинок в Conversations-клиентах
### Проблема
Клиенты семейства Conversations (включая Monocle Chat) блокируют загрузку внешних изображений
из XHTML-IM `<img src="...">` по умолчанию — это намеренная защита от трекинг-пикселей.
Картинка парсится, но не загружается.
### Решение
Дополнительно к XHTML-IM прикреплять URL картинки как **Out-of-Band Data (XEP-0066)**.
Conversations и его форки умеют рендерить OOB-ссылки как inline-превью.
Для этого к сообщению добавляется элемент:
```xml
<x xmlns="jabber:x:oob">
<url>https://example.com/image.jpg</url>
</x>
```
### Техническая реализация
1. Зарегистрировать плагин `xep_0066` в `JabberConnection`
2. В методе `send_to_room_xhtml` добавить OOB-поле к сообщению, если передан `image_url`:
```python
msg["oob"]["url"] = image_url
```
3. Сигнатуру `send_to_room_xhtml` расширить необязательным параметром `image_url: str | None = None`
4. В `JabberNewsPublisher.publish` передавать `item.image_url` в `send_to_room_xhtml`

26
docs/update-5.md Normal file
View File

@ -0,0 +1,26 @@
# Обновление 5 — Нормализация URL картинок
## Проблема
Клиент Conversations (и его форки) не отображает OOB-картинки, если URL содержит символ `:`
в path-части (например, `…/1579780545_0:182:3048:1897_…jpg`).
При парсинге OOB `<url>` клиент интерпретирует `:` как разделитель схемы или порта
и обрезает URL — ссылка становится невалидной, картинка не загружается.
URL без двоеточий в пути отображаются корректно.
## Решение
URL-энкодировать path-часть URL картинки перед сохранением в `NewsItem`.
Символ `:` кодируется как `%3A`. Согласно RFC 3986, percent-encoded символы
эквивалентны оригинальным — CDN-серверы обязаны принимать такие URL.
## Техническая реализация
1. В `adapters/sources/rss/fetcher.py` добавить функцию `_normalize_image_url(url: str) -> str`
2. Функция использует `urllib.parse` (стандартная библиотека, без новых зависимостей):
- разобрать URL через `urlparse`
- перекодировать path через `quote(path, safe='/')` — слэши остаются, `:` кодируется
- собрать обратно через `urlunparse`
3. Вызывать `_normalize_image_url` в `_extract_image_url` перед возвратом URL

21
docs/update-6.md Normal file
View File

@ -0,0 +1,21 @@
# Обновление 6 — Убрать картинку из тела сообщения
## Контекст
В update-2 картинка добавлялась двумя способами:
- в XHTML-теле через `<p><img src="..." /></p>`
- в plain-text fallback через URL картинки первым блоком
В update-4 добавлен OOB (XEP-0066), который обеспечивает отображение картинки
в клиентах семейства Conversations. Таким образом, `<img>` и URL в fallback
стали избыточными и создают визуальный мусор в клиентах, не рендерящих XHTML.
## Задача
Убрать блок картинки из тела сообщения:
1. В `_build_xhtml` — убрать `<p><img src="..." /></p><br/><br/>`
2. В `_build_plain` — убрать добавление `image_url` в список блоков
Поле `image_url` в `NewsItem` и логика его извлечения в fetcher'е остаются —
оно по-прежнему используется для OOB.

31
docs/update-7.md Normal file
View File

@ -0,0 +1,31 @@
# Обновление 7 — Пропускать OOB если URL картинки содержит недопустимые символы
## Контекст
В update-5 была добавлена нормализация URL картинок: символ `:` в path кодировался как `%3A`.
Выяснилось, что CDN принимает закодированный URL (200 OK), но клиент Conversations
всё равно не отображает картинку — причина в hotlink protection на стороне CDN.
Таким образом, нормализация URL не решает проблему и бесполезна.
Практическое наблюдение: URL с `:` в path — признак CDN с hotlink protection.
Такие картинки не отобразятся в клиентах в любом случае.
Решение: не добавлять OOB для URL, содержащих `:` в path-части.
## Задача
1. В `adapters/sources/rss/fetcher.py`:
- Удалить функцию `_normalize_image_url` и импорты `urllib.parse`
- В `_extract_image_url` проверять raw URL: если path-часть содержит `:` — возвращать `None`
## Техническая реализация
Проверка выполняется через `urlparse`:
```python
from urllib.parse import urlparse
p = urlparse(url)
if ':' in p.path:
return None
return url
```

View File

@ -25,6 +25,7 @@ class JabberConnection(ClientXMPP, JabberRoomJoiner, JabberRoomLeaver, MucJidRes
self._rooms_on_start: List[str] = [] self._rooms_on_start: List[str] = []
self.register_plugin("xep_0045") # MUC self.register_plugin("xep_0045") # MUC
self.register_plugin("xep_0066") # Out-of-Band Data
self.register_plugin("xep_0071") # XHTML-IM self.register_plugin("xep_0071") # XHTML-IM
self.register_plugin("xep_0199") # XMPP Ping self.register_plugin("xep_0199") # XMPP Ping
@ -106,16 +107,25 @@ class JabberConnection(ClientXMPP, JabberRoomJoiner, JabberRoomLeaver, MucJidRes
logger.warning("Не удалось отправить сообщение в %s", room_jid) logger.warning("Не удалось отправить сообщение в %s", room_jid)
return False return False
async def send_to_room_xhtml(self, room_jid: str, plain_text: str, xhtml_body: str) -> bool: async def send_to_room_xhtml(
self,
room_jid: str,
plain_text: str,
xhtml_body: str,
image_url: Optional[str] = None,
) -> bool:
""" """
Отправляет XHTML-IM сообщение в конференцию (XEP-0071). Отправляет XHTML-IM сообщение в конференцию (XEP-0071).
plain_text fallback для клиентов без поддержки XHTML. plain_text fallback для клиентов без поддержки XHTML.
image_url если указан, добавляется OOB (XEP-0066) для клиентов семейства Conversations.
Возвращает False если возникла ошибка. Возвращает False если возникла ошибка.
""" """
try: try:
msg = self.make_message(mto=room_jid, mtype="groupchat") msg = self.make_message(mto=room_jid, mtype="groupchat")
msg["body"] = plain_text msg["body"] = plain_text
msg["html"]["body"] = xhtml_body msg["html"]["body"] = xhtml_body
if image_url:
msg["oob"]["url"] = image_url
msg.send() msg.send()
return True return True
except Exception: except Exception:

View File

@ -19,13 +19,10 @@ class JabberNewsPublisher(NewsPublisher):
""" """
plain = self._build_plain(item) plain = self._build_plain(item)
xhtml = self._build_xhtml(item) xhtml = self._build_xhtml(item)
return await self._connection.send_to_room_xhtml(room_jid, plain, xhtml) return await self._connection.send_to_room_xhtml(room_jid, plain, xhtml, item.image_url)
def _build_plain(self, item: NewsItem) -> str: def _build_plain(self, item: NewsItem) -> str:
blocks = [] blocks = [item.title]
if item.image_url:
blocks.append(item.image_url)
blocks.append(item.title)
if item.summary: if item.summary:
blocks.append(item.summary) blocks.append(item.summary)
blocks.append(item.link) blocks.append(item.link)
@ -33,12 +30,8 @@ class JabberNewsPublisher(NewsPublisher):
def _build_xhtml(self, item: NewsItem) -> str: def _build_xhtml(self, item: NewsItem) -> str:
SEP = "<br/><br/>" SEP = "<br/><br/>"
parts = ['<body xmlns="http://www.w3.org/1999/xhtml">'] parts = [f'<p>{item.title}</p>{SEP}']
if item.image_url:
parts.append(f'<p><img src="{item.image_url}" alt="" /></p>{SEP}')
parts.append(f'<p>{item.title}</p>{SEP}')
if item.summary: if item.summary:
parts.append(f'<p>{item.summary}</p>{SEP}') parts.append(f'<p>{item.summary}</p>{SEP}')
parts.append(f'<p><a href="{item.link}">{item.link}</a></p>') parts.append(f'<p><a href="{item.link}">{item.link}</a></p>')
parts.append('</body>')
return ''.join(parts) return ''.join(parts)

View File

@ -3,6 +3,7 @@ import logging
import re import re
from html import unescape from html import unescape
from typing import List, Optional from typing import List, Optional
from urllib.parse import urlparse
import feedparser import feedparser
@ -20,10 +21,17 @@ def _strip_html(text: str) -> str:
def _extract_image_url(entry) -> Optional[str]: def _extract_image_url(entry) -> Optional[str]:
"""Возвращает URL первого enclosure с type image/*.""" """Возвращает URL первого enclosure с type image/*.
Возвращает None если URL содержит ':' в path признак hotlink-защищённого CDN.
"""
for enc in entry.get("enclosures", []): for enc in entry.get("enclosures", []):
if enc.get("type", "").startswith("image/"): if enc.get("type", "").startswith("image/"):
return enc.get("href") or enc.get("url") raw = enc.get("href") or enc.get("url")
if not raw:
return None
if ":" in urlparse(raw).path:
return None
return raw
return None return None