wiki: перевод HTML Eva в Markdown (#91)
convert.to_markdown: вложения по ref (картинка и миниатюра+файл — одна ссылка), ссылки на страницы DOC-… → page:<код>, static/ удаляются, сырого HTML нет. convert.fill_ids подставляет id вложений вместо attachment:@<n>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
16a3b8c13d
commit
c80423d5cd
64
src/jira_hub/modules/wiki/convert.py
Normal file
64
src/jira_hub/modules/wiki/convert.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""Перевод HTML страницы Eva в Markdown (wiki-module, spec wiki «Markdown-копия вики», design 10).
|
||||
|
||||
Агент присылает `attachments` и `links` с `ref` — значением атрибута `src`/`href` ровно как в HTML. По `ref` здесь
|
||||
находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает.
|
||||
"""
|
||||
import re
|
||||
|
||||
from markdownify import MarkdownConverter
|
||||
|
||||
_STATIC = ("static/", "/static/")
|
||||
_ATTACH_ID = re.compile(r"attachment:@(\d+)")
|
||||
|
||||
|
||||
class _Converter(MarkdownConverter):
|
||||
def __init__(self, attachments, links, **options):
|
||||
super().__init__(**options)
|
||||
self._by_ref = {a["ref"]: a for a in attachments}
|
||||
self._links = {l["ref"]: l["code"] for l in links}
|
||||
self.files = [] # [{url, name}] без повторов по url, в порядке первого появления
|
||||
self._index = {} # url -> индекс в files
|
||||
|
||||
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"]})
|
||||
return "[📎 %s](attachment:@%d)" % (entry["name"], self._index[url])
|
||||
|
||||
def convert_img(self, el, text, parent_tags):
|
||||
src = el.get("src") or ""
|
||||
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:
|
||||
return "" # картинка внутри ссылки на вложение: ссылку выводит <a>
|
||||
entry = self._by_ref.get(src)
|
||||
if entry is not None:
|
||||
return self._attachment(entry)
|
||||
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 ""
|
||||
entry = self._by_ref.get(href)
|
||||
if entry is not None:
|
||||
return self._attachment(entry)
|
||||
code = self._links.get(href)
|
||||
if code is not None:
|
||||
return "[%s](page:%s)" % (text.strip() or code, code)
|
||||
return super().convert_a(el, text, parent_tags)
|
||||
|
||||
|
||||
def to_markdown(html: str, attachments: list[dict], links: list[dict]) -> tuple[str, list[dict]]:
|
||||
"""HTML страницы → (Markdown, вложения страницы [{url, name}])."""
|
||||
conv = _Converter(attachments, links, heading_style="ATX", autolinks=False)
|
||||
md = conv.convert(html).strip() + "\n"
|
||||
return md, conv.files
|
||||
|
||||
|
||||
def fill_ids(markdown: str, ids: list[int]) -> str:
|
||||
"""`attachment:@<n>` → `attachment:<ids[n]>`: строки вложений после записи вложений страницы."""
|
||||
return _ATTACH_ID.sub(lambda m: "attachment:%d" % ids[int(m.group(1))], markdown)
|
||||
7
tests/fixtures/wiki/page_file.html
vendored
Normal file
7
tests/fixtures/wiki/page_file.html
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h2>Модель</h2>
|
||||
<p><a href="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx?version=1"><img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx.meta/thumbnail.jpg?version=1" alt=""></a> <img src="static/attach-file.png" alt=""> <a href="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx?version=1">model.xlsx</a> 12.03.2026</p>
|
||||
</body>
|
||||
</html>
|
||||
8
tests/fixtures/wiki/page_image.html
vendored
Normal file
8
tests/fixtures/wiki/page_image.html
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Схема данных</h1>
|
||||
<p>Общая схема модуля:</p>
|
||||
<p><img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/image2026-7-27_14-20-6.png?version=1" alt=""></p>
|
||||
</body>
|
||||
</html>
|
||||
10
tests/fixtures/wiki/page_links.html
vendored
Normal file
10
tests/fixtures/wiki/page_links.html
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Требования</h1>
|
||||
<p>См. <a href="project/Document/DOC-019834">Модель данных</a> и <a href="https://example.org">внешний сайт</a>.</p>
|
||||
<table><tr><th>Поле</th><th>Тип</th></tr><tr><td>id</td><td>int</td></tr></table>
|
||||
<ul><li>Первый<ul><li>Вложенный</li></ul></li><li>Второй</li></ul>
|
||||
<p><span>Текст в <b>span</b></span></p>
|
||||
</body>
|
||||
</html>
|
||||
87
tests/test_wiki_convert.py
Normal file
87
tests/test_wiki_convert.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Перевод HTML Eva в Markdown (wiki-module, spec wiki «Markdown-копия вики», design 10)."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from jira_hub.modules.wiki import convert
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures" / "wiki"
|
||||
FILE_URL = "https://eva.example.test/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx?version=1"
|
||||
IMG_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/image2026-7-27_14-20-6.png?version=1"
|
||||
THUMB_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx.meta/thumbnail.jpg?version=1"
|
||||
FILE_REF = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx?version=1"
|
||||
|
||||
|
||||
def _html(name):
|
||||
return (FIX / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_image_becomes_attachment():
|
||||
md, atts = convert.to_markdown(
|
||||
_html("page_image.html"),
|
||||
[{"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "image2026-7-27_14-20-6.png"}],
|
||||
[],
|
||||
)
|
||||
assert "[📎 image2026-7-27_14-20-6.png](attachment:@0)" in md.splitlines()
|
||||
assert atts == [{"url": "https://eva.example.test" + IMG_REF, "name": "image2026-7-27_14-20-6.png"}]
|
||||
|
||||
|
||||
def test_thumbnail_and_file_one_link():
|
||||
md, atts = convert.to_markdown(
|
||||
_html("page_file.html"),
|
||||
[
|
||||
{"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"},
|
||||
{"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"},
|
||||
],
|
||||
[],
|
||||
)
|
||||
assert [a["name"] for a in atts] == ["model.xlsx"] and len(atts) == 1
|
||||
assert md.count("](attachment:@0)") == 1
|
||||
assert "[📎 model.xlsx](attachment:@0)" in md
|
||||
assert "attach-file" not in md
|
||||
|
||||
|
||||
def test_page_link():
|
||||
md, _ = convert.to_markdown(
|
||||
_html("page_links.html"),
|
||||
[],
|
||||
[{"ref": "project/Document/DOC-019834", "code": "DOC-019834"}],
|
||||
)
|
||||
assert "[Модель данных](page:DOC-019834)" in md
|
||||
|
||||
|
||||
def test_external_link_kept():
|
||||
md, _ = convert.to_markdown(_html("page_links.html"), [], [])
|
||||
assert "(https://example.org)" in md
|
||||
|
||||
|
||||
def test_no_raw_html():
|
||||
for name in ("page_image.html", "page_file.html", "page_links.html"):
|
||||
md, _ = convert.to_markdown(
|
||||
_html(name),
|
||||
[{"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "image.png"},
|
||||
{"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"},
|
||||
{"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}],
|
||||
[{"ref": "project/Document/DOC-019834", "code": "DOC-019834"}],
|
||||
)
|
||||
assert re.search(r"<[a-zA-Z/]", md) is None, name
|
||||
|
||||
|
||||
def test_table_kept():
|
||||
md, _ = convert.to_markdown(_html("page_links.html"), [], [])
|
||||
table_lines = [line for line in md.splitlines() if "|" in line]
|
||||
assert table_lines and any("---" in line for line in table_lines)
|
||||
|
||||
|
||||
def test_headings_atx():
|
||||
md, _ = convert.to_markdown(_html("page_links.html"), [], [])
|
||||
assert "# Требования" in md.splitlines()
|
||||
|
||||
|
||||
def test_static_links_removed():
|
||||
html = '<p>Текст <img src="/static/x.png" alt=""> и <a href="static/y.html">ссылка</a></p>'
|
||||
md, atts = convert.to_markdown(html, [], [])
|
||||
assert "static" not in md and atts == []
|
||||
|
||||
|
||||
def test_fill_ids():
|
||||
assert convert.fill_ids("[x](attachment:@1)", [7, 9]) == "[x](attachment:9)"
|
||||
Loading…
x
Reference in New Issue
Block a user