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"{opened}>")
+ 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"{self.open_tags.pop()}>")
+ 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)