Normalize HTML poster wrappers
This commit is contained in:
@@ -62,6 +62,8 @@ Database settings:
|
|||||||
Empty header/footer settings add nothing.
|
Empty header/footer settings add nothing.
|
||||||
MAX sends wrapper text as HTML. For clickable text use:
|
MAX sends wrapper text as HTML. For clickable text use:
|
||||||
`<a href="https://example.com">Link text</a>`.
|
`<a href="https://example.com">Link text</a>`.
|
||||||
|
Wrapper HTML is normalized before publishing: broken/unsupported tags are
|
||||||
|
stripped, readable text is kept.
|
||||||
|
|
||||||
## Workers
|
## Workers
|
||||||
|
|
||||||
|
|||||||
@@ -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_poster_header_text`, `max_poster_footer_text`.
|
||||||
- MAX header/footer text is sent as HTML. For clickable text use:
|
- MAX header/footer text is sent as HTML. For clickable text use:
|
||||||
`<a href="https://example.com">Link text</a>`.
|
`<a href="https://example.com">Link text</a>`.
|
||||||
|
- 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 editor textarea must show only clean editable text.
|
||||||
- The list preview in `/editor` should show the publication preview, including composed hashtags.
|
- 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.
|
- `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.
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
from html.parser import HTMLParser
|
||||||
import re
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
def normalize_hash_tag(value: str, fallback: str = "source") -> str:
|
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)
|
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'<a href="{html.escape(href, quote=True)}">')
|
||||||
|
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(
|
def build_publication_text(
|
||||||
@@ -77,8 +164,12 @@ def build_publication_text(
|
|||||||
title_idx = idx
|
title_idx = idx
|
||||||
break
|
break
|
||||||
|
|
||||||
header = str(header_text or "").strip()
|
if parse_mode == "html":
|
||||||
footer = str(footer_text or "").strip()
|
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)
|
hashtags = publication_hashtags(category_tag, source_tag, common_tags)
|
||||||
if title_idx is None:
|
if title_idx is None:
|
||||||
return "\n\n".join(part for part in [header, footer, hashtags] if part)
|
return "\n\n".join(part for part in [header, footer, hashtags] if part)
|
||||||
|
|||||||
Reference in New Issue
Block a user