funora 0.0.1.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_catalog.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Разбор каталога с корня площадки.
|
|
2
|
+
|
|
3
|
+
Страница крупная и ловушек в ней больше, чем на любой другой из наблюдённых.
|
|
4
|
+
Разобрана пятьюдесятью одним независимым чтением; из сорока семи утверждений об
|
|
5
|
+
адресуемости устояло тринадцать. Ниже - те решения, которые из этого вышли.
|
|
6
|
+
|
|
7
|
+
ЧИТАЕТСЯ ТОЛЬКО ОСНОВНОЙ КАТАЛОГ. Списков на странице два, и все восемь карточек
|
|
8
|
+
избранного повторяют карточки основного - пересечение восемь из восьми. Обход по
|
|
9
|
+
документу удвоил бы восемь игр, молча, при полноте и нуле повреждений.
|
|
10
|
+
|
|
11
|
+
ВАРИАНТЫ ИГРЫ НЕ ОТБРАСЫВАЮТСЯ. Заголовков 872 при 842 карточках: четырнадцать
|
|
12
|
+
карточек несут по нескольку, и лишние помечены классом hidden. Это настоящие
|
|
13
|
+
варианты - у каждого свой идентификатор и свой список разделов. Класс hidden
|
|
14
|
+
говорит, какой вариант показан переключателем сейчас, а не что узел служебный.
|
|
15
|
+
|
|
16
|
+
СПИСОК РАЗДЕЛОВ СОПОСТАВЛЯЕТСЯ С ЗАГОЛОВКОМ ПО data-id, а не по соседству.
|
|
17
|
+
Порядок детей карточки - сначала все заголовки, потом переключатель, потом все
|
|
18
|
+
списки; соседство держится лишь в 836 случаях из 872.
|
|
19
|
+
|
|
20
|
+
ГЛАВНЫЙ РАЗДЕЛ ПОМЕЧАЕТСЯ, А НЕ ВЫБРАСЫВАЕТСЯ. Адрес заголовка игры совпадает с
|
|
21
|
+
адресом первой ссылки её списка - 864 раза из 864. Выброшенный, он унёс бы с
|
|
22
|
+
собой сведение о том, какой раздел у игры главный.
|
|
23
|
+
|
|
24
|
+
ВИД РАЗДЕЛА ЧИТАЕТСЯ ИЗ АДРЕСА, и это наблюдение, а не удобство: у самого узла
|
|
25
|
+
ссылки нет ни класса, ни data-атрибута, и структурного признака вида не
|
|
26
|
+
существует.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
from typing import Final
|
|
36
|
+
|
|
37
|
+
from selectolax.parser import HTMLParser, Node
|
|
38
|
+
|
|
39
|
+
from ._extract import attribute as _attribute
|
|
40
|
+
from ._extract import text as _text
|
|
41
|
+
from ._observed import Observed
|
|
42
|
+
from ._result import Completeness, Defect, Severity
|
|
43
|
+
from .errors import IncompleteResultError, ProtocolChangedError, ValidationError
|
|
44
|
+
from .extraction import SELECTORS
|
|
45
|
+
|
|
46
|
+
__all__ = ["CatalogGame", "CatalogPage", "CatalogSection", "parse_catalog"]
|
|
47
|
+
|
|
48
|
+
_CATALOG: Final[str] = SELECTORS["catalog.lists.catalog"]
|
|
49
|
+
_FAVOURITES: Final[str] = SELECTORS["catalog.lists.favourites"]
|
|
50
|
+
_LETTER_GROUP: Final[str] = SELECTORS["catalog.lists.letter_groups"]
|
|
51
|
+
|
|
52
|
+
_CARD: Final[str] = SELECTORS["catalog.game.card"]
|
|
53
|
+
_CARD_IN_LIST: Final[str] = SELECTORS["catalog.game.card.within_list"]
|
|
54
|
+
_TITLE_IN_CARD: Final[str] = SELECTORS["catalog.game.title.within_card"]
|
|
55
|
+
_TITLE: Final[str] = SELECTORS["catalog.game.title"]
|
|
56
|
+
_SWITCHER: Final[str] = SELECTORS["catalog.game.switcher"]
|
|
57
|
+
|
|
58
|
+
_SECTION_LIST: Final[str] = SELECTORS["catalog.sections.list"]
|
|
59
|
+
_LIST_IN_CARD: Final[str] = SELECTORS["catalog.sections.list.within_card"]
|
|
60
|
+
_SECTION_LINK: Final[str] = SELECTORS["catalog.sections.link"]
|
|
61
|
+
|
|
62
|
+
_ALPHABET: Final[str] = SELECTORS["catalog.totality.alphabet_index"]
|
|
63
|
+
|
|
64
|
+
#: Вид раздела по сегменту пути.
|
|
65
|
+
#:
|
|
66
|
+
#: Наблюдены два: /lots/ и /chips/. Структурного признака вида не существует - у
|
|
67
|
+
#: узла ссылки нет ни класса, ни data-атрибута, - и различать их можно только по
|
|
68
|
+
#: адресу.
|
|
69
|
+
_SECTION_KIND: Final[re.Pattern[str]] = re.compile(r"https?://[^/]+/([a-z]+)/([^/?#]+)/?")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class CatalogSection:
|
|
74
|
+
"""Раздел игры в каталоге.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
href (Observed[str]): Адрес раздела целиком.
|
|
78
|
+
kind (Observed[str]): Вид раздела из сегмента пути. Наблюдены lots и
|
|
79
|
+
chips; смысла второму контракт не приписывает.
|
|
80
|
+
section_id (Observed[str]): Идентификатор раздела из адреса.
|
|
81
|
+
title_text (Observed[str]): Название раздела.
|
|
82
|
+
is_main (bool): Совпадает ли адрес раздела с адресом самой игры.
|
|
83
|
+
position (int): Место в списке, считая с нуля.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
href: Observed[str]
|
|
87
|
+
kind: Observed[str]
|
|
88
|
+
section_id: Observed[str]
|
|
89
|
+
title_text: Observed[str]
|
|
90
|
+
is_main: bool
|
|
91
|
+
position: int
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class CatalogGame:
|
|
96
|
+
"""Игра либо один её вариант.
|
|
97
|
+
|
|
98
|
+
Вариантов у карточки бывает несколько, и каждый - самостоятельная запись со
|
|
99
|
+
своим идентификатором и своим списком разделов. Скрытые варианты
|
|
100
|
+
отбрасывать нельзя: класс hidden говорит, какой из них показан
|
|
101
|
+
переключателем сейчас.
|
|
102
|
+
|
|
103
|
+
Attributes:
|
|
104
|
+
game_id (Observed[str]): Идентификатор игры из атрибута.
|
|
105
|
+
title_text (Observed[str]): Название игры.
|
|
106
|
+
href (Observed[str]): Адрес главного раздела игры.
|
|
107
|
+
is_shown (bool): Показан ли этот вариант переключателем сейчас.
|
|
108
|
+
variant_index (int): Место варианта в карточке, считая с нуля.
|
|
109
|
+
sections (tuple[CatalogSection, ...]): Разделы этого варианта.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
game_id: Observed[str]
|
|
113
|
+
title_text: Observed[str]
|
|
114
|
+
href: Observed[str]
|
|
115
|
+
is_shown: bool
|
|
116
|
+
variant_index: int
|
|
117
|
+
sections: tuple[CatalogSection, ...]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class CatalogPage:
|
|
122
|
+
"""Результат чтения каталога.
|
|
123
|
+
|
|
124
|
+
Attributes:
|
|
125
|
+
completeness (Completeness): Полнота прочитанного.
|
|
126
|
+
reason (str): Машиночитаемая причина, по которой полнота такова.
|
|
127
|
+
observed_at (datetime): Момент наблюдения.
|
|
128
|
+
cards_total (int): Сколько карточек нашлось в основном каталоге, штук.
|
|
129
|
+
games_total (int): Сколько игр и вариантов собрано, штук.
|
|
130
|
+
sections_total (int): Сколько разделов собрано, штук.
|
|
131
|
+
letter_groups (int): Сколько буквенных групп на странице, штук.
|
|
132
|
+
defects (tuple[Defect, ...]): Замеченные повреждения.
|
|
133
|
+
query (str | None): Нормализованный запрос поиска; None у полного каталога.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
completeness: Completeness
|
|
137
|
+
reason: str
|
|
138
|
+
observed_at: datetime
|
|
139
|
+
cards_total: int
|
|
140
|
+
games_total: int
|
|
141
|
+
sections_total: int
|
|
142
|
+
letter_groups: int
|
|
143
|
+
defects: tuple[Defect, ...] = ()
|
|
144
|
+
_games: tuple[CatalogGame, ...] = field(repr=False, default=())
|
|
145
|
+
query: str | None = None
|
|
146
|
+
|
|
147
|
+
def games(self, *, accept_incomplete: bool = False) -> tuple[CatalogGame, ...]:
|
|
148
|
+
"""Возвращает игры каталога вместе с их вариантами.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
accept_incomplete (bool): Признание того, что результат может быть
|
|
152
|
+
неполным. Без него неполный результат не выдаётся.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
tuple[CatalogGame, ...]: Игры в порядке появления.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
|
|
159
|
+
не признана.
|
|
160
|
+
"""
|
|
161
|
+
if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
|
|
162
|
+
raise IncompleteResultError(
|
|
163
|
+
f"результат неполон ({self.completeness}, причина: {self.reason}), "
|
|
164
|
+
f"карточек {self.cards_total}, игр и вариантов {self.games_total}, "
|
|
165
|
+
f"разделов {self.sections_total}, повреждений {len(self.defects)}. "
|
|
166
|
+
"Передайте accept_incomplete=True, если готовы работать с неполными данными"
|
|
167
|
+
)
|
|
168
|
+
return self._games
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _section(link: Node, index: int, game_href: str | None) -> CatalogSection:
|
|
172
|
+
"""Собирает один раздел.
|
|
173
|
+
|
|
174
|
+
Вид раздела читается из сегмента пути. Структурного признака вида не
|
|
175
|
+
существует: у узла ссылки нет ни класса, ни data-атрибута.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
link (Node): Узел ссылки.
|
|
179
|
+
index (int): Место в списке, считая с нуля.
|
|
180
|
+
game_href (str | None): Адрес самой игры, чтобы пометить главный раздел.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
CatalogSection: Раздел.
|
|
184
|
+
"""
|
|
185
|
+
href = _attribute(link, "href", "section_href")
|
|
186
|
+
kind: Observed[str] = Observed.missing("href_unreadable")
|
|
187
|
+
section_id: Observed[str] = Observed.missing("href_unreadable")
|
|
188
|
+
|
|
189
|
+
if href.is_observed:
|
|
190
|
+
match = _SECTION_KIND.match(href.value)
|
|
191
|
+
if match is None:
|
|
192
|
+
kind = Observed.missing("section_href_shape_unknown")
|
|
193
|
+
section_id = Observed.missing("section_href_shape_unknown")
|
|
194
|
+
else:
|
|
195
|
+
kind = Observed.present(match.group(1))
|
|
196
|
+
section_id = Observed.present(match.group(2))
|
|
197
|
+
|
|
198
|
+
return CatalogSection(
|
|
199
|
+
href=href,
|
|
200
|
+
kind=kind,
|
|
201
|
+
section_id=section_id,
|
|
202
|
+
title_text=_text(link, "section_title"),
|
|
203
|
+
# Главный раздел ПОМЕЧАЕТСЯ, а не выбрасывается: выброшенный, он унёс бы
|
|
204
|
+
# с собой сведение о том, какой раздел у игры главный.
|
|
205
|
+
is_main=bool(game_href) and href.or_none() == game_href,
|
|
206
|
+
position=index,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _card(card: Node) -> tuple[list[CatalogGame], list[Defect]]:
|
|
211
|
+
"""Собирает одну карточку - игру либо несколько её вариантов.
|
|
212
|
+
|
|
213
|
+
Список разделов сопоставляется с заголовком ПО data-id, а не по соседству:
|
|
214
|
+
порядок детей карточки - сначала все заголовки, потом переключатель, потом
|
|
215
|
+
все списки, и соседство держится лишь у одновариантных карточек.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
card (Node): Узел карточки.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
tuple[list[CatalogGame], list[Defect]]: Варианты и повреждения.
|
|
222
|
+
"""
|
|
223
|
+
titles = card.css(_TITLE_IN_CARD)
|
|
224
|
+
lists_by_id: dict[str, Node | None] = {}
|
|
225
|
+
for node in card.css(_LIST_IN_CARD):
|
|
226
|
+
key = ((node.attributes or {}).get("data-id") or "").strip()
|
|
227
|
+
if key:
|
|
228
|
+
# Два списка с одним ключом не дают выбрать правильный по порядку.
|
|
229
|
+
lists_by_id[key] = None if key in lists_by_id else node
|
|
230
|
+
|
|
231
|
+
games: list[CatalogGame] = []
|
|
232
|
+
defects: list[Defect] = []
|
|
233
|
+
|
|
234
|
+
for index, title in enumerate(titles):
|
|
235
|
+
game_id = _attribute(title, "data-id", "game_id")
|
|
236
|
+
link = title.css_first("a")
|
|
237
|
+
href = _attribute(link, "href", "game_href")
|
|
238
|
+
|
|
239
|
+
section_list = lists_by_id.get(game_id.or_none() or "")
|
|
240
|
+
if section_list is None:
|
|
241
|
+
defects.append(
|
|
242
|
+
Defect(
|
|
243
|
+
severity=Severity.ROW,
|
|
244
|
+
code="section_list_not_paired",
|
|
245
|
+
detail=(
|
|
246
|
+
f"варианту с идентификатором {game_id.or_none()!r} не нашлось "
|
|
247
|
+
"списка разделов с тем же идентификатором. Брать соседний "
|
|
248
|
+
"нельзя: порядок детей карточки этого не обещает"
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
sections: tuple[CatalogSection, ...] = ()
|
|
253
|
+
else:
|
|
254
|
+
sections = tuple(
|
|
255
|
+
_section(one, at, href.or_none())
|
|
256
|
+
for at, one in enumerate(section_list.css("li > a"))
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
games.append(
|
|
260
|
+
CatalogGame(
|
|
261
|
+
game_id=game_id,
|
|
262
|
+
title_text=_text(link, "game_title"),
|
|
263
|
+
href=href,
|
|
264
|
+
# Класс hidden говорит, какой вариант показан переключателем
|
|
265
|
+
# сейчас, а не что узел служебный.
|
|
266
|
+
is_shown="hidden" not in ((title.attributes or {}).get("class") or "").split(),
|
|
267
|
+
variant_index=index,
|
|
268
|
+
sections=sections,
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return games, defects
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def normalize_catalog_query(query: str) -> str:
|
|
276
|
+
"""Проверяет запрос до сети; предел 200 знаков принадлежит SDK."""
|
|
277
|
+
if not isinstance(query, str):
|
|
278
|
+
raise ValidationError("query должен быть строкой")
|
|
279
|
+
if any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in query):
|
|
280
|
+
raise ValidationError("query не должен содержать управляющие символы")
|
|
281
|
+
try:
|
|
282
|
+
query.encode("utf-8")
|
|
283
|
+
except UnicodeEncodeError:
|
|
284
|
+
raise ValidationError("query должен содержать корректный Unicode") from None
|
|
285
|
+
query = query.strip().lower()
|
|
286
|
+
if not 1 <= len(query) <= 200:
|
|
287
|
+
raise ValidationError("query должен содержать от 1 до 200 знаков после нормализации")
|
|
288
|
+
return query
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _unique_fields(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
292
|
+
result: dict[str, object] = {}
|
|
293
|
+
for key, value in pairs:
|
|
294
|
+
if key in result:
|
|
295
|
+
raise ValueError("повтор поля JSON")
|
|
296
|
+
result[key] = value
|
|
297
|
+
return result
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse_catalog_search(body: str, query: str, observed_at: datetime) -> CatalogPage:
|
|
301
|
+
"""Читает наблюдённый JSON поиска, сохраняя серверные совпадения и варианты."""
|
|
302
|
+
query = normalize_catalog_query(query)
|
|
303
|
+
try:
|
|
304
|
+
payload = json.loads(body, object_pairs_hook=_unique_fields)
|
|
305
|
+
except (ValueError, RecursionError):
|
|
306
|
+
raise ProtocolChangedError("ответ поиска каталога не является JSON") from None
|
|
307
|
+
if not isinstance(payload, dict) or set(payload) != {"html"}:
|
|
308
|
+
raise ProtocolChangedError("ответ поиска каталога должен содержать только html")
|
|
309
|
+
html = payload["html"]
|
|
310
|
+
if not isinstance(html, str):
|
|
311
|
+
raise ProtocolChangedError("html поиска каталога должен быть строкой")
|
|
312
|
+
if html == "":
|
|
313
|
+
return CatalogPage(
|
|
314
|
+
Completeness.COMPLETE, "search_no_matches", observed_at, 0, 0, 0, 0, query=query
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
tree = HTMLParser(html.encode("utf-8"))
|
|
318
|
+
except UnicodeError:
|
|
319
|
+
raise ProtocolChangedError("html поиска содержит некорректный Unicode") from None
|
|
320
|
+
groups = tree.css(SELECTORS["catalog.search.response"])
|
|
321
|
+
if not groups:
|
|
322
|
+
raise ProtocolChangedError("в ответе поиска нет групп каталога")
|
|
323
|
+
games: list[CatalogGame] = []
|
|
324
|
+
defects: list[Defect] = []
|
|
325
|
+
seen: set[str] = set()
|
|
326
|
+
cards_total = 0
|
|
327
|
+
for group in groups:
|
|
328
|
+
cards = group.css(_CARD_IN_LIST)
|
|
329
|
+
if not cards:
|
|
330
|
+
raise ProtocolChangedError("в группе поиска нет карточек игр")
|
|
331
|
+
cards_total += len(cards)
|
|
332
|
+
for card in cards:
|
|
333
|
+
found, card_defects = _card(card)
|
|
334
|
+
defects.extend(card_defects)
|
|
335
|
+
damaged = not found
|
|
336
|
+
for game in found:
|
|
337
|
+
key = game.game_id.or_none()
|
|
338
|
+
damaged |= not key or key in seen
|
|
339
|
+
if key:
|
|
340
|
+
seen.add(key)
|
|
341
|
+
damaged |= not game.title_text.is_observed or not game.href.is_observed
|
|
342
|
+
damaged |= not game.sections
|
|
343
|
+
damaged |= any(
|
|
344
|
+
not all(
|
|
345
|
+
value.is_observed
|
|
346
|
+
for value in (
|
|
347
|
+
section.href,
|
|
348
|
+
section.kind,
|
|
349
|
+
section.section_id,
|
|
350
|
+
section.title_text,
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
for section in game.sections
|
|
354
|
+
)
|
|
355
|
+
if damaged:
|
|
356
|
+
defects.append(
|
|
357
|
+
Defect(
|
|
358
|
+
Severity.ROW,
|
|
359
|
+
"search_card_damaged",
|
|
360
|
+
"карточка поиска неполна или повторяет идентификатор игры",
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
games.extend(found)
|
|
364
|
+
return CatalogPage(
|
|
365
|
+
completeness=Completeness.PARTIAL if defects else Completeness.UNKNOWN,
|
|
366
|
+
reason="search_row_defects" if defects else "search_total_unobserved",
|
|
367
|
+
observed_at=observed_at,
|
|
368
|
+
cards_total=cards_total,
|
|
369
|
+
games_total=len(games),
|
|
370
|
+
sections_total=sum(len(game.sections) for game in games),
|
|
371
|
+
letter_groups=len(tree.css(SELECTORS["catalog.search.letter_groups"])),
|
|
372
|
+
defects=tuple(defects),
|
|
373
|
+
_games=tuple(games),
|
|
374
|
+
query=query,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def parse_catalog(html: str, observed_at: datetime) -> CatalogPage:
|
|
379
|
+
"""Разбирает каталог с корня площадки.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
html (str): Тело страницы. Предполагается уже классифицированным как
|
|
383
|
+
пригодное: состояние сессии здесь не проверяется.
|
|
384
|
+
observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
|
|
385
|
+
разбор оставался чистым и повторяемым на сохранённом снимке.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
CatalogPage: Игры каталога с их разделами.
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
ProtocolChangedError: Если основного каталога на странице нет: пустой
|
|
392
|
+
каталог вернуть нельзя, он неотличим от смены разметки.
|
|
393
|
+
"""
|
|
394
|
+
tree = HTMLParser(html)
|
|
395
|
+
catalog = tree.css_first(_CATALOG)
|
|
396
|
+
|
|
397
|
+
if catalog is None:
|
|
398
|
+
raise ProtocolChangedError(
|
|
399
|
+
f"на корне площадки нет основного каталога ({_CATALOG}). "
|
|
400
|
+
"Пустой каталог вернуть нельзя: он неотличим от смены разметки"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
defects: list[Defect] = []
|
|
404
|
+
cards = catalog.css(_CARD_IN_LIST)
|
|
405
|
+
if not cards:
|
|
406
|
+
raise ProtocolChangedError(
|
|
407
|
+
"в каталоге нет ни одной карточки игры. Это изменение разметки, а не пустой каталог"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
games: list[CatalogGame] = []
|
|
411
|
+
for card in cards:
|
|
412
|
+
found, card_defects = _card(card)
|
|
413
|
+
games.extend(found)
|
|
414
|
+
defects.extend(card_defects)
|
|
415
|
+
|
|
416
|
+
# Избранное читается НЕ ради игр - все его карточки повторяют основной
|
|
417
|
+
# каталог, - а ради проверки, что оно и вправду ничего не добавляет.
|
|
418
|
+
favourites = tree.css_first(_FAVOURITES)
|
|
419
|
+
if favourites is not None:
|
|
420
|
+
known = {one.game_id.or_none() for one in games if one.game_id.is_observed}
|
|
421
|
+
extra = {
|
|
422
|
+
((node.attributes or {}).get("data-id") or "").strip()
|
|
423
|
+
for node in favourites.css(_TITLE_IN_CARD)
|
|
424
|
+
} - known
|
|
425
|
+
if extra:
|
|
426
|
+
defects.append(
|
|
427
|
+
Defect(
|
|
428
|
+
severity=Severity.PAGE,
|
|
429
|
+
code="favourites_add_games",
|
|
430
|
+
detail=(
|
|
431
|
+
f"в избранном {len(extra)} игр, которых нет в основном каталоге. "
|
|
432
|
+
"Прежде избранное повторяло его целиком, и читать его было незачем"
|
|
433
|
+
),
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
letter_groups = len(tree.css(_LETTER_GROUP))
|
|
438
|
+
alphabet = tree.css_first(_ALPHABET)
|
|
439
|
+
index_links = len(alphabet.css("a")) if alphabet is not None else 0
|
|
440
|
+
|
|
441
|
+
# Полнота по ПОЛОЖИТЕЛЬНОМУ доводу: указатель по буквам не обещает ни одной
|
|
442
|
+
# группы, которой на странице нет. Ненаходка кнопки догрузки доводом не
|
|
443
|
+
# является и здесь не нужна.
|
|
444
|
+
if alphabet is None:
|
|
445
|
+
completeness, reason = Completeness.PARTIAL, "alphabet_index_missing"
|
|
446
|
+
elif index_links != letter_groups:
|
|
447
|
+
completeness, reason = Completeness.PARTIAL, "alphabet_index_disagrees"
|
|
448
|
+
defects.append(
|
|
449
|
+
Defect(
|
|
450
|
+
severity=Severity.PAGE,
|
|
451
|
+
code="alphabet_index_disagrees",
|
|
452
|
+
detail=(
|
|
453
|
+
f"в указателе {index_links} ссылок, а групп на странице {letter_groups}: "
|
|
454
|
+
"указатель обещает то, чего на странице нет, либо наоборот"
|
|
455
|
+
),
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
elif any(one.severity is Severity.PAGE for one in defects):
|
|
459
|
+
completeness, reason = Completeness.PARTIAL, "page_defects"
|
|
460
|
+
else:
|
|
461
|
+
completeness, reason = Completeness.COMPLETE, "alphabet_index_agrees"
|
|
462
|
+
|
|
463
|
+
return CatalogPage(
|
|
464
|
+
completeness=completeness,
|
|
465
|
+
reason=reason,
|
|
466
|
+
observed_at=observed_at,
|
|
467
|
+
cards_total=len(cards),
|
|
468
|
+
games_total=len(games),
|
|
469
|
+
sections_total=sum(len(one.sections) for one in games),
|
|
470
|
+
letter_groups=letter_groups,
|
|
471
|
+
defects=tuple(defects),
|
|
472
|
+
_games=tuple(games),
|
|
473
|
+
)
|