Разбор ссылок страниц Eva (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7bc59f3f32
commit
243b4b8f8d
@ -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 хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации.
|
||||
|
||||
66
src/agent.py
66
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 # запись открытой <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
1
tests/fixtures/pages/amp.html
vendored
Normal file
@ -0,0 +1 @@
|
||||
<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/a.png?version=1&x=2"><a href="project/Document/DOC-7?a=1&b=2">к</a>
|
||||
1
tests/fixtures/pages/image.html
vendored
Normal file
1
tests/fixtures/pages/image.html
vendored
Normal 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
3
tests/fixtures/pages/links.html
vendored
Normal 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
1
tests/fixtures/pages/name_quoted.html
vendored
Normal 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">
|
||||
2
tests/fixtures/pages/thumb_and_link.html
vendored
Normal file
2
tests/fixtures/pages/thumb_and_link.html
vendored
Normal 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
83
tests/test_links.py
Normal 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()
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user