Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d8272aac | ||
|
|
324cd8a959 | ||
|
|
9bd0ab2e72 | ||
|
|
05c4016747 |
20
docs/update-3.md
Normal file
20
docs/update-3.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Обновление 3
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
1. в обновлении docs/update-2.md были добавлены description и картинка новости в ее текст
|
||||||
|
2. но оформление новости выглядит хорошим (user exp) только в случае, когда есть заголовок, описание и ссылка
|
||||||
|
3. необходимо для всех вариантов наполнения новостей из RSS разделить двумя переводами строки блоки:
|
||||||
|
|
||||||
|
- картинка (если есть, то отделяем разрывом в два перевода строки)
|
||||||
|
- заголовок (есть всегда, после него разрыв)
|
||||||
|
- дескрипшен (если есть, то отделяем после него разывом)
|
||||||
|
- ссылка (есть всегда, после нее не нужен разрыв, т.к. это завершающий блок)
|
||||||
|
|
||||||
|
## Техническая реализация
|
||||||
|
|
||||||
|
1. в docs/update-2.md было реализовано 2 метода отправки новостей: _build_plain и _build_xhtml - выбор определяется наличием ссылки на картинку
|
||||||
|
2. необходимо отказаться от _build_plain в пользу _build_xhtml при любых вариантах - это даст новостям одинаковый вид
|
||||||
|
3. но блоки <p> необходимо отделять друг от друга двумя блоками <br/>
|
||||||
|
4. fallback <body> должен содержать двойные '\n' после каждого блока (кроме завершающего - ссылки), описанного выше
|
||||||
|
|
||||||
68
docs/update-4.md
Normal file
68
docs/update-4.md
Normal 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
26
docs/update-5.md
Normal 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
21
docs/update-6.md
Normal 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
31
docs/update-7.md
Normal 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
|
||||||
|
```
|
||||||
@ -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:
|
||||||
|
|||||||
@ -14,32 +14,24 @@ class JabberNewsPublisher(NewsPublisher):
|
|||||||
|
|
||||||
async def publish(self, room_jid: str, item: NewsItem) -> bool:
|
async def publish(self, room_jid: str, item: NewsItem) -> bool:
|
||||||
"""
|
"""
|
||||||
Отправляет новость в комнату.
|
Отправляет новость в комнату через XHTML-IM (XEP-0071) с plain-text fallback.
|
||||||
Если есть картинка — использует XHTML-IM (XEP-0071).
|
|
||||||
Возвращает False если бот не в комнате.
|
Возвращает False если бот не в комнате.
|
||||||
"""
|
"""
|
||||||
plain = self._build_plain(item)
|
plain = self._build_plain(item)
|
||||||
if item.image_url:
|
xhtml = self._build_xhtml(item)
|
||||||
xhtml = self._build_xhtml(item)
|
return await self._connection.send_to_room_xhtml(room_jid, plain, xhtml, item.image_url)
|
||||||
logger.debug(
|
|
||||||
"Публикация XHTML в %s\n--- plain ---\n%s\n--- xhtml ---\n%s",
|
|
||||||
room_jid, plain, xhtml,
|
|
||||||
)
|
|
||||||
return await self._connection.send_to_room_xhtml(room_jid, plain, xhtml)
|
|
||||||
logger.debug("Публикация plain-text в %s\n%s", room_jid, plain)
|
|
||||||
return await self._connection.send_to_room(room_jid, plain)
|
|
||||||
|
|
||||||
def _build_plain(self, item: NewsItem) -> str:
|
def _build_plain(self, item: NewsItem) -> str:
|
||||||
|
blocks = [item.title]
|
||||||
if item.summary:
|
if item.summary:
|
||||||
return f"{item.title}\n\n{item.summary}\n\n{item.link}"
|
blocks.append(item.summary)
|
||||||
return f"{item.title}\n{item.link}"
|
blocks.append(item.link)
|
||||||
|
return "\n\n".join(blocks)
|
||||||
|
|
||||||
def _build_xhtml(self, item: NewsItem) -> str:
|
def _build_xhtml(self, item: NewsItem) -> str:
|
||||||
parts = ['<body xmlns="http://www.w3.org/1999/xhtml">']
|
SEP = "<br/><br/>"
|
||||||
parts.append(f'<p><img src="{item.image_url}" alt="" /></p>')
|
parts = [f'<p>{item.title}</p>{SEP}']
|
||||||
parts.append(f'<p>{item.title}</p>')
|
|
||||||
if item.summary:
|
if item.summary:
|
||||||
parts.append(f'<p>{item.summary}</p>')
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user