169 lines
9.7 KiB
Python
169 lines
9.7 KiB
Python
"""Перевод 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("<", "<")
|
||
|
||
|
||
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("<", "<")
|
||
|
||
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)
|