Compare commits
No commits in common. "master" and "0.1.0" have entirely different histories.
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1 @@
|
|||||||
.env
|
.env
|
||||||
data/
|
|
||||||
|
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
# Update 1 — Исправления и дополнения к design.md
|
|
||||||
|
|
||||||
Документ фиксирует решения, принятые в ходе реализации и отладки, которые не описаны в `design.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Запуск event loop (main.py)
|
|
||||||
|
|
||||||
**Проблема:** в примере из `design.md` используется `bot.process(forever=True)` — это API sleekxmpp, которого нет в slixmpp.
|
|
||||||
|
|
||||||
**Решение:** slixmpp — asyncio-native библиотека. Точка входа:
|
|
||||||
- `setup()` — async-функция, собирает все зависимости
|
|
||||||
- `main()` — синхронная, создаёт event loop вручную через `asyncio.new_event_loop()`
|
|
||||||
- `connection.connect()` ставит соединение в очередь
|
|
||||||
- `loop.run_forever()` держит бота живым
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Обработка XMPP-сообщений (connection.py)
|
|
||||||
|
|
||||||
**Проблема:** в slixmpp событие `message` срабатывает для всех типов stanza, включая `groupchat`. Регистрация обоих событий (`message` + `groupchat_message`) на один обработчик вызывала двойную обработку каждого сообщения из комнаты.
|
|
||||||
|
|
||||||
**Решение:** один обработчик `_on_message` зарегистрирован только на событие `message`. Внутри — явная диспетчеризация по `msg["type"]`:
|
|
||||||
- `chat` → фильтр по собственному bare JID
|
|
||||||
- `groupchat` → фильтр по нику бота (отражённые сервером собственные сообщения)
|
|
||||||
- прочие типы → игнорируются
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. leave_muc не является корутиной
|
|
||||||
|
|
||||||
**Проблема:** `muc.leave_muc()` в slixmpp — синхронный метод. `await muc.leave_muc(...)` падал с `TypeError: object NoneType can't be used in 'await' expression`.
|
|
||||||
|
|
||||||
**Решение:** убран `await` у вызова `leave_muc`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Получение реального JID участника MUC (command_handler.py)
|
|
||||||
|
|
||||||
**Проблема:** `msg.get_plugin("xep_0045")` вызывался на объекте стансы, а не на `ClientXMPP`. Метод всегда возвращал `None`, из-за чего `caller_jid` был `None` для всех участников → все команды, требующие прав админа, молча отбрасывались.
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
- Добавлен порт `MucJidResolver` в `domain/admin/ports.py`:
|
|
||||||
```python
|
|
||||||
class MucJidResolver(ABC):
|
|
||||||
def get_real_jid(self, room_jid: str, nick: str) -> str | None: ...
|
|
||||||
```
|
|
||||||
- `JabberConnection` реализует `MucJidResolver` через `self.plugin["xep_0045"].get_jid_property(...)`
|
|
||||||
- `CommandHandler` принимает `jid_resolver: MucJidResolver` и использует его
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Сравнение JID при проверке прав (command_handler.py)
|
|
||||||
|
|
||||||
**Проблема:** xep_0045 возвращает полный JID с ресурсом (`user@server/resource`), а в таблице `rooms` хранится bare JID (`user@server`). Сравнение `room.admin_jid == caller_jid` всегда давало `False`.
|
|
||||||
|
|
||||||
**Решение:** перед сравнением срезается ресурс:
|
|
||||||
```python
|
|
||||||
caller_bare = caller_jid.split("/")[0]
|
|
||||||
return room.admin_jid == caller_bare
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Воспроизведение истории при входе в комнату (command_handler.py)
|
|
||||||
|
|
||||||
**Проблема:** при входе бота в MUC-комнату XMPP-сервер воспроизводит историю сообщений (XEP-0091/XEP-0203). Все старые команды (`! help`, `! subscribe`, ...) обрабатывались заново пакетом.
|
|
||||||
|
|
||||||
**Решение:** в начале `_handle_room` добавлена проверка наличия элемента `<delay>`:
|
|
||||||
```python
|
|
||||||
if msg["delay"]["stamp"]:
|
|
||||||
return # историческое сообщение, пропускаем
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Логирование
|
|
||||||
|
|
||||||
Добавлено логирование на ключевых этапах обработки команд в `command_handler.py`:
|
|
||||||
- `INFO` — распознанная команда (`room_jid`, `caller_jid`, команда, аргумент)
|
|
||||||
- `INFO` — отклонение по правам (не-админ пытается выполнить команду)
|
|
||||||
- `DEBUG` — входящее сообщение, реальный JID, результат проверки прав
|
|
||||||
- `WARNING` — некорректный ввод (команда без обязательного аргумента, неверный интервал)
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
# Доработка 2
|
|
||||||
|
|
||||||
## Задание
|
|
||||||
|
|
||||||
1. необходимо доработать rss адаптер, получающий новости, и модуль отправки в жаббер
|
|
||||||
|
|
||||||
### Улучшение 1
|
|
||||||
|
|
||||||
1. если RSS item содержит поле description, то текст новости необходимо снабдить этой информацией
|
|
||||||
2. в этом случае формат новости должен быть расширен:
|
|
||||||
|
|
||||||
```
|
|
||||||
title
|
|
||||||
|
|
||||||
|
|
||||||
description
|
|
||||||
|
|
||||||
|
|
||||||
link
|
|
||||||
```
|
|
||||||
|
|
||||||
т.е. между title и description - 2 перевода строки, и между description и link - тоже 2 перевода строки
|
|
||||||
|
|
||||||
3. если description содержит HTML тэги, то выводить нужно голый текст, предварительно очистив от тэгов
|
|
||||||
|
|
||||||
### Улучшение 2
|
|
||||||
|
|
||||||
1. если RSS item содержит элемент enclosure с параметром type="image/*" (любой image, но у меня пример только для image/jpeg есть - возможно, бывают другие варианты, нужно отработать по маске)
|
|
||||||
2. то нужно воспользоваться XEP-0071 и отправить картинку вместе с текстом новости - шаблонный пример использования
|
|
||||||
|
|
||||||
```
|
|
||||||
import slixmpp
|
|
||||||
from slixmpp.stanza import Message
|
|
||||||
from slixmpp.plugins.xep_0071 import XHTML_IM
|
|
||||||
|
|
||||||
class XHTMLBot(slixmpp.ClientXMPP):
|
|
||||||
async def send_xhtml(self, jid_to):
|
|
||||||
msg = self.make_message(mto=jid_to)
|
|
||||||
|
|
||||||
# Устанавливаем обычный текст (обязательно для fallback)
|
|
||||||
msg['body'] = 'Посмотри картинку: https://example.com/image.jpg'
|
|
||||||
|
|
||||||
# Формируем XHTML-содержимое
|
|
||||||
# Обратите внимание: тег <img> должен быть внутри <body> в пространстве имен XHTML
|
|
||||||
xhtml_body = '<body xmlns="http://www.w3.org/1999/xhtml">' + \
|
|
||||||
' <p>Смотри: <img src="https://example.com/image.jpg" /></p>' + \
|
|
||||||
'</body>'
|
|
||||||
|
|
||||||
# Включаем XHTML-IM плагин
|
|
||||||
msg['html']['body'] = xhtml_body
|
|
||||||
|
|
||||||
msg.send()
|
|
||||||
```
|
|
||||||
|
|
||||||
3. картинка должна размещаться ДО текста новости
|
|
||||||
4. Fallback (msg['body']) при XHTML-сообщении — для клиентов без XEP-0071 в тексте писать просто текст без ссылок на картинку
|
|
||||||
5. при наличии нескольких enclosure брать первый с подходящим type
|
|
||||||
|
|
||||||
|
|
||||||
### Если новость имеет и enclosure-картинку, и description
|
|
||||||
|
|
||||||
— порядок в XHTML-теле: <img> → title → description → link
|
|
||||||
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
# Обновление 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' после каждого блока (кроме завершающего - ссылки), описанного выше
|
|
||||||
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
# Обновление 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`
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
# Обновление 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
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
# Обновление 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.
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
# Обновление 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,8 +25,6 @@ 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_0199") # XMPP Ping
|
self.register_plugin("xep_0199") # XMPP Ping
|
||||||
|
|
||||||
self.add_event_handler("session_start", self._on_session_start)
|
self.add_event_handler("session_start", self._on_session_start)
|
||||||
@ -97,7 +95,7 @@ class JabberConnection(ClientXMPP, JabberRoomJoiner, JabberRoomLeaver, MucJidRes
|
|||||||
|
|
||||||
async def send_to_room(self, room_jid: str, text: str) -> bool:
|
async def send_to_room(self, room_jid: str, text: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Отправляет plain-text сообщение в конференцию.
|
Отправляет сообщение в конференцию.
|
||||||
Возвращает False если возникла ошибка (бот не в комнате).
|
Возвращает False если возникла ошибка (бот не в комнате).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@ -107,31 +105,6 @@ 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,
|
|
||||||
image_url: Optional[str] = None,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Отправляет XHTML-IM сообщение в конференцию (XEP-0071).
|
|
||||||
plain_text — fallback для клиентов без поддержки XHTML.
|
|
||||||
image_url — если указан, добавляется OOB (XEP-0066) для клиентов семейства Conversations.
|
|
||||||
Возвращает False если возникла ошибка.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
msg = self.make_message(mto=room_jid, mtype="groupchat")
|
|
||||||
msg["body"] = plain_text
|
|
||||||
msg["html"]["body"] = xhtml_body
|
|
||||||
if image_url:
|
|
||||||
msg["oob"]["url"] = image_url
|
|
||||||
msg.send()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Не удалось отправить XHTML-сообщение в %s, пробуем plain-text", room_jid)
|
|
||||||
return await self.send_to_room(room_jid, plain_text)
|
|
||||||
|
|
||||||
def get_real_jid(self, room_jid: str, nick: str) -> str | None:
|
def get_real_jid(self, room_jid: str, nick: str) -> str | None:
|
||||||
"""Возвращает реальный JID участника MUC по нику, или None если недоступен."""
|
"""Возвращает реальный JID участника MUC по нику, или None если недоступен."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from src.domain.news.entities import NewsItem
|
from src.domain.news.entities import NewsItem
|
||||||
from src.domain.news.ports import NewsPublisher
|
from src.domain.news.ports import NewsPublisher
|
||||||
from .connection import JabberConnection
|
from .connection import JabberConnection
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class JabberNewsPublisher(NewsPublisher):
|
class JabberNewsPublisher(NewsPublisher):
|
||||||
|
|
||||||
@ -14,24 +10,8 @@ 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.
|
Отправляет новость в комнату.
|
||||||
Возвращает False если бот не в комнате.
|
Возвращает False если бот не в комнате.
|
||||||
"""
|
"""
|
||||||
plain = self._build_plain(item)
|
text = f"{item.title}\n{item.link}"
|
||||||
xhtml = self._build_xhtml(item)
|
return await self._connection.send_to_room(room_jid, text)
|
||||||
return await self._connection.send_to_room_xhtml(room_jid, plain, xhtml, item.image_url)
|
|
||||||
|
|
||||||
def _build_plain(self, item: NewsItem) -> str:
|
|
||||||
blocks = [item.title]
|
|
||||||
if item.summary:
|
|
||||||
blocks.append(item.summary)
|
|
||||||
blocks.append(item.link)
|
|
||||||
return "\n\n".join(blocks)
|
|
||||||
|
|
||||||
def _build_xhtml(self, item: NewsItem) -> str:
|
|
||||||
SEP = "<br/><br/>"
|
|
||||||
parts = [f'<p>{item.title}</p>{SEP}']
|
|
||||||
if item.summary:
|
|
||||||
parts.append(f'<p>{item.summary}</p>{SEP}')
|
|
||||||
parts.append(f'<p><a href="{item.link}">{item.link}</a></p>')
|
|
||||||
return ''.join(parts)
|
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
from typing import List
|
||||||
from html import unescape
|
|
||||||
from typing import List, Optional
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
|
|
||||||
@ -14,27 +11,6 @@ from src.domain.subscriptions.entities import Subscription
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _strip_html(text: str) -> str:
|
|
||||||
"""Удаляет HTML-теги и декодирует HTML-сущности."""
|
|
||||||
text = re.sub(r'<[^>]+>', '', text)
|
|
||||||
return unescape(text).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_image_url(entry) -> Optional[str]:
|
|
||||||
"""Возвращает URL первого enclosure с type image/*.
|
|
||||||
Возвращает None если URL содержит ':' в path — признак hotlink-защищённого CDN.
|
|
||||||
"""
|
|
||||||
for enc in entry.get("enclosures", []):
|
|
||||||
if enc.get("type", "").startswith("image/"):
|
|
||||||
raw = enc.get("href") or enc.get("url")
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
if ":" in urlparse(raw).path:
|
|
||||||
return None
|
|
||||||
return raw
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class RssFetcher(NewsFetcher):
|
class RssFetcher(NewsFetcher):
|
||||||
|
|
||||||
async def fetch(self, subscription: Subscription) -> List[NewsItem]:
|
async def fetch(self, subscription: Subscription) -> List[NewsItem]:
|
||||||
@ -54,16 +30,8 @@ class RssFetcher(NewsFetcher):
|
|||||||
news_id = entry.get("id") or entry.get("link") or ""
|
news_id = entry.get("id") or entry.get("link") or ""
|
||||||
title = entry.get("title", "(без заголовка)")
|
title = entry.get("title", "(без заголовка)")
|
||||||
link = entry.get("link", "")
|
link = entry.get("link", "")
|
||||||
raw_summary = entry.get("summary")
|
summary = entry.get("summary")
|
||||||
summary = _strip_html(raw_summary) if raw_summary else None
|
|
||||||
image_url = _extract_image_url(entry)
|
|
||||||
if news_id:
|
if news_id:
|
||||||
items.append(NewsItem(
|
items.append(NewsItem(id=news_id, title=title, link=link, summary=summary))
|
||||||
id=news_id,
|
|
||||||
title=title,
|
|
||||||
link=link,
|
|
||||||
summary=summary,
|
|
||||||
image_url=image_url,
|
|
||||||
))
|
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|||||||
@ -8,4 +8,3 @@ class NewsItem:
|
|||||||
title: str
|
title: str
|
||||||
link: str
|
link: str
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
image_url: Optional[str] = None
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user