"""Перевод 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 _tail(text: str) -> str:
"""Хвостовая «\\» экранировала бы закрывающую скобку ссылки: нечётная серия в конце удваивается (T6.5)."""
m = _TAIL.search(text)
return text + "\\" if m and len(m.group(0)) % 2 else text
def _label(text: str) -> str:
"""Текст ссылки на страницу: уже экранирован markdownify, здесь скобки, переводы строк и хвостовая «\\»."""
return _tail(_BRACKET.sub(r"\\\1", _NEWLINES.sub(" ", text)))
def _href(url: str) -> str:
"""Пробел и круглые скобки ломают разбор `[текст](адрес)`: кодируются; прочее — как есть (T6.4)."""
return url.replace(" ", "%20").replace("(", "%28").replace(")", "%29")
class _Converter(MarkdownConverter):
def __init__(self, attachments, links, **options):
super().__init__(**options)
self._by_ref, self._links = {}, {}
for a in attachments: # при повторе ref действует первая запись (T6.3)
self._by_ref.setdefault(a["ref"], a)
for l in links:
self._links.setdefault(l["ref"], l["code"])
self.files = [] # [{url, name}] без повторов по url, в порядке первого появления
self._index = {} # url -> индекс в files
self._frags = [] # выданные ссылки вложений: по ним из текста выносятся вложенные вложения
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"]})
frag = "[📎 %s%s%d%s" % (_name(entry["name"]), _MARK_OPEN, self._index[url], _MARK_CLOSE)
self._frags.append(frag)
return frag
def _split_marks(self, text: str) -> tuple[str, str]:
"""(ссылки вложений из текста подряд, остаток текста): ссылка внутри ссылки в Markdown невозможна (T6.2)."""
found = sorted((text.find(f), f) for f in self._frags if f in text)
for _, f in found:
text = text.replace(f, "", 1)
return "".join(f for _, f in found), text
def convert_img(self, el, text, parent_tags):
src = el.get("src") or ""
if src.startswith(_STATIC):
return ""
entry = self._by_ref.get(src)
anchor = el.find_parent("a") # на любой глубине:
(T6.1)
outer = self._by_ref.get(anchor.get("href") or "") if anchor is not None else None
if outer is not None and (entry is None or entry["url"] == outer["url"]):
return "" # картинка того же вложения внутри ссылки на него: ссылку выводит
if entry is not None:
return self._attachment(entry)
if _unsafe(src):
return ""
el["src"] = _href(src)
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 ""
marks, rest = self._split_marks(text)
entry = self._by_ref.get(href)
if entry is not None:
return marks + self._attachment(entry)
sep = " " if marks else ""
code = self._links.get(href)
if code is not None:
return marks + sep + "[%s](page:%s)" % (_label(rest.strip()) or _name(code), code)
if _unsafe(href):
return text # исполняемая или внутренняя ссылка автора: остаётся только текст
el["href"] = _href(href)
# края текста возвращаются как были: markdownify сам выносит их за ссылку, иначе слова склеиваются (ревью I-1);
# адрес вместо пустого текста — только если из ссылки вынесено вложение (M-1)
core = rest.strip()
lead, trail = rest[:len(rest) - len(rest.lstrip())], rest[len(rest.rstrip()):]
label = lead + _tail(core) + trail if core else (_label(href) if marks else rest)
return marks + sep + super().convert_a(el, label, 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 _strip_editor_ui(soup) -> None:
"""Колонка управления строками таблицы редактора Eva (TinyMCE: номер строки, «+», точка) сохраняется в HTML
страницы блоком `table-row-controls` и давала абзацы «№», «1», «2»… перед таблицей (#95). Блок убирается целиком;
прочие `mceNonEditable` остаются: у карточек вложений тот же класс, в них ссылки на файлы."""
for el in soup.select(".table-row-controls"):
el.decompose()
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_editor_ui(soup)
_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)