wiki: перевод HTML Eva — правки ревью 2 Task 6 (#91)

Двойное экранирование текста ссылки на страницу убрано; «@» больше не
экранируется. Ссылку на вложение выдаёт только конвертер (внутренний маркер),
авторское «attachment:@» в тексте, атрибутах, коде и именах нейтрализуется,
схема attachment: у ссылок и картинок автора не выводится.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 02:10:56 +04:00
parent d8ebdf57b1
commit 97e59c1cc6
2 changed files with 139 additions and 18 deletions

View File

@ -3,31 +3,46 @@
Агент присылает `attachments` и `links` с `ref` — значением атрибута `src`/`href` ровно как в HTML. По `ref` здесь
находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает.
HTML Eva недоверенный: исполняемые схемы в ссылках не выводятся, текст автора экранируется так, чтобы он не мог
изобразить ссылку на вложение (`](attachment:@N)`) или сырой тег.
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):")
_UNSAFE_SCHEME = re.compile(r"^(javascript|data|vbscript|attachment):")
_CONTROL = re.compile(r"[\x00-\x20\x7f]")
_NEWLINES = re.compile(r"\s*[\r\n]+\s*")
_ESCAPE_LABEL = re.compile(r"([\\\[\]@])")
_PRIVATE = re.compile("[]")
_NAME_EDGE = re.compile(r"(?<!\w)[_*]|[_*](?!\w)") # «_» и «*» на границе слова; внутри слова — как есть
_BRACKET = re.compile(r"(?<!\\)([\[\]])")
_ATTACH_LINK = re.compile(r"\]\(attachment:@(\d+)\)")
def _label(text: str) -> str:
"""Текст внутри [...]: без переводов строк, со скобками и «@» под экранированием."""
return _ESCAPE_LABEL.sub(r"\\\1", _NEWLINES.sub(" ", text))
_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 _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)
@ -37,9 +52,8 @@ class _Converter(MarkdownConverter):
self._index = {} # url -> индекс в files
def escape(self, text, parent_tags):
# «@» и «<» в тексте автора: иначе `attachment:@N` и сырой тег получаются из обычного текста
text = super().escape(text, parent_tags)
return text.replace("@", r"\@").replace("<", "&lt;")
# «<» в тексте автора не должен выглядеть как тег; «@» не экранируется
return super().escape(text, parent_tags).replace("<", "&lt;")
def _attachment(self, entry):
url = entry["url"]
@ -47,11 +61,11 @@ class _Converter(MarkdownConverter):
return "" # миниатюра и файл одного вложения: вторая ссылка удаляется
self._index[url] = len(self.files)
self.files.append({"url": url, "name": entry["name"]})
return "[📎 %s](attachment:@%d)" % (_label(entry["name"]), self._index[url])
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) or _unsafe(src):
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:
@ -59,6 +73,8 @@ class _Converter(MarkdownConverter):
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):
@ -70,16 +86,31 @@ class _Converter(MarkdownConverter):
return self._attachment(entry)
code = self._links.get(href)
if code is not None:
return "[%s](page:%s)" % (_label(text.strip()) or code, code)
return "[%s](page:%s)" % (_label(text.strip()) or _name(code), code)
if _unsafe(href):
return text # исполняемая ссылка: остаётся только текст
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(html).strip() + "\n"
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

View File

@ -161,3 +161,93 @@ def test_literal_tag_text_has_no_raw_html():
md, _ = convert.to_markdown("<p>&lt;tag&gt; и &lt;script&gt;alert(1)&lt;/script&gt;</p>", [], [])
assert re.search(r"<[a-zA-Z/]", md) is None
assert "&lt;tag&gt;" in md or "&lt;tag" in md
# --- fix round 2 (re-review 1: B1, B2, B3) ---
def _no_forged_marker(md, ids=(7,)):
"""Авторский текст не порождает ссылку вложения: fill_ids ничего не меняет и шаблона нет."""
assert "](attachment:@" not in md
assert convert.fill_ids(md, list(ids)) == md
def test_page_link_text_escaped_once():
md, _ = convert.to_markdown(
'<p><a href="project/Document/DOC-1">a_b *x* c</a></p>', [],
[{"ref": "project/Document/DOC-1", "code": "DOC-1"}],
)
assert "[a\\_b \\*x\\* c](page:DOC-1)" in md.splitlines()
assert "\\\\" not in md
def test_page_link_at_sign_not_double_escaped():
md, _ = convert.to_markdown(
'<p><a href="project/Document/DOC-1">user@example.com</a></p>', [],
[{"ref": "project/Document/DOC-1", "code": "DOC-1"}],
)
assert "[user@example.com](page:DOC-1)" in md.splitlines()
def test_attachment_name_star_and_underscore_at_word_edge_escaped():
md, _ = convert.to_markdown(
'<p><a href="/files/a">x</a></p>',
[{"ref": "/files/a", "url": "https://eva.example.test/files/a", "name": "_x_*.png"}],
[],
)
assert "[📎 \\_x\\_\\*.png](attachment:@0)" in md.splitlines()
def test_attachment_name_intraword_underscore_kept():
md, _ = convert.to_markdown(
'<p><a href="/files/a">x</a></p>',
[{"ref": "/files/a", "url": "https://eva.example.test/files/a", "name": "my_file.png"}],
[],
)
assert "[📎 my_file.png](attachment:@0)" in md.splitlines()
def test_author_href_attachment_scheme_is_text():
md, atts = convert.to_markdown('<p><a href="attachment:@5">x</a></p>', [], [])
assert md.strip() == "x" and atts == []
_no_forged_marker(md)
def test_author_img_src_attachment_scheme_dropped():
md, atts = convert.to_markdown('<p>a<img src="attachment:@5" alt="z">b</p>', [], [])
assert md.strip() == "ab" and atts == []
_no_forged_marker(md)
def test_author_alt_cannot_forge_marker():
md, _ = convert.to_markdown('<p><img src="/x.png" alt="a](attachment:@0)"></p>', [], [])
_no_forged_marker(md)
def test_author_title_cannot_forge_marker():
md, _ = convert.to_markdown('<p><a href="https://e.org" title="](attachment:@0)">t</a></p>', [], [])
_no_forged_marker(md)
def test_author_code_cannot_forge_marker():
md, _ = convert.to_markdown("<p><code>](attachment:@0)</code></p>", [], [])
_no_forged_marker(md)
def test_author_text_cannot_forge_marker():
md, _ = convert.to_markdown("<p>](attachment:@0) и &#93;(attachment:&#64;0)</p>", [], [])
_no_forged_marker(md)
def test_author_private_chars_cannot_forge_marker():
md, _ = convert.to_markdown("<p>&#57344;0&#57345;</p>", [], [])
_no_forged_marker(md)
def test_plain_at_sign_kept():
md, _ = convert.to_markdown("<p>user@example.com и @ivan</p>", [], [])
assert "user@example.com и @ivan" in md
def test_code_keeps_literal_text():
md, _ = convert.to_markdown("<p><code>&lt;script&gt; a@b</code></p>", [], [])
assert "`<script> a@b`" in md