Извлечь типизированные данные из HTML#
Поля документной модели несут CSS- или XPath-селекторы. SDK сначала строит дерево HTML, затем извлекает простые значения и передаёт их настроенному адаптеру моделей.
Модель остаётся обычным dataclass.
→ Request · GET /catalogue/page-1.html
← Response · 200 OK
<h1>Mailbox library</h1>
<article class="book">
<a href="sdk-patterns.html">SDK Patterns</a><span>12.50</span>
</article>
# examples/docs/response_html.py
@dataclass(frozen=True, slots=True)
class Book:
title: Annotated[str, CSS("a::text")]
price_text: Annotated[str, CSS("span::text")]
@property
def price(self) -> Decimal:
return Decimal(self.price_text)
@dataclass(frozen=True, slots=True)
class Catalogue:
title: Annotated[str, CSS("h1::text")]
books: Annotated[list[Book], Scope(CSS("article.book"))]
next_href: Annotated[str | None, CSS("a.next::attr(href)")] = None
CSS("a::text") читает текст одного узла. Scope(CSS("article.book")) создаёт отдельный
контекст для каждой карточки и строит список Book. Необязательное поле получает None, если
селектор ничего не нашёл; обязательное поле приводит к ExtractionError с путём модели.
# examples/docs/response_html.py
@dataclass(frozen=True, slots=True, kw_only=True)
class GetCatalogue(HttpOperation[Catalogue]):
__http__ = Http.get("/catalogue/page-{page}.html")
page: Path[int]
class CatalogueApi(SyncApi):
page = op(GetCatalogue)
Селекторы в Catalogue сообщают компилятору, что ответ нужно читать как документ. Модель без
селекторов на том же месте читалась бы как JSON. Явное представление Html(Model) нужно для
особого статуса, типа содержимого или условия.
Файл примера целиком
"""Extract a typed model from an HTML response."""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Annotated
from eazy_sdk_html import CSS, Scope
from eazy_sdk import Http, HttpOperation, Path, SyncApi, op
from examples.docs.request_site import request_client
# docs:models:start
# examples/docs/response_html.py
@dataclass(frozen=True, slots=True)
class Book:
title: Annotated[str, CSS("a::text")]
price_text: Annotated[str, CSS("span::text")]
@property
def price(self) -> Decimal:
return Decimal(self.price_text)
@dataclass(frozen=True, slots=True)
class Catalogue:
title: Annotated[str, CSS("h1::text")]
books: Annotated[list[Book], Scope(CSS("article.book"))]
next_href: Annotated[str | None, CSS("a.next::attr(href)")] = None
# docs:models:end
# docs:operation:start
# examples/docs/response_html.py
@dataclass(frozen=True, slots=True, kw_only=True)
class GetCatalogue(HttpOperation[Catalogue]):
__http__ = Http.get("/catalogue/page-{page}.html")
page: Path[int]
class CatalogueApi(SyncApi):
page = op(GetCatalogue)
# docs:operation:end
def main() -> None:
with request_client() as client:
catalogue = CatalogueApi(client).page(page=1)
print(f"{catalogue.title}: {len(catalogue.books)} books")
print(f"first: {catalogue.books[0].title} {catalogue.books[0].price}")
print(f"next: {catalogue.next_href}")
if __name__ == "__main__":
main()
Запуск: uv run python examples/docs/response_html.py.
Mailbox library: 2 books
first: SDK Patterns 12.50
next: page-2.html
По умолчанию HTML читает Parsel с поддержкой CSS и XPath. Другой документный бэкенд задаётся в
Serialization(documents=(...)) на корне. Компилятор заранее проверяет, что бэкенд понимает язык
каждого селектора.
Для отдельного HTML-фрагмента без HTTP используйте parse_html(data, Model). Он применяет ту же
модель, селекторы и адаптеры.
Достать значение из встроенного скрипта#
Токен страницы часто лежит внутри <script>, и разметкой к нему не подойти. Селектор Regex
ищет по тексту документа и стоит на поле рядом с CSS и XPath.
# examples/docs/response_regex.py
class MailPage(BaseModel):
title: Annotated[str, CSS("title::text"), Const("Mail")]
token: Annotated[str, Regex(r'"account":\{"token":"([^"]+)"')]
email: Annotated[str, Regex(r'"account":\{[^}]*"email":"([^"]+)"')]
every_token: Annotated[list[str], Regex(r'"token":"([^"]+)"')]
@dataclass(frozen=True, slots=True, kw_only=True)
class OpenInbox(HttpOperation[MailPage]):
__http__ = Http.get("/inbox/")
sid: Cookie[str]
class MailPages(SyncApi):
inbox = op(OpenInbox)
Шаблон с одной группой отдаёт в поле эту группу, шаблон без групп отдаёт всё совпадение. Две группы и больше считаются ошибкой объявления. Поле с одним значением получает первое совпадение, поле-список получает все.
На странице примера ключ token встречается дважды, и первым идёт чужой. Поле token привязано
к ключу account, поэтому получает нужное значение, а every_token показывает оба.
Поиск идёт по тексту в том виде, в каком его прислал сервис. Сущности вида & не
раскрываются, содержимое скрипта не разбирается. Второй аргумент принимает флаги модуля re.
title с Const("Mail") отличает страницу почты от страницы входа, которую сайт отдаёт с тем же
статусом 200. Поле из куки или заголовка того же ответа добавляется в эту же модель маркерами
FromCookie и FromHeader.
Файл примера целиком
"""Read a value out of an inline script, where no markup addresses it."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from eazy_sdk_html import CSS, Regex
from pydantic import BaseModel
from eazy_sdk import Cookie, Http, HttpOperation, SyncApi, op
from eazy_sdk.response import Const
from examples.docs.redirect_site import SESSION_ID, redirect_client
# docs:example:start
# examples/docs/response_regex.py
class MailPage(BaseModel):
title: Annotated[str, CSS("title::text"), Const("Mail")]
token: Annotated[str, Regex(r'"account":\{"token":"([^"]+)"')]
email: Annotated[str, Regex(r'"account":\{[^}]*"email":"([^"]+)"')]
every_token: Annotated[list[str], Regex(r'"token":"([^"]+)"')]
@dataclass(frozen=True, slots=True, kw_only=True)
class OpenInbox(HttpOperation[MailPage]):
__http__ = Http.get("/inbox/")
sid: Cookie[str]
class MailPages(SyncApi):
inbox = op(OpenInbox)
# docs:example:end
def main() -> None:
with redirect_client() as client:
page = MailPages(client).inbox(sid=SESSION_ID)
print(f"title: {page.title}")
print(f"token: {page.token}")
print(f"email: {page.email}")
print(f"every token: {', '.join(page.every_token)}")
if __name__ == "__main__":
main()
Запуск: uv run python examples/docs/response_regex.py.
title: Mail
token: page-token-42
email: ada@mail.example
every token: ads-token, page-token-42
Значение, которое лежит то в странице, то в куке#
Сайт не всегда держит значение в одном месте: токен формы у одного клиента стоит в странице, у
другого в куке. Поле называет оба источника через |, в том порядке, в каком их пробовать.
# examples/docs/response_first_of.py
class LoginPage(BaseModel):
csrf: Annotated[str, Regex(r'"csrf":"([^"]+)"') | FromCookie("act")]
@dataclass(frozen=True, slots=True, kw_only=True)
class OpenLogin(HttpOperation[LoginPage]):
__http__ = Http.get("/{page}")
page: Path[str] = "in-page"
Поле получает первое найденное значение. В цепочку входят селекторы CSS, XPath, Regex и
источники вне тела: FromHeader, FromCookie, Location.query(...). Обязательное поле, которого
нет ни в одном источнике, даёт MalformedResponseError. Такое поле делает модель различимой:
страница без токена под неё не подходит, и условие when= для этого не нужно.
Запуск: uv run python -m examples.docs.response_first_of.
page has it: from-page
only the cookie has it: from-cookie
nowhere: MalformedResponseError