wiki: перевод HTML Eva — правки ревью Task 6 (#91)
Схемы javascript:/data:/vbscript: в ссылках не выводятся; имя вложения и текст ссылки экранируются (скобки, @, переводы строк); fill_ids меняет только ](attachment:@N) и поднимает ValueError вне диапазона; «<» в тексте — <; attachments/links = None трактуются как пустые списки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c80423d5cd
commit
d8ebdf57b1
@ -2,13 +2,30 @@
|
|||||||
|
|
||||||
Агент присылает `attachments` и `links` с `ref` — значением атрибута `src`/`href` ровно как в HTML. По `ref` здесь
|
Агент присылает `attachments` и `links` с `ref` — значением атрибута `src`/`href` ровно как в HTML. По `ref` здесь
|
||||||
находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает.
|
находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает.
|
||||||
|
|
||||||
|
HTML Eva недоверенный: исполняемые схемы в ссылках не выводятся, текст автора экранируется так, чтобы он не мог
|
||||||
|
изобразить ссылку на вложение (`](attachment:@N)`) или сырой тег.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from markdownify import MarkdownConverter
|
from markdownify import MarkdownConverter
|
||||||
|
|
||||||
_STATIC = ("static/", "/static/")
|
_STATIC = ("static/", "/static/")
|
||||||
_ATTACH_ID = re.compile(r"attachment:@(\d+)")
|
_UNSAFE_SCHEME = re.compile(r"^(javascript|data|vbscript):")
|
||||||
|
_CONTROL = re.compile(r"[\x00-\x20\x7f]")
|
||||||
|
_NEWLINES = re.compile(r"\s*[\r\n]+\s*")
|
||||||
|
_ESCAPE_LABEL = re.compile(r"([\\\[\]@])")
|
||||||
|
_ATTACH_LINK = re.compile(r"\]\(attachment:@(\d+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def _label(text: str) -> str:
|
||||||
|
"""Текст внутри [...]: без переводов строк, со скобками и «@» под экранированием."""
|
||||||
|
return _ESCAPE_LABEL.sub(r"\\\1", _NEWLINES.sub(" ", text))
|
||||||
|
|
||||||
|
|
||||||
|
def _unsafe(href: str) -> bool:
|
||||||
|
"""Исполняемая схема: управляющие символы и пробелы внутри схемы не мешают распознаванию."""
|
||||||
|
return bool(_UNSAFE_SCHEME.match(_CONTROL.sub("", href).lower()))
|
||||||
|
|
||||||
|
|
||||||
class _Converter(MarkdownConverter):
|
class _Converter(MarkdownConverter):
|
||||||
@ -19,17 +36,22 @@ class _Converter(MarkdownConverter):
|
|||||||
self.files = [] # [{url, name}] без повторов по url, в порядке первого появления
|
self.files = [] # [{url, name}] без повторов по url, в порядке первого появления
|
||||||
self._index = {} # url -> индекс в files
|
self._index = {} # url -> индекс в files
|
||||||
|
|
||||||
|
def escape(self, text, parent_tags):
|
||||||
|
# «@» и «<» в тексте автора: иначе `attachment:@N` и сырой тег получаются из обычного текста
|
||||||
|
text = super().escape(text, parent_tags)
|
||||||
|
return text.replace("@", r"\@").replace("<", "<")
|
||||||
|
|
||||||
def _attachment(self, entry):
|
def _attachment(self, entry):
|
||||||
url = entry["url"]
|
url = entry["url"]
|
||||||
if url in self._index:
|
if url in self._index:
|
||||||
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)" % (entry["name"], self._index[url])
|
return "[📎 %s](attachment:@%d)" % (_label(entry["name"]), self._index[url])
|
||||||
|
|
||||||
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):
|
if src.startswith(_STATIC) or _unsafe(src):
|
||||||
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:
|
||||||
@ -48,17 +70,24 @@ 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)" % (text.strip() or code, code)
|
return "[%s](page:%s)" % (_label(text.strip()) or code, code)
|
||||||
|
if _unsafe(href):
|
||||||
|
return text # исполняемая ссылка: остаётся только текст
|
||||||
return super().convert_a(el, text, parent_tags)
|
return super().convert_a(el, text, parent_tags)
|
||||||
|
|
||||||
|
|
||||||
def to_markdown(html: str, attachments: list[dict], links: list[dict]) -> 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}])."""
|
||||||
conv = _Converter(attachments, links, 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(html).strip() + "\n"
|
||||||
return md, conv.files
|
return md, conv.files
|
||||||
|
|
||||||
|
|
||||||
def fill_ids(markdown: str, ids: list[int]) -> str:
|
def fill_ids(markdown: str, ids: list[int]) -> str:
|
||||||
"""`attachment:@<n>` → `attachment:<ids[n]>`: строки вложений после записи вложений страницы."""
|
"""`](attachment:@<n>)` → `](attachment:<ids[n]>)`: только ссылки вложений, которые выдал конвертер."""
|
||||||
return _ATTACH_ID.sub(lambda m: "attachment:%d" % ids[int(m.group(1))], markdown)
|
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)
|
||||||
|
|||||||
@ -85,3 +85,79 @@ def test_static_links_removed():
|
|||||||
|
|
||||||
def test_fill_ids():
|
def test_fill_ids():
|
||||||
assert convert.fill_ids("[x](attachment:@1)", [7, 9]) == "[x](attachment:9)"
|
assert convert.fill_ids("[x](attachment:@1)", [7, 9]) == "[x](attachment:9)"
|
||||||
|
|
||||||
|
|
||||||
|
# --- fix round 1 (review Task 6: I1, I2, I3, M3, M4) ---
|
||||||
|
|
||||||
|
def test_unsafe_schemes_dropped_from_links():
|
||||||
|
html = ('<p><a href="javascript:alert(1)">x1</a> <a href="JaVaScript:alert(2)">x2</a> '
|
||||||
|
'<a href="data:text/html,<b>x</b>">x3</a> <a href=" java\tscript:alert(3)">x4</a> '
|
||||||
|
'<a href="vbscript:msgbox">x5</a></p>')
|
||||||
|
md, _ = convert.to_markdown(html, [], [])
|
||||||
|
for text in ("x1", "x2", "x3", "x4", "x5"):
|
||||||
|
assert text in md
|
||||||
|
assert "(javascript" not in md.lower() and "(data:" not in md and "(vbscript" not in md.lower()
|
||||||
|
assert "java" not in md.lower().replace("x4", "") # схема не осталась ни в каком виде
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsafe_scheme_img_dropped():
|
||||||
|
md, atts = convert.to_markdown('<p>a<img src="javascript:x" alt="z">b</p>', [], [])
|
||||||
|
assert md.strip() == "ab" and atts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_name_and_link_text_escaped():
|
||||||
|
md, atts = convert.to_markdown(
|
||||||
|
'<p><a href="/files/a">x</a></p>',
|
||||||
|
[{"ref": "/files/a", "url": "https://eva.example.test/files/a", "name": "a]b[c).png"}],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
assert "[📎 a\\]b\\[c).png](attachment:@0)" in md.splitlines()
|
||||||
|
assert atts[0]["name"] == "a]b[c).png"
|
||||||
|
|
||||||
|
md, _ = convert.to_markdown(
|
||||||
|
'<p><a href="project/Document/DOC-1">a ] b [x](http://evil)</a></p>',
|
||||||
|
[],
|
||||||
|
[{"ref": "project/Document/DOC-1", "code": "DOC-1"}],
|
||||||
|
)
|
||||||
|
assert "[a \\] b \\[x\\](http://evil)](page:DOC-1)" in md.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def test_newlines_in_link_text_collapsed():
|
||||||
|
md, _ = convert.to_markdown(
|
||||||
|
'<p><a href="project/Document/DOC-1">первая\nвторая</a></p>', [],
|
||||||
|
[{"ref": "project/Document/DOC-1", "code": "DOC-1"}],
|
||||||
|
)
|
||||||
|
assert "[первая вторая](page:DOC-1)" in md.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def test_literal_attachment_marker_in_text_is_safe():
|
||||||
|
md, atts = convert.to_markdown("<p>[x](attachment:@5) и attachment:@0</p>", [], [])
|
||||||
|
assert atts == []
|
||||||
|
assert convert.fill_ids(md, []) == md
|
||||||
|
assert "(attachment:@5)" not in md.replace("\\(attachment:\\@5)", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_ids_by_index_not_prefix():
|
||||||
|
ids = list(range(100, 111))
|
||||||
|
assert convert.fill_ids("[a](attachment:@1) [b](attachment:@10)", ids) == \
|
||||||
|
"[a](attachment:101) [b](attachment:110)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_ids_out_of_range_raises():
|
||||||
|
try:
|
||||||
|
convert.fill_ids("[a](attachment:@3)", [1, 2])
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "@3" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("ожидалась ValueError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_lists_are_empty():
|
||||||
|
md, atts = convert.to_markdown("<p>текст</p>", None, None)
|
||||||
|
assert md == "текст\n" and atts == []
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user