fc3e: html_to_plain + truncate_at_word utilities (stdlib html.parser, no new deps)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
"""HTML→plain converter and word-boundary truncator.
|
||||
|
||||
Used by PostFeedService to sanitize Post.description for the unified
|
||||
posts feed. Stdlib-only (no bleach dependency) — strips ALL tags and
|
||||
collapses whitespace.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
# <br>, </p>, </div>, </li>, </h1..6> → soft block separators we emit as \n
|
||||
# so paragraph breaks survive the strip.
|
||||
_BLOCK_END_TAGS = frozenset({
|
||||
"p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"blockquote", "tr", "ul", "ol",
|
||||
})
|
||||
_VOID_NEWLINE_TAGS = frozenset({"br"})
|
||||
|
||||
_MULTI_WS_RE = re.compile(r"[^\S\n]+") # runs of horizontal whitespace
|
||||
_MULTI_NL_RE = re.compile(r"\n{3,}") # cap consecutive newlines at 2
|
||||
|
||||
|
||||
class _PlainTextExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._chunks: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self._chunks.append(data)
|
||||
|
||||
def handle_starttag(self, tag: str, attrs) -> None:
|
||||
if tag in _VOID_NEWLINE_TAGS:
|
||||
self._chunks.append("\n")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in _BLOCK_END_TAGS:
|
||||
self._chunks.append("\n")
|
||||
|
||||
def get_text(self) -> str:
|
||||
return "".join(self._chunks)
|
||||
|
||||
|
||||
def html_to_plain(s: str | None) -> str:
|
||||
"""Strip HTML, normalize whitespace. Returns "" for None.
|
||||
|
||||
- All tags removed; entities decoded (stdlib parser does this).
|
||||
- <br> and block-closing tags (</p>, </div>, </li>, </h1..6>, ...)
|
||||
become "\\n" so paragraph breaks survive.
|
||||
- Horizontal whitespace runs collapse to a single space.
|
||||
- Three+ newlines compress to two; leading/trailing whitespace stripped.
|
||||
- Idempotent on plain-text input.
|
||||
"""
|
||||
if s is None:
|
||||
return ""
|
||||
parser = _PlainTextExtractor()
|
||||
parser.feed(s)
|
||||
parser.close()
|
||||
text = parser.get_text()
|
||||
# Collapse horizontal whitespace per line, then cap consecutive newlines.
|
||||
text = _MULTI_WS_RE.sub(" ", text)
|
||||
text = _MULTI_NL_RE.sub("\n\n", text)
|
||||
# Strip leading/trailing whitespace on the whole string AND each line.
|
||||
text = "\n".join(line.strip() for line in text.split("\n")).strip()
|
||||
return text
|
||||
|
||||
|
||||
def truncate_at_word(s: str | None, limit: int) -> tuple[str, bool]:
|
||||
"""Return (truncated, was_truncated). Cuts at last whitespace before
|
||||
``limit``; falls back to a hard cut if no whitespace is found.
|
||||
Appends an ellipsis when truncation occurs. Empty/None → ("", False).
|
||||
"""
|
||||
if not s:
|
||||
return ("", False)
|
||||
if len(s) <= limit:
|
||||
return (s, False)
|
||||
head = s[:limit]
|
||||
last_ws = head.rfind(" ")
|
||||
if last_ws > 0:
|
||||
head = head[:last_ws]
|
||||
return (head + "…", True)
|
||||
Reference in New Issue
Block a user