wiki: перевод HTML Eva — правки ревью 2 Task 6 (#91)
Двойное экранирование текста ссылки на страницу убрано; «@» больше не экранируется. Ссылку на вложение выдаёт только конвертер (внутренний маркер), авторское «attachment:@» в тексте, атрибутах, коде и именах нейтрализуется, схема attachment: у ссылок и картинок автора не выводится. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8ebdf57b1
commit
97e59c1cc6
@ -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("<", "<")
|
||||
|
||||
|
||||
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("<", "<")
|
||||
# «<» в тексте автора не должен выглядеть как тег; «@» не экранируется
|
||||
return super().escape(text, parent_tags).replace("<", "<")
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -161,3 +161,93 @@ def test_literal_tag_text_has_no_raw_html():
|
||||
md, _ = convert.to_markdown("<p><tag> и <script>alert(1)</script></p>", [], [])
|
||||
assert re.search(r"<[a-zA-Z/]", md) is None
|
||||
assert "<tag>" in md or "<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) и ](attachment:@0)</p>", [], [])
|
||||
_no_forged_marker(md)
|
||||
|
||||
|
||||
def test_author_private_chars_cannot_forge_marker():
|
||||
md, _ = convert.to_markdown("<p>0</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><script> a@b</code></p>", [], [])
|
||||
assert "`<script> a@b`" in md
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user