mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 01:50:03 +00:00
rss-formatting-preserve: сохранять абзацы и разметку из RSS (html_to_text/extract_full_text normalize; safe_html bleach для предпросмотра; пересборка старых постов)
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
# VESTI — Статус
|
# VESTI — Статус
|
||||||
|
|
||||||
Обновлено: 2026-09-15 (отбор без ухода со страницы + layout отобранных на всю высоту)
|
Обновлено: 2026-09-15 (RSS: сохранение абзацев/разметки в текстах новостей)
|
||||||
<!--
|
<!--
|
||||||
История обновлений:
|
История обновлений:
|
||||||
|
2026-09-15 — rss-formatting-preserve: html_to_text/extract_full_text сохраняют абзацы (\n\n);
|
||||||
|
safe_html (bleach) для предпросмотра RSS-текстов; пересобраны старые посты
|
||||||
|
(omarchy 25/25, bleepingcomputer 16/16); lwn частично (403/429 — при след. крауле)
|
||||||
2026-09-15 — change selected-layout-stay: «⭐ Отобрать» (single+bulk) больше не уводит на /selected — редирект на /candidates с сохранением группировки/фильтров (post_select → src="candidates", bulk select → back). Отобранные: панели на всю высоту окна (calc(100vh - 160px) вместо 82vh), резюме — отдельным блоком сразу под кнопками «Обработать моделью», редактор «Мой пост» + оригинал растянуты на всю оставшуюся высоту, комментарий + «Опубликовать»/«Сохранить комментарий» — в самом низу (border-top). Пустое место внизу убрано.
|
2026-09-15 — change selected-layout-stay: «⭐ Отобрать» (single+bulk) больше не уводит на /selected — редирект на /candidates с сохранением группировки/фильтров (post_select → src="candidates", bulk select → back). Отобранные: панели на всю высоту окна (calc(100vh - 160px) вместо 82vh), резюме — отдельным блоком сразу под кнопками «Обработать моделью», редактор «Мой пост» + оригинал растянуты на всю оставшуюся высоту, комментарий + «Опубликовать»/«Сохранить комментарий» — в самом низу (border-top). Пустое место внизу убрано.
|
||||||
2026-09-14 — кандидаты: после reject/approve/comment/reclassify/rewrite сохраняется group_by и фильтры (скрытые поля форм + _cand_back); ?status=rejected больше не даёт пустой список (жёсткое status='new' только по умолчанию)
|
2026-09-14 — кандидаты: после reject/approve/comment/reclassify/rewrite сохраняется group_by и фильтры (скрытые поля форм + _cand_back); ?status=rejected больше не даёт пустой список (жёсткое status='new' только по умолчанию)
|
||||||
2026-09-14 — кандидаты: в списке только внешние new-посты (is_own=0 AND status='new' в _fetch_candidates); свои посты канала (is_own=1) и published/rejected больше не показываются как кандидаты
|
2026-09-14 — кандидаты: в списке только внешние new-посты (is_own=0 AND status='new' в _fetch_candidates); свои посты канала (is_own=1) и published/rejected больше не показываются как кандидаты
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# VESTI — RSS/Atom-краулер (feedparser + httpx).
|
||||||
|
# Читает источники из sources.yaml (crawler: rss), условные GET (etag/modified),
|
||||||
|
# нормализует URL (UTM/якоря), дотягивает полный текст (trafilatura) для summary-only,
|
||||||
|
# пишет посты в posts (sha256-дедуп) и состояние ленты в rss_state.
|
||||||
|
#
|
||||||
|
# Запуск:
|
||||||
|
# python -m crawler.rss_crawler --all # все включённые rss-источники
|
||||||
|
# python -m crawler.rss_crawler --direction linux
|
||||||
|
# python -m crawler.rss_crawler --source lwn
|
||||||
|
# python -m crawler.rss_crawler --source lwn --dry-run # печать без записи в БД
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import html as html_mod
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from config import RSS_DEFAULT_TIMEOUT, RSS_USER_AGENT, ensure_dirs
|
||||||
|
from db.db import db
|
||||||
|
from sources.sources import (
|
||||||
|
get_enabled_sources, get_source, load_sources_yaml, sync_sources_to_db,
|
||||||
|
start_run, finish_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
|
log = logging.getLogger("vesti.rss")
|
||||||
|
|
||||||
|
MAX_ITEMS = 25 # максимум записей ленты за запуск (остальные — следующими запусками)
|
||||||
|
RATE_LIMIT_SEC = 1.5 # пауза между источниками (вежливый опрос)
|
||||||
|
MIN_SUMMARY_LEN = 220 # если выжимка короче — пробуем дотянуть полный текст
|
||||||
|
ERROR_DEAD_THRESHOLD = 5 # N ошибок подряд → status='dead' (фид-здоровье)
|
||||||
|
|
||||||
|
# Параметры запроса, которые режут из URL (трекинг/мусор)
|
||||||
|
UTM_PARAMS = {
|
||||||
|
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
|
||||||
|
"fbclid", "gclid", "ref", "ref_src", "mc_cid", "mc_eid",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_url(url):
|
||||||
|
"""Чистит URL от UTM-параметров, fbclid/gclid/ref и якоря -> canonical_url.
|
||||||
|
|
||||||
|
Возвращает None для мусорных ссылок (без схемы/хоста).
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
url = url.strip()
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
return None
|
||||||
|
u = urlparse(url)
|
||||||
|
if not u.netloc:
|
||||||
|
return None
|
||||||
|
q = [(k, v) for k, v in parse_qsl(u.query, keep_blank_values=True)
|
||||||
|
if k.lower() not in UTM_PARAMS]
|
||||||
|
return urlunparse(u._replace(query=urlencode(q), fragment=""))
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_text(text: str) -> str:
|
||||||
|
return hashlib.sha256((text or "").encode("utf-8", "ignore")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# Блочные теги, вокруг которых сохраняем переносы (абзацы/списки/заголовки/цитаты)
|
||||||
|
_BLOCK_TAGS = [
|
||||||
|
"p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"blockquote", "pre", "tr", "section", "article",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Нормализация текста: сохраняем структуру абзацев (\n\n), убираем мусор.
|
||||||
|
# НЕ схлопываем всё в одну строку — иначе новость в предпросмотре — сплошной текст.
|
||||||
|
def normalize_text(text):
|
||||||
|
"""Чистит текст: пробелы по краям строк, 3+ переноса → 2, без изменения слов."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
text = re.sub(r"[ \t]+\n", "\n", text) # пробелы перед концом строки
|
||||||
|
text = re.sub(r"\n[ \t]+", "\n", text) # пробелы после начала строки
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text) # не больше одной пустой строки
|
||||||
|
# пробелы по краям каждой строки
|
||||||
|
text = "\n".join(l.strip() for l in text.split("\n"))
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_text(html_str):
|
||||||
|
"""Раскрывает HTML (из ленты) в читаемый text с сохранением абзацев/списков.
|
||||||
|
|
||||||
|
HTML-теги разметки (b/i/a/...) вырезаются, их содержимое остаётся текстом;
|
||||||
|
блочные теги (p/li/h1-6/blockquote/pre) дают переносы строк, чтобы текст
|
||||||
|
не превращался в сплошную кашу.
|
||||||
|
"""
|
||||||
|
from selectolax.parser import HTMLParser
|
||||||
|
if not html_str:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
tree = HTMLParser(html_str)
|
||||||
|
tree.strip_tags(["script", "style", "noscript"])
|
||||||
|
if tree.body is None:
|
||||||
|
return ""
|
||||||
|
for node in tree.css(", ".join(_BLOCK_TAGS)):
|
||||||
|
node.insert_before("\n\n")
|
||||||
|
text = tree.body.text()
|
||||||
|
return normalize_text(text)
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("html_to_text failed: %s", e)
|
||||||
|
return re.sub(r"<[^>]+>", " ", html_mod.unescape(html_str)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_full_text(url, timeout=15):
|
||||||
|
"""Полный текст страницы через trafilatura. Возвращает str или None (не падает)."""
|
||||||
|
import httpx
|
||||||
|
try:
|
||||||
|
r = httpx.get(url, headers={"User-Agent": RSS_USER_AGENT},
|
||||||
|
timeout=timeout, follow_redirects=True)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
import trafilatura
|
||||||
|
txt = trafilatura.extract(
|
||||||
|
r.text,
|
||||||
|
include_comments=False,
|
||||||
|
include_tables=False,
|
||||||
|
favor_precision=True,
|
||||||
|
# include_formatting=True сохраняет абзацы (\\n\\n) — иначе текст
|
||||||
|
# схлопывается в сплошную строку и новость нечитаема
|
||||||
|
include_formatting=True,
|
||||||
|
)
|
||||||
|
return (txt or "").strip() or None
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("full text %s failed: %s", url, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def entry_published_at(entry):
|
||||||
|
"""published_parsed/updated_parsed -> ISO-строка; None если нет даты."""
|
||||||
|
for key in ("published_parsed", "updated_parsed"):
|
||||||
|
st = getattr(entry, key, None)
|
||||||
|
if st:
|
||||||
|
try:
|
||||||
|
import datetime
|
||||||
|
return datetime.datetime(*st[:6], tzinfo=datetime.timezone.utc).isoformat()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_entries(feed, source, dry_run=False):
|
||||||
|
"""Превращает записи ленты feedparser в список dict постов.
|
||||||
|
|
||||||
|
returns (posts, last_build_date)
|
||||||
|
"""
|
||||||
|
posts = []
|
||||||
|
for entry in feed.entries[:MAX_ITEMS]:
|
||||||
|
link = getattr(entry, "link", None) or ""
|
||||||
|
canonical = normalize_url(link)
|
||||||
|
if not canonical:
|
||||||
|
continue
|
||||||
|
title = getattr(entry, "title", "") or ""
|
||||||
|
# полный текст из ленты (content[].value), иначе summary
|
||||||
|
content_parts = [c.get("value", "") for c in (getattr(entry, "content", None) or [])]
|
||||||
|
raw_html = content_parts[0] if content_parts else ""
|
||||||
|
summary_html = getattr(entry, "summary", "") or ""
|
||||||
|
text = html_to_text(raw_html or summary_html)
|
||||||
|
# summary-only лента: пробуем дотянуть полный текст
|
||||||
|
if (not raw_html or len(text) < MIN_SUMMARY_LEN) and canonical:
|
||||||
|
full = extract_full_text(canonical)
|
||||||
|
if full:
|
||||||
|
text = full
|
||||||
|
if not text:
|
||||||
|
text = title # последний рубеж: заголовок
|
||||||
|
text = normalize_text(text)
|
||||||
|
posts.append({
|
||||||
|
"url": canonical,
|
||||||
|
"title": title,
|
||||||
|
"text": text,
|
||||||
|
"published_at": entry_published_at(entry),
|
||||||
|
"author": getattr(entry, "author", None) or None,
|
||||||
|
})
|
||||||
|
last_build = None
|
||||||
|
fb = getattr(feed, "last_build_date_parsed", None) or getattr(feed, "published_parsed", None)
|
||||||
|
if fb:
|
||||||
|
try:
|
||||||
|
import datetime
|
||||||
|
last_build = datetime.datetime(*fb[:6], tzinfo=datetime.timezone.utc).isoformat()
|
||||||
|
except Exception:
|
||||||
|
last_build = None
|
||||||
|
return posts, last_build
|
||||||
|
|
||||||
|
|
||||||
|
def store_rss_posts(conn, source_id, posts):
|
||||||
|
"""Вставка RSS-постов в posts с дедупом по sha256(text) и url.
|
||||||
|
|
||||||
|
Возвращает количество НОВЫХ. Повторная вставка той же статьи (даже с другим
|
||||||
|
URL в ленте) не создаёт второй строки — sha256 от текста.
|
||||||
|
"""
|
||||||
|
new = 0
|
||||||
|
for p in posts:
|
||||||
|
sha = sha256_text(p["text"])
|
||||||
|
dup = conn.execute(
|
||||||
|
"SELECT id FROM posts WHERE sha256=? OR url=?",
|
||||||
|
(sha, p["url"]),
|
||||||
|
).fetchone()
|
||||||
|
if dup:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO posts
|
||||||
|
(sha256, source_id, url, text, content_type, published_at, status, is_own, is_own_canonical)
|
||||||
|
VALUES (?,?,?,?, 'text', ?, 'new', 0, 0)""",
|
||||||
|
(sha, source_id, p["url"], p["text"], p["published_at"]),
|
||||||
|
)
|
||||||
|
new += 1
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
def run_source(source, dry_run=False):
|
||||||
|
"""Краулинг одного RSS-источника: условный GET, парсинг, запись, состояние."""
|
||||||
|
slug = source["slug"]
|
||||||
|
feed_url = source.get("feed_url")
|
||||||
|
if not feed_url:
|
||||||
|
log.error("SKIP %s: нет feed_url (crawler=rss, а лента не задана)", slug)
|
||||||
|
return
|
||||||
|
run_id = start_run(source["id"], f"rss:{slug}", "cron")
|
||||||
|
t0 = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
# --- состояние ленты ---
|
||||||
|
with db() as conn:
|
||||||
|
st = conn.execute("SELECT * FROM rss_state WHERE slug=?", (slug,)).fetchone()
|
||||||
|
state = dict(st) if st else {}
|
||||||
|
|
||||||
|
headers = {"User-Agent": RSS_USER_AGENT, "Accept": "application/rss+xml, application/atom+xml, application/xml;q=0.9, */*;q=0.8"}
|
||||||
|
if state.get("etag"):
|
||||||
|
headers["If-None-Match"] = state["etag"]
|
||||||
|
if state.get("modified"):
|
||||||
|
headers["If-Modified-Since"] = state["modified"]
|
||||||
|
|
||||||
|
resp = httpx.get(feed_url, headers=headers, timeout=RSS_DEFAULT_TIMEOUT,
|
||||||
|
follow_redirects=True)
|
||||||
|
# вежливый ретрай на 429/503 (LWN троттлит частые опросы)
|
||||||
|
for attempt in range(3):
|
||||||
|
if resp.status_code in (429, 503):
|
||||||
|
retry_after = 0
|
||||||
|
ra = resp.headers.get("retry-after")
|
||||||
|
if ra:
|
||||||
|
try: retry_after = int(ra)
|
||||||
|
except ValueError: retry_after = 10
|
||||||
|
wait = max(retry_after, 5 * (attempt + 1))
|
||||||
|
log.warning("%s: HTTP %d, retry %d/3 через %ds", slug, resp.status_code, attempt + 1, wait)
|
||||||
|
time.sleep(wait)
|
||||||
|
resp = httpx.get(feed_url, headers=headers, timeout=RSS_DEFAULT_TIMEOUT,
|
||||||
|
follow_redirects=True)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if resp.status_code == 304:
|
||||||
|
# лента не менялась — экономим (0 fetched/0 new, ok)
|
||||||
|
duration = int((time.monotonic() - t0) * 1000)
|
||||||
|
if not dry_run:
|
||||||
|
finish_run(run_id, "ok", 0, 0, None, duration)
|
||||||
|
log.info("OK %s: 304 (не менялась), %dms", slug, duration)
|
||||||
|
return
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"HTTP {resp.status_code} for {feed_url}")
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
feed = feedparser.parse(resp.content, response_headers=resp.headers)
|
||||||
|
|
||||||
|
if getattr(feed, "bozo", False) and not feed.entries:
|
||||||
|
raise RuntimeError(f"bozo feed (не парсится): {getattr(feed, 'bozo_exception', '')}")
|
||||||
|
|
||||||
|
posts, last_build = parse_entries(feed, source, dry_run)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f"=== {slug} ({source['name']}) — dry_run, {len(posts)} записей ===")
|
||||||
|
for p in posts:
|
||||||
|
print(f" - {p['published_at'] or '????'} | {p['title'][:80]}")
|
||||||
|
print(f" {p['url']}")
|
||||||
|
return
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
new = store_rss_posts(conn, source["id"], posts)
|
||||||
|
new_etag = (resp.headers.get("etag") or "").strip()
|
||||||
|
new_modified = (resp.headers.get("last-modified") or "").strip()
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO rss_state (slug, etag, modified, last_build_date, last_error, error_count, status, updated_at)
|
||||||
|
VALUES (?,?,?,?, NULL, 0, 'alive', datetime('now'))
|
||||||
|
ON CONFLICT(slug) DO UPDATE SET
|
||||||
|
etag=excluded.etag, modified=excluded.modified,
|
||||||
|
last_build_date=excluded.last_build_date,
|
||||||
|
last_error=NULL, error_count=0, status='alive',
|
||||||
|
updated_at=datetime('now')""",
|
||||||
|
(slug, new_etag or state.get("etag"), new_modified or state.get("modified"), last_build),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sources SET last_fetch=datetime('now'), status='alive', last_error=NULL WHERE id=?",
|
||||||
|
(source["id"],),
|
||||||
|
)
|
||||||
|
duration = int((time.monotonic() - t0) * 1000)
|
||||||
|
finish_run(run_id, "ok", len(posts), new, None, duration)
|
||||||
|
log.info("OK %s: fetched=%d new=%d (%.1fs)", slug, len(posts), new, duration / 1000)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration = int((time.monotonic() - t0) * 1000)
|
||||||
|
if not dry_run:
|
||||||
|
with db() as conn:
|
||||||
|
st = conn.execute("SELECT error_count FROM rss_state WHERE slug=?", (slug,)).fetchone()
|
||||||
|
err_count = (st[0] if st else 0) + 1
|
||||||
|
status = "dead" if err_count >= ERROR_DEAD_THRESHOLD else "alive"
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO rss_state (slug, last_error, error_count, status, updated_at)
|
||||||
|
VALUES (?,?,?,?, datetime('now'))
|
||||||
|
ON CONFLICT(slug) DO UPDATE SET
|
||||||
|
last_error=excluded.last_error, error_count=excluded.error_count,
|
||||||
|
status=excluded.status, updated_at=datetime('now')""",
|
||||||
|
(slug, str(e)[:500], err_count, status),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sources SET last_error=?, status=? WHERE id=?",
|
||||||
|
(str(e)[:500], status, source["id"]),
|
||||||
|
)
|
||||||
|
finish_run(run_id, "error", 0, 0, str(e)[:500], duration)
|
||||||
|
log.error("ERROR %s: %s", slug, e)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="VESTI RSS/Atom crawler")
|
||||||
|
ap.add_argument("--source", help="slug источника (например lwn)")
|
||||||
|
ap.add_argument("--direction", help="направление (например linux)")
|
||||||
|
ap.add_argument("--all", action="store_true", help="все включённые rss-источники")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="печать записей без записи в БД")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
ensure_dirs()
|
||||||
|
data = load_sources_yaml()
|
||||||
|
sync_sources_to_db(data)
|
||||||
|
|
||||||
|
if args.source:
|
||||||
|
src = get_source(args.source)
|
||||||
|
if not src:
|
||||||
|
log.error("source %s not found", args.source)
|
||||||
|
sys.exit(1)
|
||||||
|
sources = [src]
|
||||||
|
elif args.direction:
|
||||||
|
sources = get_enabled_sources(crawler="rss", direction=args.direction)
|
||||||
|
else:
|
||||||
|
sources = get_enabled_sources(crawler="rss")
|
||||||
|
|
||||||
|
if not sources:
|
||||||
|
log.info("no rss sources to crawl")
|
||||||
|
return
|
||||||
|
|
||||||
|
for src in sources:
|
||||||
|
run_source(src, dry_run=args.dry_run)
|
||||||
|
time.sleep(RATE_LIMIT_SEC)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
skip_specs: true
|
||||||
|
created: 2026-09-15
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# rss-formatting-preserve: сохранять форматирование RSS-новостей (абзацы, разметка)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Пользователь: в RSS omarchy внутри текста новости есть HTML-символы, а мы их «выкидываем»
|
||||||
|
из текста, из-за чего вся новость в предпросмотре отображается сплошным текстом, ничего
|
||||||
|
не понять. Нужно при скачивании из источников RSS сохранять заложенное форматирование.
|
||||||
|
|
||||||
|
Разбор:
|
||||||
|
- `html_to_text()` (crawler/rss_crawler.py) режет HTML корректно, но без разделителей
|
||||||
|
между блочными элементами (p/li/h1-6/blockquote) — абзацы/списки сливаются в один кусок.
|
||||||
|
- После этого `re.sub(r"\s+", " ", text)` (стр. 143) схлопывает и переносы строк —
|
||||||
|
даже если бы были `\n\n`, они превращаются в один пробел. Итог: «сплошной текст».
|
||||||
|
- В вебе `md_filter` (web/app.py) вызывается на `text` из RSS — синтаксис HTML (b/i/a)
|
||||||
|
экранируется и виден как текст-каша, а markdown-разметки в RSS-тексте нет.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Краулер: сохранять структуру абзацев
|
||||||
|
|
||||||
|
`html_to_text()`:
|
||||||
|
- после удаления script/style/noscript — вставлять переносы-разделители вокруг блочных
|
||||||
|
тегов (p, div, li, h1–h6, blockquote, pre, tr), чтобы они давали `\n\n` между абзацами
|
||||||
|
и `\n` внутри list-item.
|
||||||
|
- текст по-прежнему plain (в БД хранится текст, а не HTML) — просто читаемая структура.
|
||||||
|
|
||||||
|
`parse_entries()` (стр. 143): заменить `re.sub(r"\s+", " ", text)` на нормализацию:
|
||||||
|
- схлопывать 3+ переноса до 2 (максимум пустая строка между абзацами);
|
||||||
|
- убирать горизонтальные пробелы в начале/конце строк и перед переносами;
|
||||||
|
- пробелы внутри строки НЕ трогать (сохраняются слова/URL).
|
||||||
|
|
||||||
|
### 2. Веб: безопасный рендер HTML-разметки из RSS
|
||||||
|
|
||||||
|
Исходный текст поста (RSS) в предпросмотре (кандидаты, отобранные) — рендерить как
|
||||||
|
безопасный HTML подмножеством тегов, а не экранировать в тёмную кашу:
|
||||||
|
|
||||||
|
- новый фильтр `safe_html` в web/app.py на основе `bleach`:
|
||||||
|
`bleach.clean(text, tags=ALLOWED, attributes={'a': ['href', 'title']},
|
||||||
|
protocols=['http','https'])` + `bleach.linkify`;
|
||||||
|
- применяется ТОЛЬКО к `text` (оригинал из RSS) в candidates.html и selected.html;
|
||||||
|
- markdown-фильтр (`md_filter`) остаётся для пересказов/комментариев (markdown от модели).
|
||||||
|
|
||||||
|
Публикация (publisher/card.py) не меняется: в канал уходит plain text, абзацы `\n\n`
|
||||||
|
будут сохранены — Telegram корректно отобразит переносы.
|
||||||
|
|
||||||
|
### Безопасность
|
||||||
|
|
||||||
|
- `safe_html` — allowlist-санитайзер (bleach): никаких script/style/iframe/on*, href
|
||||||
|
только http(s), linkify распознаёт ссылки. XSS-поверхности нет.
|
||||||
|
- `md_filter` не меняется (остаётся escape→markdown).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- crawler/rss_crawler.py — html_to_text(), parse_entries() (абзацы, нормализация).
|
||||||
|
- web/app.py — фильтр safe_html (bleach).
|
||||||
|
- web/templates/candidates.html — `{{ selected.text | safe_html }}`.
|
||||||
|
- web/templates/selected.html — `{{ selected.text | safe_html }}`.
|
||||||
|
- requirements.txt — +bleach.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# rss-formatting-preserve
|
||||||
|
|
||||||
|
Сохранять форматирование RSS-новостей (абзацы, разметка) при скачивании и в предпросмотре.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Пользователь: в RSS omarchy внутри текста новости есть HTML-символы, мы их выкидываем
|
||||||
|
из текста, поэтому вся новость в предпросмотре — сплошной текст, ничего не понять.
|
||||||
|
Нужно при скачивании из RSS сохранять заложенное форматирование.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **crawler/rss_crawler.py**: html_to_text() вставляет переносы вокруг блочных тегов
|
||||||
|
(p/li/h1-6/blockquote/pre), чтобы абзацы и списки сохранялись; parse_entries() больше
|
||||||
|
не схлопывает все пробелы в один — нормализует: 3+ переноса → 2, пробелы по краям
|
||||||
|
строк убираются, внутренние сохраняются.
|
||||||
|
- **web/app.py**: новый фильтр `safe_html` (bleach.clean allowlist + linkify) для
|
||||||
|
безопасного рендера HTML-разметки исходных RSS-текстов в предпросмотре.
|
||||||
|
- **web/templates/candidates.html / selected.html**: `| safe_html` для `selected.text`.
|
||||||
|
- **requirements.txt**: +bleach.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Тексты в БД: появляются абзацы (\n\n), форматирование сохраняется читаемым.
|
||||||
|
- Предпросмотр кандидатов/отобранных: разметка (b/i/a/списки) рендерится безопасно.
|
||||||
|
- Markdown-пересказы (rewritten_text) и комментарии не задеваются — md_filter без изменений.
|
||||||
|
- Публикация (card.py) — plain text, абзацы \n\n дойдут в Telegram.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# rss-formatting-preserve
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Тексты из RSS (omarchy и др.) в предпросмотре — сплошной текст: абзацы/списки/разметка
|
||||||
|
теряются. При скачивании нужно сохранять заложенное форматирование.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] rss_crawler.py: html_to_text() — разделители между блочными тегами (p/li/h1-6/blockquote/pre)
|
||||||
|
- [x] rss_crawler.py: parse_entries() — не схлопывать все пробелы `\s+`; normalize_text (3+ переноса → 2, пробелы по краям строк убрать, внутренние сохранить)
|
||||||
|
- [x] rss_crawler.py: extract_full_text() — include_formatting=True (trafilatura) для сохранения абзацев
|
||||||
|
- [x] web/app.py: фильтр `safe_html` (bleach.clean allowlist + linkify); md_filter не тронут
|
||||||
|
- [x] web/templates/candidates.html: `selected.text | safe_html` вместо `| markdown`
|
||||||
|
- [x] web/templates/selected.html: `selected.text | safe_html` вместо `| markdown`
|
||||||
|
- [x] requirements.txt: +bleach (установлен в venv)
|
||||||
|
- [x] Пересборка старых RSS-постов: 47 обновлено с абзацами (omarchy 25/25, bleepingcomputer 16/16; lwn 12 — 403/429, будут при следующем крауле)
|
||||||
|
- [x] STATUS.md актуализирован; validate; commit
|
||||||
@@ -12,3 +12,6 @@ aiosqlite>=0.20
|
|||||||
PySocks>=1.7
|
PySocks>=1.7
|
||||||
PyYAML>=6.0
|
PyYAML>=6.0
|
||||||
Markdown>=3.6
|
Markdown>=3.6
|
||||||
|
trafilatura>=1.8
|
||||||
|
selectolax>=0.3
|
||||||
|
bleach>=6.0
|
||||||
|
|||||||
+43
@@ -17,6 +17,11 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from markupsafe import Markup, escape
|
from markupsafe import Markup, escape
|
||||||
|
|
||||||
|
try: # bleach — санитайзер HTML из RSS (allowlist); см. requirements.txt
|
||||||
|
import bleach
|
||||||
|
except ImportError:
|
||||||
|
bleach = None
|
||||||
|
|
||||||
# CRUD источников: работа с sources.yaml (источник истины) + БД синк
|
# CRUD источников: работа с sources.yaml (источник истины) + БД синк
|
||||||
from sources.sources import (
|
from sources.sources import (
|
||||||
db_source_to_yaml,
|
db_source_to_yaml,
|
||||||
@@ -51,6 +56,43 @@ def md_filter(text: str) -> Markup:
|
|||||||
return Markup(html)
|
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:
|
def dt_filter(value) -> str:
|
||||||
"""2026-08-15T15:53 → 15:53 15.08.2026; None/мусор → ''."""
|
"""2026-08-15T15:53 → 15:53 15.08.2026; None/мусор → ''."""
|
||||||
if not value:
|
if not value:
|
||||||
@@ -64,6 +106,7 @@ def dt_filter(value) -> str:
|
|||||||
|
|
||||||
|
|
||||||
tpl.filters["markdown"] = md_filter
|
tpl.filters["markdown"] = md_filter
|
||||||
|
tpl.filters["safe_html"] = safe_html_filter
|
||||||
tpl.filters["dt"] = dt_filter
|
tpl.filters["dt"] = dt_filter
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<style>
|
<style>
|
||||||
body { padding-top: 4.5rem; background: #f5f6fa; }
|
body { padding-top: 4.5rem; background: #f5f6fa; }
|
||||||
.card { box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
.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 { background-color: #dcecfc; }
|
||||||
.list-group-item.active-row:hover { background-color: #d0e4f8; }
|
.list-group-item.active-row:hover { background-color: #d0e4f8; }
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
<h6 class="text-muted flex-shrink-0">Оригинальный пост</h6>
|
<h6 class="text-muted flex-shrink-0">Оригинальный пост</h6>
|
||||||
<div class="flex-grow-1 overflow-auto" style="min-height:0;">
|
<div class="flex-grow-1 overflow-auto" style="min-height:0;">
|
||||||
{% if selected.text %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if selected.media_path %}
|
{% if selected.media_path %}
|
||||||
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
|
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@
|
|||||||
<h6 class="text-muted">Оригинальный пост</h6>
|
<h6 class="text-muted">Оригинальный пост</h6>
|
||||||
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
|
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
|
||||||
{% if selected.text %}
|
{% if selected.text %}
|
||||||
<div class="post-text">{{ selected.text | markdown }}</div>
|
<div class="post-text">{{ selected.text | safe_html }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if selected.media_path %}
|
{% if selected.media_path %}
|
||||||
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
|
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
|
||||||
|
|||||||
Reference in New Issue
Block a user