"""Перевод 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"(? 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("<", "<")
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("<", "<")
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 "" # картинка внутри ссылки на вложение: ссылку выводит
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:@)` → `](attachment:)`: только ссылки вложений, которые выдал конвертер."""
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)