Andrey Epifancev 97e59c1cc6 wiki: перевод HTML Eva — правки ревью 2 Task 6 (#91)
Двойное экранирование текста ссылки на страницу убрано; «@» больше не
экранируется. Ссылку на вложение выдаёт только конвертер (внутренний маркер),
авторское «attachment:@» в тексте, атрибутах, коде и именах нейтрализуется,
схема attachment: у ссылок и картинок автора не выводится.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:10:56 +04:00

125 lines
6.7 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Перевод HTML страницы Eva в Markdown (wiki-module, spec wiki «Markdown-копия вики», design 10).
Агент присылает `attachments` и `links` с `ref` — значением атрибута `src`/`href` ровно как в HTML. По `ref` здесь
находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает.
HTML Eva недоверенный. Ссылка на вложение `](attachment:@N)` — единственная, которую заменяет `fill_ids`, и её
выдаёт только конвертер: авторский текст, атрибуты, код и имена не могут породить такую подстроку. Для этого
конвертер ставит внутренний маркер (приватные символы U+E000/U+E001), а в готовом Markdown нейтрализует любое
`attachment:@` из авторского содержимого и только после этого подставляет настоящие ссылки.
"""
import re
from bs4 import BeautifulSoup, NavigableString
from markdownify import MarkdownConverter
_STATIC = ("static/", "/static/")
_UNSAFE_SCHEME = re.compile(r"^(javascript|data|vbscript|attachment):")
_CONTROL = re.compile(r"[\x00-\x20\x7f]")
_NEWLINES = re.compile(r"\s*[\r\n]+\s*")
_PRIVATE = re.compile("[]")
_NAME_EDGE = re.compile(r"(?<!\w)[_*]|[_*](?!\w)") # «_» и «*» на границе слова; внутри слова — как есть
_BRACKET = re.compile(r"(?<!\\)([\[\]])")
_ATTACH_LINK = re.compile(r"\]\(attachment:@(\d+)\)")
_MARK_OPEN, _MARK_CLOSE = "", ""
_MARK = re.compile("(\\d+)")
def _unsafe(href: str) -> bool:
"""Исполняемая или внутренняя схема: управляющие символы и пробелы внутри схемы не мешают распознаванию."""
return bool(_UNSAFE_SCHEME.match(_CONTROL.sub("", href).lower()))
def _name(text: str) -> str:
"""Сырой текст агента (имя вложения, код) внутри [...]: переводы строк, Markdown-знаки и скобки экранируются."""
text = _NEWLINES.sub(" ", _PRIVATE.sub("", text))
text = re.sub(r"([\\\[\]])", r"\\\1", text)
text = _NAME_EDGE.sub(lambda m: "\\" + m.group(0), text)
return text.replace("<", "&lt;")
def _label(text: str) -> str:
"""Текст ссылки на страницу: уже экранирован markdownify, здесь только скобки и переводы строк."""
return _BRACKET.sub(r"\\\1", _NEWLINES.sub(" ", text))
class _Converter(MarkdownConverter):
def __init__(self, attachments, links, **options):
super().__init__(**options)
self._by_ref = {a["ref"]: a for a in attachments}
self._links = {l["ref"]: l["code"] for l in links}
self.files = [] # [{url, name}] без повторов по url, в порядке первого появления
self._index = {} # url -> индекс в files
def escape(self, text, parent_tags):
# «<» в тексте автора не должен выглядеть как тег; «@» не экранируется
return super().escape(text, parent_tags).replace("<", "&lt;")
def _attachment(self, entry):
url = entry["url"]
if url in self._index:
return "" # миниатюра и файл одного вложения: вторая ссылка удаляется
self._index[url] = len(self.files)
self.files.append({"url": url, "name": entry["name"]})
return "[📎 %s%s%d%s" % (_name(entry["name"]), _MARK_OPEN, self._index[url], _MARK_CLOSE)
def convert_img(self, el, text, parent_tags):
src = el.get("src") or ""
if src.startswith(_STATIC):
return ""
parent = el.parent
if parent is not None and parent.name == "a" and (parent.get("href") or "") in self._by_ref:
return "" # картинка внутри ссылки на вложение: ссылку выводит <a>
entry = self._by_ref.get(src)
if entry is not None:
return self._attachment(entry)
if _unsafe(src):
return ""
return super().convert_img(el, text, parent_tags)
def convert_a(self, el, text, parent_tags):
href = el.get("href") or ""
if href.startswith(_STATIC):
return ""
entry = self._by_ref.get(href)
if entry is not None:
return self._attachment(entry)
code = self._links.get(href)
if code is not None:
return "[%s](page:%s)" % (_label(text.strip()) or _name(code), code)
if _unsafe(href):
return text # исполняемая или внутренняя ссылка автора: остаётся только текст
return super().convert_a(el, text, parent_tags)
def _strip_private(soup) -> None:
"""Приватные символы U+E000/U+E001 из авторского HTML убираются: иначе они подделали бы маркер."""
for s in soup.find_all(string=True):
if type(s) is NavigableString and _PRIVATE.search(s):
s.replace_with(NavigableString(_PRIVATE.sub("", s)))
for el in soup.find_all(True):
for key, value in list(el.attrs.items()):
if isinstance(value, str) and _PRIVATE.search(value):
el[key] = _PRIVATE.sub("", value)
def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None) -> tuple[str, list[dict]]:
"""HTML страницы → (Markdown, вложения страницы [{url, name}])."""
soup = BeautifulSoup(html, "html.parser")
_strip_private(soup)
conv = _Converter(attachments or [], links or [], heading_style="ATX", autolinks=False)
md = conv.convert_soup(soup).strip() + "\n"
md = md.replace("attachment:@", "attachment:‌@") # авторское «attachment:@» больше не совпадает с шаблоном
md = _MARK.sub(lambda m: "](attachment:@%s)" % m.group(1), md) # настоящие ссылки вложений
return md, conv.files
def fill_ids(markdown: str, ids: list[int]) -> str:
"""`](attachment:@<n>)` → `](attachment:<ids[n]>)`: только ссылки вложений, которые выдал конвертер."""
def repl(m):
n = int(m.group(1))
if n >= len(ids):
raise ValueError(f"вложение @{n} вне списка id (всего {len(ids)})")
return "](attachment:%d)" % ids[n]
return _ATTACH_LINK.sub(repl, markdown)