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` здесь
|
Агент присылает `attachments` и `links` с `ref` — значением атрибута `src`/`href` ровно как в HTML. По `ref` здесь
|
||||||
находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает.
|
находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает.
|
||||||
|
|
||||||
HTML Eva недоверенный: исполняемые схемы в ссылках не выводятся, текст автора экранируется так, чтобы он не мог
|
HTML Eva недоверенный. Ссылка на вложение `](attachment:@N)` — единственная, которую заменяет `fill_ids`, и её
|
||||||
изобразить ссылку на вложение (`](attachment:@N)`) или сырой тег.
|
выдаёт только конвертер: авторский текст, атрибуты, код и имена не могут породить такую подстроку. Для этого
|
||||||
|
конвертер ставит внутренний маркер (приватные символы U+E000/U+E001), а в готовом Markdown нейтрализует любое
|
||||||
|
`attachment:@` из авторского содержимого и только после этого подставляет настоящие ссылки.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup, NavigableString
|
||||||
from markdownify import MarkdownConverter
|
from markdownify import MarkdownConverter
|
||||||
|
|
||||||
_STATIC = ("static/", "/static/")
|
_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]")
|
_CONTROL = re.compile(r"[\x00-\x20\x7f]")
|
||||||
_NEWLINES = re.compile(r"\s*[\r\n]+\s*")
|
_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+)\)")
|
_ATTACH_LINK = re.compile(r"\]\(attachment:@(\d+)\)")
|
||||||
|
_MARK_OPEN, _MARK_CLOSE = "", ""
|
||||||
|
_MARK = re.compile("(\\d+)")
|
||||||
def _label(text: str) -> str:
|
|
||||||
"""Текст внутри [...]: без переводов строк, со скобками и «@» под экранированием."""
|
|
||||||
return _ESCAPE_LABEL.sub(r"\\\1", _NEWLINES.sub(" ", text))
|
|
||||||
|
|
||||||
|
|
||||||
def _unsafe(href: str) -> bool:
|
def _unsafe(href: str) -> bool:
|
||||||
"""Исполняемая схема: управляющие символы и пробелы внутри схемы не мешают распознаванию."""
|
"""Исполняемая или внутренняя схема: управляющие символы и пробелы внутри схемы не мешают распознаванию."""
|
||||||
return bool(_UNSAFE_SCHEME.match(_CONTROL.sub("", href).lower()))
|
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):
|
class _Converter(MarkdownConverter):
|
||||||
def __init__(self, attachments, links, **options):
|
def __init__(self, attachments, links, **options):
|
||||||
super().__init__(**options)
|
super().__init__(**options)
|
||||||
@ -37,9 +52,8 @@ class _Converter(MarkdownConverter):
|
|||||||
self._index = {} # url -> индекс в files
|
self._index = {} # url -> индекс в files
|
||||||
|
|
||||||
def escape(self, text, parent_tags):
|
def escape(self, text, parent_tags):
|
||||||
# «@» и «<» в тексте автора: иначе `attachment:@N` и сырой тег получаются из обычного текста
|
# «<» в тексте автора не должен выглядеть как тег; «@» не экранируется
|
||||||
text = super().escape(text, parent_tags)
|
return super().escape(text, parent_tags).replace("<", "<")
|
||||||
return text.replace("@", r"\@").replace("<", "<")
|
|
||||||
|
|
||||||
def _attachment(self, entry):
|
def _attachment(self, entry):
|
||||||
url = entry["url"]
|
url = entry["url"]
|
||||||
@ -47,11 +61,11 @@ class _Converter(MarkdownConverter):
|
|||||||
return "" # миниатюра и файл одного вложения: вторая ссылка удаляется
|
return "" # миниатюра и файл одного вложения: вторая ссылка удаляется
|
||||||
self._index[url] = len(self.files)
|
self._index[url] = len(self.files)
|
||||||
self.files.append({"url": url, "name": entry["name"]})
|
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):
|
def convert_img(self, el, text, parent_tags):
|
||||||
src = el.get("src") or ""
|
src = el.get("src") or ""
|
||||||
if src.startswith(_STATIC) or _unsafe(src):
|
if src.startswith(_STATIC):
|
||||||
return ""
|
return ""
|
||||||
parent = el.parent
|
parent = el.parent
|
||||||
if parent is not None and parent.name == "a" and (parent.get("href") or "") in self._by_ref:
|
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)
|
entry = self._by_ref.get(src)
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
return self._attachment(entry)
|
return self._attachment(entry)
|
||||||
|
if _unsafe(src):
|
||||||
|
return ""
|
||||||
return super().convert_img(el, text, parent_tags)
|
return super().convert_img(el, text, parent_tags)
|
||||||
|
|
||||||
def convert_a(self, el, text, parent_tags):
|
def convert_a(self, el, text, parent_tags):
|
||||||
@ -70,16 +86,31 @@ class _Converter(MarkdownConverter):
|
|||||||
return self._attachment(entry)
|
return self._attachment(entry)
|
||||||
code = self._links.get(href)
|
code = self._links.get(href)
|
||||||
if code is not None:
|
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):
|
if _unsafe(href):
|
||||||
return text # исполняемая ссылка: остаётся только текст
|
return text # исполняемая или внутренняя ссылка автора: остаётся только текст
|
||||||
return super().convert_a(el, text, parent_tags)
|
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]]:
|
def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None) -> tuple[str, list[dict]]:
|
||||||
"""HTML страницы → (Markdown, вложения страницы [{url, name}])."""
|
"""HTML страницы → (Markdown, вложения страницы [{url, name}])."""
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
_strip_private(soup)
|
||||||
conv = _Converter(attachments or [], links or [], heading_style="ATX", autolinks=False)
|
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
|
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>", [], [])
|
md, _ = convert.to_markdown("<p><tag> и <script>alert(1)</script></p>", [], [])
|
||||||
assert re.search(r"<[a-zA-Z/]", md) is None
|
assert re.search(r"<[a-zA-Z/]", md) is None
|
||||||
assert "<tag>" in md or "<tag" in md
|
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