Разбор ссылок страниц Eva (#1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 04:54:39 +04:00
parent 7bc59f3f32
commit 243b4b8f8d
9 changed files with 167 additions and 1 deletions

View File

@ -153,3 +153,12 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге
Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе. Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе.
24. **`download` принимает только адрес под `base` (иначе `ValueError("чужой хост")`) и повторяет те же коды, что `call`.** 24. **`download` принимает только адрес под `base` (иначе `ValueError("чужой хост")`) и повторяет те же коды, что `call`.**
Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет. Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет.
25. **`classify_links`: уточнения разбора (задача 3).**
- Миниатюра определяется по хвосту пути `.meta/thumbnail.jpg` (query отбрасывается), адрес вложения — путь без хвоста, без query и fragment, процентная кодировка пути сохраняется.
- Имя: `alt`/`title` → текст `<a>` (пробелы схлопнуты) → `unquote` последнего сегмента пути.
- Порядок записей — порядок открывающих тегов (для `<a><img></a>` сначала ссылка, потом картинка); повторы не склеиваются.
- Код страницы — первое `DOC-<цифры>` в пути (регистр не важен, результат в верхнем регистре); `/files/…` приоритетнее кода.
- `ref` — значение после unescape; сверено с хабом: `convert.py` ищет по `el.get("src"/"href")` BeautifulSoup, то есть тоже по unescaped-значению (решение 12 подтверждено, расхождений нет).
- В `tests/test_py39.py` в белый список stdlib добавлен `html` (`html.parser`).
Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации.

View File

@ -30,6 +30,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
from html.parser import HTMLParser
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple 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)) 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 # запись открытой <a>, в неё копится текст
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: class FolderTransport:
"""Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке). """Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке).
Запрос — файл req/<id>.json, ответ кладёт реле хаба в resp/<id>.json. Ошибки те же, что у Http.call: Запрос — файл req/<id>.json, ответ кладёт реле хаба в resp/<id>.json. Ошибки те же, что у Http.call:

1
tests/fixtures/pages/amp.html vendored Normal file
View File

@ -0,0 +1 @@
<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/a.png?version=1&amp;x=2"><a href="project/Document/DOC-7?a=1&amp;b=2">к</a>

1
tests/fixtures/pages/image.html vendored Normal file
View File

@ -0,0 +1 @@
<p>Схема:</p><img alt="Схема" src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/img.png?version=1">

3
tests/fixtures/pages/links.html vendored Normal file
View File

@ -0,0 +1,3 @@
<p><a href="project/Document/DOC-019834">см. раздел</a> <a href="https://eva.test/project/Document/doc-5">абс.</a>
<a href="https://other.test/files/x.png">чужой файл</a> <a href="https://other.test/project/Document/DOC-1">чужая</a>
<a href="https://eva.test/other/page">прочее</a></p>

1
tests/fixtures/pages/name_quoted.html vendored Normal file
View File

@ -0,0 +1 @@
<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/%D0%9F%D0%9C%D0%9F.xlsx?version=1">

View File

@ -0,0 +1,2 @@
<p><img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/model.xlsx.meta/thumbnail.jpg?version=1" alt="model.xlsx"> <img src="static/attach-file.png"> <a href="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/model.xlsx?version=1">model.xlsx</a></p>
<p><img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/data.dbml.meta/thumbnail.jpg?version=1"></p>

83
tests/test_links.py Normal file
View File

@ -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('<img src="static/attach-file.png">', 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(
'<a href="https://other.test/files/x.png">x</a><a href="https://other.test/project/Document/DOC-1">y</a>',
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('<a href="%s/f.bin" title="Файл">текст</a>' % OBJ, BASE + "/")
self.assertEqual(att[0]["name"], "Файл")
def test_name_from_link_text(self):
att, _ = agent.classify_links('<a href="%s/f.bin"> мой <b>файл</b> </a>' % OBJ, BASE)
self.assertEqual(att[0]["name"], "мой файл")
def test_order_nested_img_in_link(self):
att, _ = agent.classify_links(
'<a href="%s/a.bin"><img src="%s/b.png"></a><img src="%s/c.png">' % (OBJ, OBJ, OBJ), BASE)
self.assertEqual([a["name"] for a in att], ["a.bin", "b.png", "c.png"])
if __name__ == "__main__":
unittest.main()

View File

@ -18,7 +18,7 @@ class Py39Test(unittest.TestCase):
ast.parse(f.read(), feature_version=(3, 9)) ast.parse(f.read(), feature_version=(3, 9))
def test_no_third_party_imports(self): 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: for name in self.FILES:
with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f: with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f:
tree = ast.parse(f.read()) tree = ast.parse(f.read())