diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 9dc2283..c17844f 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -153,3 +153,12 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе. 24. **`download` принимает только адрес под `base` (иначе `ValueError("чужой хост")`) и повторяет те же коды, что `call`.** Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет. +25. **`classify_links`: уточнения разбора (задача 3).** + - Миниатюра определяется по хвосту пути `.meta/thumbnail.jpg` (query отбрасывается), адрес вложения — путь без хвоста, без query и fragment, процентная кодировка пути сохраняется. + - Имя: `alt`/`title` → текст `` (пробелы схлопнуты) → `unquote` последнего сегмента пути. + - Порядок записей — порядок открывающих тегов (для `` сначала ссылка, потом картинка); повторы не склеиваются. + - Код страницы — первое `DOC-<цифры>` в пути (регистр не важен, результат в верхнем регистре); `/files/…` приоритетнее кода. + - `ref` — значение после unescape; сверено с хабом: `convert.py` ищет по `el.get("src"/"href")` BeautifulSoup, то есть тоже по unescaped-значению (решение 12 подтверждено, расхождений нет). + - В `tests/test_py39.py` в белый список stdlib добавлен `html` (`html.parser`). + + Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации. diff --git a/src/agent.py b/src/agent.py index 7e8df37..e77c3dd 100644 --- a/src/agent.py +++ b/src/agent.py @@ -30,6 +30,7 @@ import urllib.error import urllib.parse import urllib.request import uuid +from html.parser import HTMLParser from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple @@ -325,6 +326,71 @@ class EvaClient(Http): return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit)) +class _LinkParser(HTMLParser): + """Собирает img[src] и a[href] в порядке появления: (тег, значение атрибута после unescape, alt/title, текст ссылки).""" + + def __init__(self): + HTMLParser.__init__(self, convert_charrefs=True) + self.items = [] # [tag, ref, hint, text] + self._open = None # запись открытой , в неё копится текст + + def handle_starttag(self, tag, attrs): + a = dict(attrs) + if tag == "img": + ref = a.get("src") + if ref: + self.items.append(["img", ref, a.get("alt") or a.get("title") or "", ""]) + elif tag == "a": + ref = a.get("href") + self._open = None + if ref: + self._open = ["a", ref, a.get("title") or "", ""] + self.items.append(self._open) + + def handle_startendtag(self, tag, attrs): + if tag == "img": + self.handle_starttag(tag, attrs) + + def handle_endtag(self, tag): + if tag == "a": + self._open = None + + def handle_data(self, data): + if self._open is not None: + self._open[3] += data + + +_PAGE_CODE = re.compile(r"DOC-\d+", re.IGNORECASE) +_THUMB_TAIL = ".meta/thumbnail.jpg" + + +def classify_links(html: str, base_url: str) -> Tuple[list, list]: + """Вложения и ссылки на страницы из HTML страницы Eva (design 6–7). Порядок появления, повторы не склеиваются. + + ref — значение атрибута после unescape (design 12). Чужой хост и static/ не передаются.""" + parser = _LinkParser() + parser.feed(html or "") + parser.close() + base = urllib.parse.urlsplit(base_url) + attachments, links = [], [] + for _tag, ref, hint, text in parser.items: + parts = urllib.parse.urlsplit(urllib.parse.urljoin(base_url.rstrip("/") + "/", ref.strip())) + if (parts.scheme, parts.netloc) != (base.scheme, base.netloc): + continue + path = parts.path + if path.startswith("/files/"): + if path.endswith(_THUMB_TAIL): + path = path[:-len(_THUMB_TAIL)] + name = " ".join(hint.split()) or " ".join(text.split()) or urllib.parse.unquote(path.rsplit("/", 1)[-1]) + url = urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", "")) + attachments.append({"ref": ref, "url": url, "name": name}) + continue + m = _PAGE_CODE.search(path) + if m: + links.append({"ref": ref, "code": m.group(0).upper()}) + return attachments, links + + class FolderTransport: """Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке). Запрос — файл req/.json, ответ кладёт реле хаба в resp/.json. Ошибки те же, что у Http.call: diff --git a/tests/fixtures/pages/amp.html b/tests/fixtures/pages/amp.html new file mode 100644 index 0000000..b582d73 --- /dev/null +++ b/tests/fixtures/pages/amp.html @@ -0,0 +1 @@ +к diff --git a/tests/fixtures/pages/image.html b/tests/fixtures/pages/image.html new file mode 100644 index 0000000..0fabd84 --- /dev/null +++ b/tests/fixtures/pages/image.html @@ -0,0 +1 @@ +

Схема:

Схема diff --git a/tests/fixtures/pages/links.html b/tests/fixtures/pages/links.html new file mode 100644 index 0000000..45abe7c --- /dev/null +++ b/tests/fixtures/pages/links.html @@ -0,0 +1,3 @@ +

см. раздел абс. +чужой файл чужая +прочее

diff --git a/tests/fixtures/pages/name_quoted.html b/tests/fixtures/pages/name_quoted.html new file mode 100644 index 0000000..fffc285 --- /dev/null +++ b/tests/fixtures/pages/name_quoted.html @@ -0,0 +1 @@ + diff --git a/tests/fixtures/pages/thumb_and_link.html b/tests/fixtures/pages/thumb_and_link.html new file mode 100644 index 0000000..a4521cb --- /dev/null +++ b/tests/fixtures/pages/thumb_and_link.html @@ -0,0 +1,2 @@ +

model.xlsx model.xlsx

+

diff --git a/tests/test_links.py b/tests/test_links.py new file mode 100644 index 0000000..fd9b7cd --- /dev/null +++ b/tests/test_links.py @@ -0,0 +1,83 @@ +"""Разбор ссылок страницы Eva: вложения и ссылки на страницы (design 6–7, 12).""" +import os +import unittest + +from tests import _common # noqa: F401 +import agent + +BASE = "https://eva.test" +OBJ = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001" +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def page(name): + with open(os.path.join(HERE, "fixtures", "pages", name), encoding="utf-8") as fh: + return fh.read() + + +class ClassifyLinksTest(unittest.TestCase): + def test_image_relative_files(self): + att, links = agent.classify_links(page("image.html"), BASE) + self.assertEqual(att, [{"ref": OBJ + "/img.png?version=1", "url": BASE + OBJ + "/img.png", "name": "Схема"}]) + self.assertEqual(links, []) + + def test_thumbnail_maps_to_file(self): + att, _ = agent.classify_links(page("thumb_and_link.html"), BASE) + self.assertEqual(att[0]["url"], BASE + OBJ + "/model.xlsx") + self.assertEqual(att[0]["name"], "model.xlsx") + self.assertEqual(att[0]["ref"], OBJ + "/model.xlsx.meta/thumbnail.jpg?version=1") + self.assertEqual(att[2]["url"], BASE + OBJ + "/data.dbml") + self.assertEqual(att[2]["name"], "data.dbml") + + def test_file_link_same_url(self): + att, _ = agent.classify_links(page("thumb_and_link.html"), BASE) + self.assertEqual(len(att), 3) # повторы не склеиваются + self.assertEqual(att[1]["url"], att[0]["url"]) + self.assertEqual(att[1]["ref"], OBJ + "/model.xlsx?version=1") + self.assertEqual(att[1]["name"], "model.xlsx") + + def test_static_ignored(self): + att, links = agent.classify_links('', BASE) + self.assertEqual((att, links), ([], [])) + + def test_page_link_relative(self): + _, links = agent.classify_links(page("links.html"), BASE) + self.assertEqual(links[0], {"ref": "project/Document/DOC-019834", "code": "DOC-019834"}) + self.assertEqual(links[1], {"ref": "https://eva.test/project/Document/doc-5", "code": "DOC-5"}) + + def test_foreign_host(self): + att, links = agent.classify_links(page("links.html"), BASE) + self.assertEqual(att, []) + self.assertEqual([l["code"] for l in links], ["DOC-019834", "DOC-5"]) + att, links = agent.classify_links( + 'xy', + BASE) + self.assertEqual((att, links), ([], [])) + + def test_name_unquoted(self): + att, _ = agent.classify_links(page("name_quoted.html"), BASE) + self.assertEqual(att[0]["name"], "ПМП.xlsx") + self.assertEqual(att[0]["url"], BASE + OBJ + "/%D0%9F%D0%9C%D0%9F.xlsx") + + def test_ref_exact(self): + att, links = agent.classify_links(page("amp.html"), BASE) + self.assertEqual(att[0]["ref"], OBJ + "/a.png?version=1&x=2") # после unescape, как ищет хаб + self.assertEqual(links[0]["ref"], "project/Document/DOC-7?a=1&b=2") + self.assertEqual(att[0]["url"], BASE + OBJ + "/a.png") + + def test_base_with_trailing_slash_and_title(self): + att, _ = agent.classify_links('текст' % OBJ, BASE + "/") + self.assertEqual(att[0]["name"], "Файл") + + def test_name_from_link_text(self): + att, _ = agent.classify_links(' мой файл ' % OBJ, BASE) + self.assertEqual(att[0]["name"], "мой файл") + + def test_order_nested_img_in_link(self): + att, _ = agent.classify_links( + '' % (OBJ, OBJ, OBJ), BASE) + self.assertEqual([a["name"] for a in att], ["a.bin", "b.png", "c.png"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_py39.py b/tests/test_py39.py index 03c76c9..0f95115 100644 --- a/tests/test_py39.py +++ b/tests/test_py39.py @@ -18,7 +18,7 @@ class Py39Test(unittest.TestCase): ast.parse(f.read(), feature_version=(3, 9)) def test_no_third_party_imports(self): - allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl", "hashlib", "re", "subprocess"} + allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl", "hashlib", "re", "html", "subprocess"} for name in self.FILES: with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f: tree = ast.parse(f.read())