Разбор ссылок страниц 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`.**
Обоснование: токен нельзя отправлять на чужой хост (решение 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.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 # запись открытой <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:
"""Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке).
Запрос — файл 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))
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())