"""Перевод 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)