169 lines
9.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+)\)")
_TAIL = re.compile(r"\\+$")
_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 _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 = [] # выданные ссылки вложений: по ним из текста <a> выносятся вложенные вложения
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"]})
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") # на любой глубине: <a><span><img></span></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 "" # картинка того же вложения внутри ссылки на него: ссылку выводит <a>
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:@<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)