rss-formatting-preserve: сохранять абзацы и разметку из RSS (html_to_text/extract_full_text normalize; safe_html bleach для предпросмотра; пересборка старых постов)

This commit is contained in:
kpa39l
2026-09-15 18:53:57 +00:00
parent b25054e1df
commit 582dc3cf66
11 changed files with 521 additions and 4 deletions
+43
View File
@@ -17,6 +17,11 @@ from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
from markupsafe import Markup, escape
try: # bleach — санитайзер HTML из RSS (allowlist); см. requirements.txt
import bleach
except ImportError:
bleach = None
# CRUD источников: работа с sources.yaml (источник истины) + БД синк
from sources.sources import (
db_source_to_yaml,
@@ -51,6 +56,43 @@ def md_filter(text: str) -> Markup:
return Markup(html)
# Теги/атрибуты, разрешённые в текстах из RSS (безопасное подмножество HTML).
_SAFE_TAGS = [
"p", "br", "b", "strong", "i", "em", "u", "s", "strike", "del",
"a", "ul", "ol", "li", "blockquote", "pre", "code", "h1", "h2", "h3",
"h4", "h5", "h6", "table", "thead", "tbody", "tr", "th", "td",
]
_SAFE_ATTRS = {"a": ["href", "title", "rel"]}
_SAFE_PROTOCOLS = ["http", "https", "mailto"]
def safe_html_filter(text: str) -> Markup:
"""Исходный текст из RSS → безопасный HTML (allowlist-санитайзер).
Сохраняет «заложенное форматирование» (жирный, ссылки, списки, абзацы),
вырезает всё опасное (script/style/on*/iframe/svg/...) и делает ссылки
кликабельными (linkify). Для markdown-пересказов/комментариев НЕ используется —
они идут через md_filter.
"""
if not text:
return Markup("")
if bleach is None:
# запасной вариант без bleach — экранируем (безопасно, но без разметки)
return Markup(md_lib.markdown(escape(text), extensions=["nl2br", "sane_lists"]))
try:
cleaned = bleach.clean(
text,
tags=_SAFE_TAGS,
attributes=_SAFE_ATTRS,
protocols=_SAFE_PROTOCOLS,
strip=True,
)
cleaned = bleach.linkify(cleaned, parse_email=True)
return Markup(cleaned)
except Exception:
return Markup(escape(text))
def dt_filter(value) -> str:
"""2026-08-15T15:53 → 15:53 15.08.2026; None/мусор → ''."""
if not value:
@@ -64,6 +106,7 @@ def dt_filter(value) -> str:
tpl.filters["markdown"] = md_filter
tpl.filters["safe_html"] = safe_html_filter
tpl.filters["dt"] = dt_filter
+1 -1
View File
@@ -8,7 +8,7 @@
<style>
body { padding-top: 4.5rem; background: #f5f6fa; }
.card { box-shadow: 0 1px 4px rgba(0,0,0,.08); }
.post-text { white-space: pre-wrap; max-height: 140px; overflow-y: auto; font-size: .9rem; }
.post-text { white-space: pre-wrap; font-size: .9rem; }
/* список кандидатов: выбранный пост — светло-голубая заливка карточки */
.list-group-item.active-row { background-color: #dcecfc; }
.list-group-item.active-row:hover { background-color: #d0e4f8; }
+1 -1
View File
@@ -168,7 +168,7 @@
<h6 class="text-muted flex-shrink-0">Оригинальный пост</h6>
<div class="flex-grow-1 overflow-auto" style="min-height:0;">
{% if selected.text %}
<div class="post-text mt-1">{{ selected.text | markdown }}</div>
<div class="post-text mt-1">{{ selected.text | safe_html }}</div>
{% endif %}
{% if selected.media_path %}
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
+1 -1
View File
@@ -174,7 +174,7 @@
<h6 class="text-muted">Оригинальный пост</h6>
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
{% if selected.text %}
<div class="post-text">{{ selected.text | markdown }}</div>
<div class="post-text">{{ selected.text | safe_html }}</div>
{% endif %}
{% if selected.media_path %}
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}