From 8eadddba1f045e39bf33f818ed66affc9557a9aa Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 4 Aug 2026 20:35:47 +0500 Subject: [PATCH] Normalize HTML poster wrappers --- RAA.md | 2 + STATE.md | 3 + src/vk_parser_app/text_utils.py | 97 ++++++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/RAA.md b/RAA.md index 0c82649..1a52dc8 100644 --- a/RAA.md +++ b/RAA.md @@ -62,6 +62,8 @@ Database settings: Empty header/footer settings add nothing. MAX sends wrapper text as HTML. For clickable text use: `Link text`. + Wrapper HTML is normalized before publishing: broken/unsupported tags are + stripped, readable text is kept. ## Workers diff --git a/STATE.md b/STATE.md index 9130ffb..933411a 100644 --- a/STATE.md +++ b/STATE.md @@ -94,6 +94,9 @@ canonical git repository as FN-8. Code work for both projects must happen in `max_poster_header_text`, `max_poster_footer_text`. - MAX header/footer text is sent as HTML. For clickable text use: `Link text`. +- HTML wrapper settings are normalized before publishing: safe `http/https` + links and simple formatting stay, unsupported/broken tags are stripped while + their readable text is kept. - The editor textarea must show only clean editable text. - The list preview in `/editor` should show the publication preview, including composed hashtags. - `build_publication_text()` in `src/vk_parser_app/text_utils.py` is display/composition helper only. Do not use it before writing `final_text` to DB. diff --git a/src/vk_parser_app/text_utils.py b/src/vk_parser_app/text_utils.py index e7ddada..5eac148 100644 --- a/src/vk_parser_app/text_utils.py +++ b/src/vk_parser_app/text_utils.py @@ -1,6 +1,9 @@ from __future__ import annotations +import html +from html.parser import HTMLParser import re +from urllib.parse import urlparse def normalize_hash_tag(value: str, fallback: str = "source") -> str: @@ -47,7 +50,91 @@ def publication_hashtags(category_tag: str, source_tag: str, common_tags: object return " ".join(f"#{tag}" for tag in tags if tag) -import html +def _unescape_repeated(value: str, limit: int = 3) -> str: + current = str(value or "") + for _ in range(limit): + next_value = html.unescape(current) + if next_value == current: + break + current = next_value + return current + + +def _safe_href(value: str) -> str: + href = _unescape_repeated(value).strip() + parsed = urlparse(href) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return "" + return href + + +class _HTMLWrapperNormalizer(HTMLParser): + allowed_format_tags = {"b", "strong", "i", "em", "u", "s", "code"} + + def __init__(self) -> None: + super().__init__(convert_charrefs=False) + self.parts: list[str] = [] + self.open_tags: list[str] = [] + self.skip_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + if tag in {"script", "style"}: + self.skip_depth += 1 + return + if self.skip_depth: + return + if tag == "br": + self.parts.append("\n") + return + if tag == "a": + href = _safe_href(next((value or "" for name, value in attrs if name.lower() == "href"), "")) + if not href: + return + self.parts.append(f'') + self.open_tags.append("a") + return + if tag in self.allowed_format_tags: + self.parts.append(f"<{tag}>") + self.open_tags.append(tag) + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if tag in {"script", "style"} and self.skip_depth: + self.skip_depth -= 1 + return + if self.skip_depth or tag not in self.open_tags: + return + while self.open_tags: + opened = self.open_tags.pop() + self.parts.append(f"") + if opened == tag: + break + + def handle_data(self, data: str) -> None: + if not self.skip_depth: + self.parts.append(html.escape(data)) + + def handle_entityref(self, name: str) -> None: + self.handle_data(html.unescape(f"&{name};")) + + def handle_charref(self, name: str) -> None: + self.handle_data(html.unescape(f"&#{name};")) + + def normalized(self) -> str: + while self.open_tags: + self.parts.append(f"") + return re.sub(r"\n{3,}", "\n\n", "".join(self.parts)).strip() + + +def normalize_html_wrapper_text(value: str) -> str: + raw = str(value or "").strip() + if not raw: + return "" + parser = _HTMLWrapperNormalizer() + parser.feed(_unescape_repeated(raw)) + parser.close() + return parser.normalized() def build_publication_text( @@ -77,8 +164,12 @@ def build_publication_text( title_idx = idx break - header = str(header_text or "").strip() - footer = str(footer_text or "").strip() + if parse_mode == "html": + header = normalize_html_wrapper_text(header_text) + footer = normalize_html_wrapper_text(footer_text) + else: + header = str(header_text or "").strip() + footer = str(footer_text or "").strip() hashtags = publication_hashtags(category_tag, source_tag, common_tags) if title_idx is None: return "\n\n".join(part for part in [header, footer, hashtags] if part)