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.
Files changed (87) hide show
  1. funora/__init__.py +265 -0
  2. funora/_account.py +661 -0
  3. funora/_aclient.py +1484 -0
  4. funora/_budget.py +579 -0
  5. funora/_calc.py +182 -0
  6. funora/_canonical.py +235 -0
  7. funora/_catalog.py +473 -0
  8. funora/_chat_history.py +367 -0
  9. funora/_chats.py +392 -0
  10. funora/_chips.py +403 -0
  11. funora/_classify.py +466 -0
  12. funora/_client.py +1490 -0
  13. funora/_currency_switch.py +130 -0
  14. funora/_cursor.py +120 -0
  15. funora/_delivered.py +233 -0
  16. funora/_diff.py +752 -0
  17. funora/_engine.py +4795 -0
  18. funora/_extract.py +124 -0
  19. funora/_field_schema.py +112 -0
  20. funora/_fileio.py +58 -0
  21. funora/_gate.py +69 -0
  22. funora/_hops.py +101 -0
  23. funora/_host.py +120 -0
  24. funora/_identity.py +284 -0
  25. funora/_json.py +31 -0
  26. funora/_listen.py +312 -0
  27. funora/_lot_form.py +331 -0
  28. funora/_market.py +406 -0
  29. funora/_matching.py +147 -0
  30. funora/_money.py +279 -0
  31. funora/_monitoring.py +396 -0
  32. funora/_observed.py +237 -0
  33. funora/_order.py +552 -0
  34. funora/_order_details.py +281 -0
  35. funora/_orders.py +807 -0
  36. funora/_outbound.py +453 -0
  37. funora/_own_lots.py +301 -0
  38. funora/_poll.py +410 -0
  39. funora/_price_audit.py +281 -0
  40. funora/_proxies.py +232 -0
  41. funora/_raise.py +150 -0
  42. funora/_refund.py +102 -0
  43. funora/_result.py +157 -0
  44. funora/_retry.py +213 -0
  45. funora/_review_write.py +138 -0
  46. funora/_reviews.py +584 -0
  47. funora/_runner.py +651 -0
  48. funora/_secret.py +385 -0
  49. funora/_showcase.py +362 -0
  50. funora/_signals.py +375 -0
  51. funora/_skeleton.py +752 -0
  52. funora/_snapshot.py +276 -0
  53. funora/_state.py +279 -0
  54. funora/_stock.py +32 -0
  55. funora/_thread.py +574 -0
  56. funora/_transport.py +1023 -0
  57. funora/_updates.py +292 -0
  58. funora/_verdicts.py +91 -0
  59. funora/_viewing.py +143 -0
  60. funora/_watch.py +825 -0
  61. funora/_watch_state.py +237 -0
  62. funora/_whoami.py +546 -0
  63. funora/bot/__init__.py +52 -0
  64. funora/bot/_delivery.py +341 -0
  65. funora/bot/_outbox.py +261 -0
  66. funora/bot/_runtime.py +447 -0
  67. funora/bot/_spool.py +534 -0
  68. funora/budget.py +311 -0
  69. funora/capabilities.py +297 -0
  70. funora/conformance.py +758 -0
  71. funora/contract.py +73 -0
  72. funora/errors.py +996 -0
  73. funora/events.py +189 -0
  74. funora/extraction.py +420 -0
  75. funora/observe.py +560 -0
  76. funora/operations.py +671 -0
  77. funora/py.typed +0 -0
  78. funora/reconciliation.py +51 -0
  79. funora/response_classes.py +155 -0
  80. funora/retry.py +238 -0
  81. funora/send_outcome.py +78 -0
  82. funora/skeleton_format.py +77 -0
  83. funora-0.0.1.dev2.dist-info/METADATA +294 -0
  84. funora-0.0.1.dev2.dist-info/RECORD +87 -0
  85. funora-0.0.1.dev2.dist-info/WHEEL +4 -0
  86. funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
  87. funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_order.py ADDED
@@ -0,0 +1,552 @@
1
+ """Разбор страницы одного заказа.
2
+
3
+ Модуль чистый: вход - разметка и момент наблюдения, выход - запись. Разбор
4
+ обязан повторяться на сохранённом снимке спустя полгода и давать то же самое.
5
+
6
+ ГЛАВНОЕ ПРО ЭТУ СТРАНИЦУ, и оно объясняет почти всё остальное. Восемь её
7
+ параметров подписаны локализованными метками в узлах без класса, и различить
8
+ поля между собой разметка не даёт. Два из восьми совпадают ПОБАЙТОВО вместе с
9
+ обёрткой и цепочкой предков - шестьсот двадцать четыре байта против шестисот
10
+ двадцати четырёх, - и отличаются только положением.
11
+
12
+ Отсюда устройство: именованными полями отдаётся то, у чего есть свой якорь -
13
+ атрибут, класс, ссылка; остальное отдаётся перечнем «метка и значение» как есть.
14
+ Разбор, назвавший восьмой параметр по его месту, соврал бы в тот день, когда
15
+ площадка переставит колонки, - и соврал бы молча.
16
+
17
+ Три решения сверх этого.
18
+
19
+ Номер заказа берётся из АТРИБУТА виджета отзыва, а не из текста заголовка.
20
+ Заголовок его тоже несёт, но голым текстовым узлом до тега переноса: читать
21
+ оттуда значило бы резать строку по вёрстке.
22
+
23
+ Состояние читается по словарю цветовых классов, снятому со списка продаж, и
24
+ помечается ВЫВЕДЕННЫМ. Что словарь верен и для этой страницы - рассуждение,
25
+ пусть и правдоподобное: класс тот же, оформление у площадки одно. Пометка
26
+ уверенности существует ровно для таких случаев.
27
+
28
+ Отсутствие отзыва читается ПОЛОЖИТЕЛЬНО: атрибут оценки присутствует и пуст.
29
+ Это свидетельство, а не неудача поиска, и потому пустой отзыв даёт наблюдение
30
+ «пусто», а не «не наблюдали».
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass, field
36
+ from datetime import datetime
37
+ from typing import Final
38
+
39
+ from selectolax.parser import HTMLParser, Node
40
+
41
+ from ._observed import Confidence, Observed
42
+ from ._result import Defect, Severity
43
+ from .errors import ProtocolChangedError
44
+ from .extraction import PRESENCE_BY_CLASS, SELECTORS, STATUS_BY_CELL_CLASS, OrderStatus
45
+
46
+ __all__ = ["parse_review_block", "OrderParam", "OrderView", "parse_order_page"]
47
+
48
+ _ORDER_NUMBER_CARRIER: Final[str] = SELECTORS["order.identity.order_number"]
49
+ _STATUS_CARRIER: Final[str] = SELECTORS["order.identity.status_carrier"]
50
+ _REFUND_FORM: Final[str] = SELECTORS["order.identity.refund_available"]
51
+ _CATEGORY_ITEM: Final[str] = SELECTORS["order.identity.category_link"]
52
+ _AMOUNT_BLOCK: Final[str] = SELECTORS["order.identity.amount_block"]
53
+ _COUNTERPARTY_LINK: Final[str] = SELECTORS["order.counterparty.link"]
54
+ _COUNTERPARTY_FLAGS: Final[str] = SELECTORS["order.counterparty.account_flags"]
55
+ _REVIEW_CONTAINER: Final[str] = SELECTORS["order.review.container"]
56
+ _REVIEW_AUTHOR: Final[str] = SELECTORS["order.review.author"]
57
+ _CHAT_WIDGET: Final[str] = SELECTORS["order.chat.widget"]
58
+ _MESSAGE: Final[str] = SELECTORS["order.chat.messages"]
59
+
60
+ #: Ссылка на страницу переписки целиком. Читается как второй, независимый
61
+ #: от виджета носитель того же диалога: разойдись они - разметка изменилась.
62
+ _FULL_CHAT: Final[str] = SELECTORS["order.chat.full_chat_link"]
63
+ _PARAM_LIST: Final[str] = SELECTORS["order.params.container"]
64
+ _PARAM_ITEM: Final[str] = SELECTORS["order.params.item"]
65
+ _PARAM_LABEL: Final[str] = SELECTORS["order.params.label"]
66
+
67
+ #: Ссылка на раздел внутри параметра. Селектор параметра находит его по НАЛИЧИЮ
68
+ #: такой ссылки, а прочесть её надо отдельно.
69
+ _CATEGORY_HREF: Final[str] = SELECTORS["order.identity.category_link.attributes.category_href"]
70
+
71
+ #: Токен блокировки учётной записи.
72
+ #:
73
+ #: Наблюдён впервые на странице заказа. Словарь присутствия, выведенный по
74
+ #: списку продаж, знает два токена - online и offline, - и этого в нём нет.
75
+ _BANNED: Final[str] = "banned"
76
+
77
+ #: Число суммы и знак валюты внутри блока суммы.
78
+ #:
79
+ #: Адресуются ТЕГАМИ, а не классами. Классы там утилитарные - отступ и
80
+ #: начертание, - и редизайн вправе их переставить, ничего не меняя по существу.
81
+ #: Теги же несут роли: число в span, знак в strong.
82
+ _AMOUNT_NUMBER: Final[str] = "span"
83
+ _AMOUNT_SYMBOL: Final[str] = "strong"
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class OrderParam:
88
+ """Один параметр заказа, как показала страница.
89
+
90
+ Имени у параметра нет нарочно. Различить восемь параметров можно было бы
91
+ только по тексту метки, а текст локализован: правило, читающее «Дата»,
92
+ умрёт на английской локали молча.
93
+
94
+ Attributes:
95
+ label_text (str): Метка параметра, как показана.
96
+ value_text (str): Значение параметра, как показано.
97
+ """
98
+
99
+ label_text: str
100
+ value_text: str
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class OrderView:
105
+ """Заказ в том виде, в каком его отдаёт страница.
106
+
107
+ Не Order и намеренно отличается от него типом. Полную запись со страницы
108
+ собрать нельзя: сторон она не разделяет, кода валюты не даёт.
109
+
110
+ Attributes:
111
+ order_number (Observed[str]): Номер заказа из атрибута.
112
+ status (Observed[OrderStatus]): Состояние заказа, выведенное по словарю.
113
+ status_class (Observed[str]): Имя класса, из которого прочитано состояние.
114
+ amount_text (Observed[str]): Сумма, как показана.
115
+ currency_symbol_text (Observed[str]): Знак валюты.
116
+ category_href (Observed[str]): Адрес раздела заказа.
117
+ counterparty_name (Observed[str]): Имя контрагента.
118
+ counterparty_href (Observed[str]): Адрес профиля контрагента.
119
+ counterparty_online (Observed[bool]): В сети ли контрагент.
120
+ counterparty_banned (Observed[bool]): Заблокирован ли его аккаунт.
121
+ chat_node_id (Observed[str]): Идентификатор диалога по заказу.
122
+ refund_available (Observed[bool]): Показана ли форма возврата.
123
+ review_rating (Observed[int]): Оценка в отзыве, если он есть.
124
+ review_author_id (Observed[str]): Идентификатор автора отзыва.
125
+ messages_shown (int): Сколько сообщений показала страница.
126
+ observed_at (datetime): Момент наблюдения.
127
+ defects (tuple[Defect, ...]): Замеченные повреждения.
128
+ """
129
+
130
+ order_number: Observed[str]
131
+ status: Observed[OrderStatus]
132
+ status_class: Observed[str]
133
+ amount_text: Observed[str]
134
+ currency_symbol_text: Observed[str]
135
+ category_href: Observed[str]
136
+ counterparty_name: Observed[str]
137
+ counterparty_href: Observed[str]
138
+ counterparty_online: Observed[bool]
139
+ counterparty_banned: Observed[bool]
140
+ chat_node_id: Observed[str]
141
+ refund_available: Observed[bool]
142
+ review_rating: Observed[int]
143
+ review_author_id: Observed[str]
144
+ messages_shown: int
145
+ observed_at: datetime
146
+ defects: tuple[Defect, ...] = ()
147
+ _params: tuple[OrderParam, ...] = field(repr=False, default=())
148
+
149
+ def params(self) -> tuple[OrderParam, ...]:
150
+ """Возвращает параметры заказа как есть, в порядке появления.
151
+
152
+ Признания неполноты здесь не требуется, в отличие от списков. Перечень
153
+ не обещает полноты и обещать её не может: он ровно то, что показала
154
+ страница, и вызывающий это видит.
155
+
156
+ Returns:
157
+ tuple[OrderParam, ...]: Пары «метка и значение».
158
+ """
159
+ return self._params
160
+
161
+
162
+ def _attr(node: Node | None, name: str, field_name: str) -> Observed[str]:
163
+ """Читает атрибут, различая три исхода.
164
+
165
+ Пустое значение атрибута - это ФАКТ О СТРАНИЦЕ, а не о нашем незнании, и
166
+ отдаётся как наблюдение «пусто». На этой странице от различия зависит
167
+ чтение отзыва: пустой атрибут оценки означает «отзыва нет».
168
+
169
+ Args:
170
+ node (Node | None): Узел либо None.
171
+ name (str): Имя атрибута.
172
+ field_name (str): Имя поля для причины отсутствия.
173
+
174
+ Returns:
175
+ Observed[str]: Наблюдение.
176
+ """
177
+ if node is None:
178
+ return Observed.missing(f"selector_no_match:{field_name}")
179
+ attributes = node.attributes or {}
180
+ if name not in attributes:
181
+ return Observed.missing(f"attribute_absent:{field_name}")
182
+ value = (attributes.get(name) or "").strip()
183
+ return Observed.present(value) if value else Observed.empty("")
184
+
185
+
186
+ def _text(node: Node | None, field_name: str) -> Observed[str]:
187
+ """Читает текст узла как наблюдение.
188
+
189
+ Args:
190
+ node (Node | None): Узел либо None.
191
+ field_name (str): Имя поля для причины отсутствия.
192
+
193
+ Returns:
194
+ Observed[str]: Наблюдение.
195
+ """
196
+ if node is None:
197
+ return Observed.missing(f"selector_no_match:{field_name}")
198
+ value = " ".join((node.text() or "").split())
199
+ return Observed.present(value) if value else Observed.empty("")
200
+
201
+
202
+ def _classes(node: Node | None) -> set[str]:
203
+ """Возвращает имена классов узла множеством.
204
+
205
+ Args:
206
+ node (Node | None): Узел либо None.
207
+
208
+ Returns:
209
+ set[str]: Имена классов; пустое множество, если узла нет.
210
+ """
211
+ if node is None:
212
+ return set()
213
+ return set(((node.attributes or {}).get("class") or "").split())
214
+
215
+
216
+ def _status(tree: HTMLParser) -> tuple[Observed[OrderStatus], Observed[str], list[Defect]]:
217
+ """Читает состояние заказа из носителя в заголовке.
218
+
219
+ Узел адресуется ПОЛОЖЕНИЕМ в заголовке, а не своим классом: класс здесь -
220
+ значение, и селектор по нему находил бы узел лишь пока заказ в этом самом
221
+ состоянии.
222
+
223
+ Значение помечается ВЫВЕДЕННЫМ. Словарь цветовых классов снят со списка
224
+ продаж; что он верен и для страницы заказа - рассуждение, пусть и
225
+ правдоподобное. Пометка уверенности существует ровно для таких случаев.
226
+
227
+ Args:
228
+ tree (HTMLParser): Разобранная страница.
229
+
230
+ Returns:
231
+ tuple[Observed[OrderStatus], Observed[str], list[Defect]]: Состояние,
232
+ имя класса и перечень повреждений.
233
+ """
234
+ node = tree.css_first(_STATUS_CARRIER)
235
+ if node is None:
236
+ return (
237
+ Observed.missing("selector_no_match:status"),
238
+ Observed.missing("selector_no_match:status_class"),
239
+ [
240
+ Defect(
241
+ severity=Severity.PAGE,
242
+ code="status_carrier_missing",
243
+ detail=(
244
+ f"носителя состояния ({_STATUS_CARRIER}) на странице нет. "
245
+ "Состояние заказа - то, по чему бот решает выдавать товар"
246
+ ),
247
+ field_name="status",
248
+ )
249
+ ],
250
+ )
251
+
252
+ names = _classes(node)
253
+ known = sorted(names & set(STATUS_BY_CELL_CLASS))
254
+ raw = Observed.present(" ".join(sorted(names))) if names else Observed.empty("")
255
+
256
+ if not known:
257
+ return (
258
+ Observed.missing("status_class_not_in_dictionary"),
259
+ raw,
260
+ [
261
+ Defect(
262
+ severity=Severity.PAGE,
263
+ code="status_class_not_in_dictionary",
264
+ detail=(
265
+ f"классы носителя {sorted(names)} не входят в наблюдённый "
266
+ "словарь состояний. Наблюдено одно состояние на одном заказе, "
267
+ "и незнакомое честнее объявить непрочитанным"
268
+ ),
269
+ field_name="status",
270
+ )
271
+ ],
272
+ )
273
+
274
+ if len(known) > 1:
275
+ return (
276
+ Observed.missing("status_carriers_disagree"),
277
+ raw,
278
+ [
279
+ Defect(
280
+ severity=Severity.PAGE,
281
+ code="status_carriers_disagree",
282
+ detail=(
283
+ f"носитель несёт сразу {known}: состояний два, а заказ один. "
284
+ "Взять любое значило бы выбрать наугад"
285
+ ),
286
+ field_name="status",
287
+ )
288
+ ],
289
+ )
290
+
291
+ return Observed.present(STATUS_BY_CELL_CLASS[known[0]], Confidence.INFERRED), raw, []
292
+
293
+
294
+ def _amount(tree: HTMLParser) -> tuple[Observed[str], Observed[str]]:
295
+ """Разделяет сумму и знак валюты.
296
+
297
+ Селектор берёт блок целиком - число и знак вместе, - а делит их код.
298
+ Селектором это делалось бы через утилитарные классы отступа и начертания,
299
+ которые редизайн вправе переставить, ничего не меняя по существу.
300
+
301
+ Args:
302
+ tree (HTMLParser): Разобранная страница.
303
+
304
+ Returns:
305
+ tuple[Observed[str], Observed[str]]: Сумма и знак валюты.
306
+ """
307
+ block = tree.css_first(_AMOUNT_BLOCK)
308
+ if block is None:
309
+ return (
310
+ Observed.missing("selector_no_match:amount"),
311
+ Observed.missing("selector_no_match:currency_symbol"),
312
+ )
313
+
314
+ value = tree.css_first(f"{_AMOUNT_BLOCK} > div")
315
+ if value is None:
316
+ return (
317
+ Observed.missing("amount_block_has_no_value"),
318
+ Observed.missing("amount_block_has_no_value"),
319
+ )
320
+
321
+ # Делится ПО УЗЛАМ, а не разбором текста. Первая редакция резала текст блока
322
+ # образцом «цифры, потом остальное» - и не работала на снимке вовсе: скелет
323
+ # заменяет значение подписью, и текст начинается с буквы. Разбор, который
324
+ # нельзя проверить на фикстуре, не проверен ничем.
325
+ numbers = value.css(_AMOUNT_NUMBER)
326
+ symbols = value.css(_AMOUNT_SYMBOL)
327
+ if len(numbers) != 1 or len(symbols) != 1:
328
+ return (
329
+ Observed.missing("amount_block_shape_changed"),
330
+ Observed.missing("amount_block_shape_changed"),
331
+ )
332
+
333
+ return _text(numbers[0], "amount"), _text(symbols[0], "currency_symbol")
334
+
335
+
336
+ def _flag(names: set[str], token: str, dictionary: dict[str, bool] | None = None) -> Observed[bool]:
337
+ """Читает признак по наблюдённому словарю классов, а не по отрицанию.
338
+
339
+ Правило «нет offline, значит online» запрещено: оно выглядело бы работающим
340
+ ровно до переименования класса, а неузнанный класс честнее объявить
341
+ ненаблюдённым.
342
+
343
+ Args:
344
+ names (set[str]): Имена классов узла.
345
+ token (str): Токен, наличие которого читается, если словарь не задан.
346
+ dictionary (dict[str, bool] | None): Словарь «класс - значение».
347
+
348
+ Returns:
349
+ Observed[bool]: Наблюдение.
350
+ """
351
+ if not names:
352
+ return Observed.missing("selector_no_match:account_flags")
353
+ if dictionary is None:
354
+ return Observed.present(token in names)
355
+ hits = sorted(names & set(dictionary))
356
+ if not hits:
357
+ return Observed.missing("class_not_in_dictionary")
358
+ if len(hits) > 1:
359
+ return Observed.missing("classes_disagree")
360
+ return Observed.present(dictionary[hits[0]])
361
+
362
+
363
+ def _review(tree: HTMLParser) -> tuple[Observed[int], Observed[str], list[Defect]]:
364
+ """Читает оценку и автора отзыва по заказу.
365
+
366
+ ОТСУТСТВИЕ ОТЗЫВА ЧИТАЕТСЯ ПОЛОЖИТЕЛЬНО: атрибут оценки присутствует и
367
+ пуст. Это свидетельство, а не неудача поиска, и потому пустой отзыв даёт
368
+ наблюдение «пусто», а не «не наблюдали».
369
+
370
+ Args:
371
+ tree (HTMLParser): Разобранная страница.
372
+
373
+ Returns:
374
+ tuple[Observed[int], Observed[str], list[Defect]]: Оценка, автор,
375
+ перечень повреждений.
376
+ """
377
+ author = _attr(tree.css_first(_REVIEW_AUTHOR), "data-author", "review_author_id")
378
+ raw = _attr(tree.css_first(_REVIEW_CONTAINER), "data-rating", "review_rating")
379
+
380
+ if not raw.is_observed:
381
+ return Observed.missing(raw.reason or "review_rating_unreadable"), author, []
382
+
383
+ value = raw.value
384
+ if not value:
385
+ # Атрибут есть и пуст - отзыва нет. Наблюдение, а не незнание.
386
+ return Observed.empty(0), author, []
387
+
388
+ if not value.isdigit():
389
+ return (
390
+ Observed.missing("review_rating_not_a_number"),
391
+ author,
392
+ [
393
+ Defect(
394
+ severity=Severity.PAGE,
395
+ code="review_rating_not_a_number",
396
+ detail=f"оценка отзыва пришла как {value!r}, а не числом",
397
+ field_name="review_rating",
398
+ )
399
+ ],
400
+ )
401
+
402
+ return Observed.present(int(value)), author, []
403
+
404
+
405
+ def parse_review_block(html: str) -> tuple[Observed[int], Observed[str], tuple[Defect, ...]]:
406
+ """Читает отзыв из КУСКА разметки, а не из целой страницы.
407
+
408
+ ЗАЧЕМ ОТДЕЛЬНЫЙ ВХОД. Ответ площадки на написание отзыва несёт
409
+ перерисованный виджет - тот же самый, что лежит на странице заказа.
410
+ Разобрать его вторым разбором значило бы завести две дороги к одной
411
+ разметке, и разошлись бы они молча: селектор поправят в одной.
412
+
413
+ Аргументы:
414
+ html (str): Кусок разметки с виджетом отзыва.
415
+
416
+ Возвращает:
417
+ tuple[Observed[int], Observed[str], tuple[Defect, ...]]: Оценка, автор,
418
+ перечень повреждений.
419
+ """
420
+ rating, author, defects = _review(HTMLParser(html))
421
+ return rating, author, tuple(defects)
422
+
423
+
424
+ def parse_order_page(html: str, observed_at: datetime) -> OrderView:
425
+ """Разбирает страницу одного заказа.
426
+
427
+ Args:
428
+ html (str): Тело страницы. Предполагается уже классифицированным как
429
+ пригодное: состояние сессии здесь не проверяется.
430
+ observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
431
+ разбор оставался чистым и повторяемым на сохранённом снимке.
432
+
433
+ Returns:
434
+ OrderView: Заказ в том виде, в каком его отдала страница.
435
+
436
+ Raises:
437
+ ProtocolChangedError: Если перечня параметров на странице нет вовсе -
438
+ без него читать нечего, и пустую запись возвращать нельзя: она
439
+ неотличима от заказа без параметров.
440
+ """
441
+ tree = HTMLParser(html)
442
+ defects: list[Defect] = []
443
+
444
+ if tree.css_first(_PARAM_LIST) is None:
445
+ raise ProtocolChangedError(
446
+ f"на странице заказа нет перечня параметров ({_PARAM_LIST}). "
447
+ "Пустую запись вернуть нельзя: она неотличима от заказа без параметров"
448
+ )
449
+
450
+ status, status_class, status_defects = _status(tree)
451
+ defects.extend(status_defects)
452
+
453
+ amount, symbol = _amount(tree)
454
+ review_rating, review_author, review_defects = _review(tree)
455
+ defects.extend(review_defects)
456
+
457
+ flags = _classes(tree.css_first(_COUNTERPARTY_FLAGS))
458
+ widget = tree.css_first(_CHAT_WIDGET)
459
+
460
+ # Ссылка «открыть переписку целиком» - второй носитель того же диалога.
461
+ # Пропади она при живом виджете, и наоборот - разметка изменилась, и молча
462
+ # отдать идентификатор было бы хуже, чем сказать об этом.
463
+ if (tree.css_first(_FULL_CHAT) is None) != (widget is None):
464
+ defects.append(
465
+ Defect(
466
+ severity=Severity.PAGE,
467
+ code="chat_carriers_disagree",
468
+ detail=(
469
+ f"виджет переписки ({_CHAT_WIDGET}) и ссылка на неё "
470
+ f"({_FULL_CHAT}) разошлись: один есть, другого нет"
471
+ ),
472
+ field_name="chat_node_id",
473
+ )
474
+ )
475
+
476
+ # Ссылка на раздел ищется по всему перечню, а не внутри найденного
477
+ # параметра: параметр и опознаётся-то по наличию такой ссылки, и второе
478
+ # совпадение означало бы, что признак перестал различать.
479
+ if tree.css_first(_CATEGORY_ITEM) is None:
480
+ defects.append(
481
+ Defect(
482
+ severity=Severity.PAGE,
483
+ code="category_item_missing",
484
+ detail=(f"параметра со ссылкой на раздел ({_CATEGORY_ITEM}) на странице нет"),
485
+ field_name="category_href",
486
+ )
487
+ )
488
+
489
+ category_links = tree.css(_CATEGORY_HREF)
490
+ if len(category_links) > 1:
491
+ defects.append(
492
+ Defect(
493
+ severity=Severity.PAGE,
494
+ code="category_link_ambiguous",
495
+ detail=(
496
+ f"ссылок на раздел среди параметров {len(category_links)}, а признак "
497
+ "рассчитан на одну. Взять первую значило бы выбрать наугад"
498
+ ),
499
+ field_name="category_href",
500
+ )
501
+ )
502
+ category = Observed[str].missing("category_link_ambiguous")
503
+ else:
504
+ category = _attr(category_links[0] if category_links else None, "href", "category_href")
505
+
506
+ params: list[OrderParam] = []
507
+ param_nodes = tree.css(_PARAM_ITEM)
508
+ for item in param_nodes:
509
+ label = item.css_first("h5")
510
+ if label is None:
511
+ continue
512
+ label_text = " ".join((label.text() or "").split())
513
+ # Текст значения берётся у ПАРАМЕТРА без метки, а не у первого вложенного
514
+ # блока: у восьмого параметра внутри лежит окно возврата со своими
515
+ # полями, и обход по вложенным утащил бы их в значение.
516
+ whole = " ".join((item.text() or "").split())
517
+ value_text = whole[len(label_text) :].strip() if whole.startswith(label_text) else whole
518
+ params.append(OrderParam(label_text=label_text, value_text=value_text))
519
+
520
+ label_count = len(tree.css(_PARAM_LABEL))
521
+ if len(params) != len(param_nodes) or len(params) != label_count:
522
+ defects.append(
523
+ Defect(
524
+ severity=Severity.PAGE,
525
+ code="param_label_missing",
526
+ detail=(
527
+ f"параметров {len(param_nodes)}, с меткой {len(params)}, "
528
+ f"меток {label_count}: подписи параметров неполны или неоднозначны"
529
+ ),
530
+ )
531
+ )
532
+
533
+ return OrderView(
534
+ order_number=_attr(tree.css_first(_ORDER_NUMBER_CARRIER), "data-order", "order_number"),
535
+ status=status,
536
+ status_class=status_class,
537
+ amount_text=amount,
538
+ currency_symbol_text=symbol,
539
+ category_href=category,
540
+ counterparty_name=_text(tree.css_first(_COUNTERPARTY_LINK), "counterparty_name"),
541
+ counterparty_href=_attr(tree.css_first(_COUNTERPARTY_LINK), "href", "counterparty_href"),
542
+ counterparty_online=_flag(flags, "online", PRESENCE_BY_CLASS),
543
+ counterparty_banned=_flag(flags, _BANNED),
544
+ chat_node_id=_attr(widget, "data-id", "chat_node_id"),
545
+ refund_available=Observed.present(tree.css_first(_REFUND_FORM) is not None),
546
+ review_rating=review_rating,
547
+ review_author_id=review_author,
548
+ messages_shown=len(tree.css(_MESSAGE)),
549
+ observed_at=observed_at,
550
+ defects=tuple(defects),
551
+ _params=tuple(params),
552
+ )