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/_reviews.py ADDED
@@ -0,0 +1,584 @@
1
+ """Разбор отзывов со страницы профиля продавца.
2
+
3
+ Модуль чистый по тем же причинам, что и разбор списка продаж: вход - разметка и
4
+ момент наблюдения, выход - страница записей, и повторить его на сохранённом
5
+ снимке спустя полгода обязано давать тот же результат.
6
+
7
+ Три решения отличают его от разбора продаж.
8
+
9
+ Оценка читается из ИМЕНИ КЛАССА, а не из текста. У узла оценки текста нет
10
+ вовсе: площадка рисует звёзды стилями, а число прячет в классе - div.rating5.
11
+ Перечисление закрыто по наблюдению, а не по догадке: площадка сама перечислила
12
+ все пять уровней в выпадающем списке фильтра отзывов.
13
+
14
+ Узлов оценки в строке ДВА, а не один - широкий макет и узкий. Значение в них
15
+ одно и то же, и разбор обязан их сверить. Взять первый попавшийся значило бы
16
+ повторить ошибку, на которой уже спотыкался разбор продаж: он брал первый
17
+ [data-href] строки, в снимке их было два, оба вели на одного человека, и ошибка
18
+ не была видна ровно до того дня, когда перестала бы.
19
+
20
+ Полнота определяется строками и наблюдённым управлением догрузкой. COMPLETE
21
+ подтверждает конец текущей выборки при целостном ответе. Продолжение связано
22
+ с продавцом и выбранной оценкой, которую сервер может не повторять в форме.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from datetime import datetime
30
+ from typing import Final
31
+
32
+ from selectolax.parser import HTMLParser, Node
33
+
34
+ from ._cursor import decode_cursor, encode_cursor
35
+ from ._extract import attribute
36
+ from ._observed import Observed
37
+ from ._result import Completeness, Defect, Severity, collect_rows
38
+ from .errors import (
39
+ CursorIncompatibleError,
40
+ IncompleteResultError,
41
+ ProtocolChangedError,
42
+ ValidationError,
43
+ )
44
+ from .extraction import SELECTORS
45
+
46
+ __all__ = ["Review", "ReviewsCursor", "ReviewsPage", "parse_reviews_page"]
47
+
48
+ #: Внешний контейнер таблицы отзывов.
49
+ _TABLE: Final[str] = SELECTORS["reviews.table"]
50
+
51
+ #: Контейнер строк. Тот же класс, что у списка продаж: площадка переиспользует
52
+ #: виджет dyn-table.
53
+ _ROWS_CONTAINER: Final[str] = SELECTORS["reviews.rows_container"]
54
+
55
+ #: Строка отзыва.
56
+ _ROW: Final[str] = SELECTORS["reviews.row"]
57
+
58
+ #: Кнопка догрузки. Стоит сразу за таблицей отзывов.
59
+ #:
60
+ #: Утром 24.08.2026 контракт утверждал, что её нет: перебор классов по образцу
61
+ #: more|pagin|load|next|prev ничего не нашёл, и это приняли за отсутствие.
62
+ #: Названа она иначе, и образец её не поймал.
63
+ _CONTINUE: Final[str] = SELECTORS["reviews.pagination.continue_button"]
64
+
65
+ #: Форма догрузки. Второй узел того же механизма.
66
+ _PAGE_FORM: Final[str] = SELECTORS["reviews.pagination.form"]
67
+
68
+ #: Обёртка ОДНОГО отзыва, а не списка - имя обманывает.
69
+ #:
70
+ #: Служит третьим счётом строк, не зависящим ни от класса строки, ни от прямых
71
+ #: потомков контейнера. Обёрток ровно столько же, сколько отзывов, и расхождение
72
+ #: означает, что разметка изменилась там, где два других счёта этого не видят.
73
+ _WRAPPER: Final[str] = SELECTORS["reviews.row.wrapper"]
74
+
75
+ #: Узел оценки. В строке их два - широкий макет и узкий.
76
+ _RATING: Final[str] = SELECTORS["reviews.fields.rating"]
77
+
78
+ _AUTHOR_NAME: Final[str] = SELECTORS["reviews.fields.author_name"]
79
+ _AUTHOR_HREF: Final[str] = SELECTORS["reviews.fields.author_href"]
80
+ _AUTHOR_PHOTO_HREF: Final[str] = SELECTORS["reviews.fields.author_photo_href"]
81
+ _ORDER_HREF: Final[str] = SELECTORS["reviews.fields.order_href"]
82
+ _TEXT: Final[str] = SELECTORS["reviews.fields.text"]
83
+ _DATE_TEXT: Final[str] = SELECTORS["reviews.fields.date_text"]
84
+ _DETAIL_TEXT: Final[str] = SELECTORS["reviews.fields.detail_text"]
85
+
86
+ #: Оценка в имени класса: rating5 значит пять звёзд.
87
+ #:
88
+ #: Перечисление закрыто нарочно. Открытое приняло бы rating7 или rating0 за
89
+ #: оценку, а закрытое объявит незнакомое ненаблюдённым - и это правильный ответ:
90
+ #: смена шкалы у площадки должна быть заметна, а не подхвачена молча.
91
+ _RATING_CLASS: Final[re.Pattern[str]] = re.compile(r"^rating([1-5])$")
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class Review:
96
+ """Отзыв, прочитанный с профиля продавца.
97
+
98
+ Момента времени в записи нет, и это не упущение. Дата показана
99
+ локализованной человеческой записью - тридцать один знак с буквами, цифрами
100
+ и пунктуацией, - и разбирать её значило бы угадывать по одной локали из
101
+ трёх. Строка, которую нельзя разобрать, честнее момента, угаданного неверно.
102
+
103
+ Attributes:
104
+ row_index (int): Место отзыва на странице, считая с нуля.
105
+ rating (Observed[int]): Оценка в звёздах, целое от одного до пяти.
106
+ author_name (Observed[str]): Отображаемое имя автора.
107
+ author_href (Observed[str]): Адрес профиля автора.
108
+ order_href (Observed[str]): Адрес заказа, к которому отзыв относится.
109
+ text (Observed[str]): Текст отзыва.
110
+ date_text (Observed[str]): Дата в том виде, в каком её показали.
111
+ detail_text (Observed[str]): Подпись под отзывом: что купили и почём.
112
+ """
113
+
114
+ row_index: int
115
+ rating: Observed[int]
116
+ author_name: Observed[str]
117
+ author_href: Observed[str]
118
+ order_href: Observed[str]
119
+ text: Observed[str]
120
+ date_text: Observed[str]
121
+ detail_text: Observed[str]
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class ReviewsCursor:
126
+ """Позиция отзывов одного продавца с той же выбранной оценкой."""
127
+
128
+ user_id: str
129
+ value: str = field(repr=False)
130
+ rating: int | None = None
131
+
132
+ def to_token(self) -> str:
133
+ """Сериализует курсор для сохранения между запусками."""
134
+ try:
135
+ scope = normalize_review_rating(self.rating) or None
136
+ except ValidationError:
137
+ raise CursorIncompatibleError(
138
+ "оценка в курсоре должна быть целым 1..5 либо None"
139
+ ) from None
140
+ return encode_cursor("reviews.get", self.user_id, self.value, scope=scope)
141
+
142
+ @classmethod
143
+ def from_token(cls, token: str) -> ReviewsCursor:
144
+ """Восстанавливает курсор, проверяя версию, семейство и операцию."""
145
+ owner, position, scope = decode_cursor(token, kind="reviews.get")
146
+ if scope is not None and scope not in {"1", "2", "3", "4", "5"}:
147
+ raise CursorIncompatibleError("неподдерживаемая оценка в курсоре отзывов")
148
+ return cls(owner, position, None if scope is None else int(scope))
149
+
150
+
151
+ def normalize_review_rating(rating: int | None) -> str:
152
+ """Проверяет оценку до запроса и возвращает значение поля формы."""
153
+ if rating is None:
154
+ return ""
155
+ if type(rating) is not int or not 1 <= rating <= 5:
156
+ raise ValidationError("rating должен быть целым от 1 до 5 либо None")
157
+ return str(rating)
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class ReviewsPage:
162
+ """Результат чтения отзывов.
163
+
164
+ Записи отдаются методом, а не полем, по той же причине, что и у списка
165
+ продаж: открытый список делает неполноту незаметной.
166
+
167
+ Attributes:
168
+ completeness (Completeness): Полнота прочитанного.
169
+ reason (str): Машиночитаемая причина, по которой полнота такова.
170
+ observed_at (datetime): Момент наблюдения.
171
+ rows_total (int): Сколько кандидатов в отзывы нашлось, штук.
172
+ rows_accepted (int): Сколько отзывов собрано, штук.
173
+ rows_rejected (int): Сколько кандидатов отброшено, штук.
174
+ defects (tuple[Defect, ...]): Замеченные повреждения.
175
+ """
176
+
177
+ completeness: Completeness
178
+ reason: str
179
+ observed_at: datetime
180
+ rows_total: int
181
+ rows_accepted: int
182
+ rows_rejected: int
183
+ defects: tuple[Defect, ...] = ()
184
+ next_cursor: ReviewsCursor | None = None
185
+ _entries: tuple[Review, ...] = field(repr=False, default=())
186
+
187
+ def rows(self, *, accept_incomplete: bool = False) -> tuple[Review, ...]:
188
+ """Возвращает собранные отзывы.
189
+
190
+ Args:
191
+ accept_incomplete (bool): Признание того, что результат может быть
192
+ неполным. Без него неполный результат не выдаётся.
193
+
194
+ Returns:
195
+ tuple[Review, ...]: Отзывы в порядке появления на странице.
196
+
197
+ Raises:
198
+ IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
199
+ не признана.
200
+ """
201
+ if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
202
+ raise IncompleteResultError(
203
+ f"результат неполон ({self.completeness}, причина: {self.reason}), "
204
+ f"собрано {self.rows_accepted} из {self.rows_total}, "
205
+ f"повреждений {len(self.defects)}. Передайте accept_incomplete=True, "
206
+ "если готовы работать с неполными данными"
207
+ )
208
+ return self._entries
209
+
210
+ def __len__(self) -> int:
211
+ """Возвращает число собранных отзывов.
212
+
213
+ Returns:
214
+ int: Число собранных отзывов.
215
+ """
216
+ return len(self._entries)
217
+
218
+
219
+ def _text_of(node: Node | None, name: str) -> Observed[str]:
220
+ """Извлекает текст узла как наблюдаемое значение.
221
+
222
+ Args:
223
+ node (Node | None): Узел либо None, если селектор не нашёл ничего.
224
+ name (str): Имя поля для причины отсутствия.
225
+
226
+ Returns:
227
+ Observed[str]: Наблюдение. Отсутствие узла и пустой текст различаются.
228
+ """
229
+ if node is None:
230
+ return Observed.missing(f"selector_no_match:{name}")
231
+ value = " ".join((node.text() or "").split())
232
+ return Observed.present(value) if value else Observed.empty("")
233
+
234
+
235
+ def _rating(row: Node, index: int) -> tuple[Observed[int], list[Defect]]:
236
+ """Читает оценку из имени класса и сверяет два узла между собой.
237
+
238
+ Узлов оценки в строке два: один для широкого макета, другой для узкого.
239
+ Значение в них одно и то же, и расхождение означает не выбор, а изменение
240
+ разметки - объявлять оценку по первому попавшемуся значило бы отдать
241
+ покупателю чужую звезду молча.
242
+
243
+ Args:
244
+ row (Node): Строка отзыва.
245
+ index (int): Место строки на странице, для сообщения о повреждении.
246
+
247
+ Returns:
248
+ tuple[Observed[int], list[Defect]]: Оценка и перечень повреждений.
249
+ """
250
+ nodes = row.css(_RATING)
251
+ if not nodes:
252
+ return Observed.missing("selector_no_match:rating"), []
253
+
254
+ seen: set[int] = set()
255
+ unknown: list[str] = []
256
+ for node in nodes:
257
+ for name in (node.attributes.get("class") or "").split():
258
+ match = _RATING_CLASS.match(name)
259
+ if match is not None:
260
+ seen.add(int(match.group(1)))
261
+ else:
262
+ unknown.append(name)
263
+
264
+ if not seen:
265
+ # Узел есть, а оценки в его классах нет. Это не «оценки не показали»:
266
+ # это класс, которого перечисление не знает, и подхватить его молча
267
+ # значило бы принять чужую шкалу за свою.
268
+ return (
269
+ Observed.missing("rating_class_not_recognised"),
270
+ [
271
+ Defect(
272
+ severity=Severity.ROW,
273
+ code="rating_class_not_recognised",
274
+ detail=(
275
+ f"строка {index}: у узлов оценки классы {sorted(set(unknown))}, "
276
+ "ни один не входит в наблюдённое перечисление rating1..rating5"
277
+ ),
278
+ field_name="rating",
279
+ )
280
+ ],
281
+ )
282
+
283
+ if len(seen) > 1:
284
+ return (
285
+ Observed.missing("rating_carriers_disagree"),
286
+ [
287
+ Defect(
288
+ severity=Severity.ROW,
289
+ code="rating_carriers_disagree",
290
+ detail=(
291
+ f"строка {index}: узлы оценки разошлись, прочитано {sorted(seen)}. "
292
+ "Взять любую из них значило бы выбрать наугад"
293
+ ),
294
+ field_name="rating",
295
+ )
296
+ ],
297
+ )
298
+
299
+ return Observed.present(seen.pop()), []
300
+
301
+
302
+ def _author_href(row: Node, index: int) -> tuple[Observed[str], list[Defect]]:
303
+ """Читает адрес профиля автора и сверяет два его носителя.
304
+
305
+ Адресов в строке два: на имени и на аватаре. Ведут они в одно место, и
306
+ расхождение означает изменение разметки. Проверка заведена по той же
307
+ причине, по которой сверяются два узла оценки.
308
+
309
+ Args:
310
+ row (Node): Строка отзыва.
311
+ index (int): Место строки на странице, для сообщения о повреждении.
312
+
313
+ Returns:
314
+ tuple[Observed[str], list[Defect]]: Адрес и перечень повреждений.
315
+ """
316
+ from_name = attribute(row.css_first(_AUTHOR_HREF), "href", "author_href")
317
+ from_photo = attribute(row.css_first(_AUTHOR_PHOTO_HREF), "href", "author_photo_href")
318
+
319
+ if not from_name.is_observed or not from_photo.is_observed:
320
+ return from_name, []
321
+
322
+ if from_name.value != from_photo.value:
323
+ return (
324
+ Observed.missing("author_href_mismatch"),
325
+ [
326
+ Defect(
327
+ severity=Severity.ROW,
328
+ code="author_href_mismatch",
329
+ detail=(
330
+ f"строка {index}: адрес на имени {from_name.value!r} и адрес "
331
+ f"на аватаре {from_photo.value!r} ведут в разные места"
332
+ ),
333
+ field_name="author_href",
334
+ )
335
+ ],
336
+ )
337
+
338
+ return from_name, []
339
+
340
+
341
+ def _parse_row(row: Node, index: int) -> tuple[Review, list[Defect]]:
342
+ """Собирает один отзыв.
343
+
344
+ Отказа здесь нет ни одного: у отзыва нет поля, без которого запись
345
+ бессмысленна. У записи продажи такое поле есть - идентификатор заказа, - и
346
+ строка без него отбрасывается. Отзыв без адреса заказа остаётся отзывом:
347
+ оценка и текст читаются и без него.
348
+
349
+ Args:
350
+ row (Node): Строка отзыва.
351
+ index (int): Место строки на странице, считая с нуля.
352
+
353
+ Returns:
354
+ tuple[Review, list[Defect]]: Отзыв и перечень повреждений строки.
355
+ """
356
+ rating, defects = _rating(row, index)
357
+ author_href, href_defects = _author_href(row, index)
358
+ defects.extend(href_defects)
359
+
360
+ return (
361
+ Review(
362
+ row_index=index,
363
+ rating=rating,
364
+ author_name=_text_of(row.css_first(_AUTHOR_NAME), "author_name"),
365
+ author_href=author_href,
366
+ order_href=attribute(row.css_first(_ORDER_HREF), "href", "order_href"),
367
+ text=_text_of(row.css_first(_TEXT), "text"),
368
+ date_text=_text_of(row.css_first(_DATE_TEXT), "date_text"),
369
+ detail_text=_text_of(row.css_first(_DETAIL_TEXT), "detail_text"),
370
+ ),
371
+ defects,
372
+ )
373
+
374
+
375
+ def _totality(tree: HTMLParser) -> tuple[Completeness, str]:
376
+ """Определяет конец выборки по наблюдённой кнопке догрузки.
377
+
378
+ Видимая кнопка сопровождает страницы с продолжением; скрытая вместе с
379
+ пустой позицией - конечную страницу. Отсутствие кнопки не доказывает конец.
380
+ Форму, курсор и повреждения строк вызывающий проверяет отдельно.
381
+ """
382
+ button = tree.css_first(_CONTINUE)
383
+ if button is None:
384
+ # Кнопки нет вовсе. Это не «догружать нечего»: страницы без неё никто не
385
+ # видел, и объявлять по её отсутствию полноту значило бы вывести знание
386
+ # из ненаходки - ровно та ошибка, из-за которой этот раздел и переписан.
387
+ return Completeness.PARTIAL, "pagination_control_missing"
388
+
389
+ if "hidden" not in (button.attributes.get("class") or "").split():
390
+ # Кнопка показана: догружать есть что.
391
+ return Completeness.PARTIAL, "more_rows_available"
392
+
393
+ return Completeness.COMPLETE, "all_rows_parsed"
394
+
395
+
396
+ def _next_cursor(
397
+ tree: HTMLParser, defects: list[Defect], user_id: str | None, rating: int | None
398
+ ) -> ReviewsCursor | None:
399
+ forms = tree.css(_PAGE_FORM)
400
+ buttons = tree.css(_CONTINUE)
401
+ if len(forms) > 1 or len(buttons) > 1:
402
+ defects.append(
403
+ Defect(Severity.PAGE, "pagination_controls_ambiguous", "несколько форм или кнопок")
404
+ )
405
+ return None
406
+ if not forms or not buttons:
407
+ return None # Полнота отдельно учитывает отсутствие управляющих узлов.
408
+ visible = "hidden" not in (buttons[0].attributes.get("class") or "").split()
409
+ values = {}
410
+ for name, selector in (
411
+ ("user_id", SELECTORS["reviews.pagination.fields.user_id"]),
412
+ ("continue", SELECTORS["reviews.pagination.fields.continue"]),
413
+ ("filter", SELECTORS["reviews.pagination.fields.filter"]),
414
+ ):
415
+ nodes = tree.css(selector)
416
+ if len(nodes) > 1:
417
+ defects.append(Defect(Severity.PAGE, "pagination_fields_ambiguous", name))
418
+ return None
419
+ if nodes:
420
+ values[name] = nodes[0].attributes.get("value") or ""
421
+ owner = values.get("user_id")
422
+ if owner and user_id is not None and owner != user_id:
423
+ raise ProtocolChangedError("форма отзывов принадлежит другому продавцу")
424
+ if values.get("filter") and values["filter"] != normalize_review_rating(rating):
425
+ defects.append(
426
+ Defect(Severity.PAGE, "pagination_filter_mismatch", "форма содержит другую оценку")
427
+ )
428
+ return None
429
+ token = values.get("continue", "")
430
+ if not visible:
431
+ if token:
432
+ defects.append(
433
+ Defect(Severity.PAGE, "pagination_controls_conflict", "скрытая кнопка с курсором")
434
+ )
435
+ return None
436
+ if not owner or not token:
437
+ defects.append(
438
+ Defect(
439
+ Severity.PAGE, "pagination_cursor_missing", "форма не содержит пригодного курсора"
440
+ )
441
+ )
442
+ return None
443
+ return ReviewsCursor(owner, token, rating)
444
+
445
+
446
+ def parse_reviews_page(
447
+ html: str, observed_at: datetime, *, user_id: str | None = None, rating: int | None = None
448
+ ) -> ReviewsPage:
449
+ """Разбирает страницу профиля и собирает отзывы.
450
+
451
+ Args:
452
+ html (str): Тело страницы. Предполагается уже классифицированным как
453
+ пригодное: состояние сессии здесь не проверяется.
454
+ observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
455
+ разбор оставался чистым и повторяемым на сохранённом снимке.
456
+
457
+ Returns:
458
+ ReviewsPage: Отзывы вместе с полнотой и перечнем повреждений.
459
+
460
+ Raises:
461
+ ProtocolChangedError: Если разметка изменилась настолько, что читать
462
+ нечего: нет таблицы либо нет контейнера строк.
463
+ """
464
+ normalize_review_rating(rating)
465
+ tree = HTMLParser(html)
466
+ defects: list[Defect] = []
467
+
468
+ empty = tree.css(SELECTORS["reviews.empty_filtered"])
469
+ if empty:
470
+ if (
471
+ rating is None
472
+ or len(empty) != 1
473
+ or not empty[0].text(strip=True)
474
+ or len(tree.css(SELECTORS["reviews.filter"])) != 1
475
+ or any(
476
+ tree.css_first(selector) is not None
477
+ for selector in (_TABLE, _ROWS_CONTAINER, _ROW, _WRAPPER, _PAGE_FORM, _CONTINUE)
478
+ )
479
+ ):
480
+ raise ProtocolChangedError("неоднозначный признак пустой выборки отзывов")
481
+ return ReviewsPage(
482
+ completeness=Completeness.COMPLETE,
483
+ reason="empty_filtered_list",
484
+ observed_at=observed_at,
485
+ rows_total=0,
486
+ rows_accepted=0,
487
+ rows_rejected=0,
488
+ defects=(),
489
+ _entries=(),
490
+ )
491
+
492
+ if tree.css_first(_TABLE) is None:
493
+ raise ProtocolChangedError(
494
+ f"на странице профиля нет контейнера таблицы отзывов ({_TABLE}). "
495
+ "Пустой список вернуть нельзя: профиля без отзывов проект не видел, "
496
+ "и отличить его от смены разметки нечем"
497
+ )
498
+
499
+ if tree.css_first(_ROWS_CONTAINER) is None:
500
+ raise ProtocolChangedError(
501
+ f"на странице профиля нет контейнера строк ({_ROWS_CONTAINER}). "
502
+ "Без него нельзя проверить, что строки найдены все"
503
+ )
504
+
505
+ if tree.css_first(_PAGE_FORM) is None:
506
+ defects.append(
507
+ Defect(
508
+ severity=Severity.PAGE,
509
+ code="pagination_form_missing",
510
+ detail=(
511
+ f"формы догрузки ({_PAGE_FORM}) на странице нет. Она стоит за "
512
+ "таблицей отзывов на всех наблюдённых снимках; её исчезновение "
513
+ "означает, что механизм догрузки изменился"
514
+ ),
515
+ )
516
+ )
517
+
518
+ found = collect_rows(tree, _ROWS_CONTAINER, _ROW)
519
+ defects.extend(found.defects)
520
+
521
+ # Третий счёт. Обёрток столько же, сколько отзывов, и разойдись они - это
522
+ # повреждение уровня страницы, а не повод молча вернуть меньше строк.
523
+ wrappers = len(tree.css(_WRAPPER))
524
+ if wrappers != len(found.rows):
525
+ defects.append(
526
+ Defect(
527
+ severity=Severity.PAGE,
528
+ code="wrapper_count_mismatch",
529
+ detail=(
530
+ f"обёрток отзыва {wrappers}, а строк найдено {len(found.rows)}. "
531
+ "Один из двух счётчиков видит не то, что другой"
532
+ ),
533
+ )
534
+ )
535
+
536
+ entries: list[Review] = []
537
+ for index, row in enumerate(found.rows):
538
+ entry, row_defects = _parse_row(row, index)
539
+ defects.extend(row_defects)
540
+ entries.append(entry)
541
+
542
+ rating_mismatch = rating is not None and any(
543
+ entry.rating.is_observed and entry.rating.value != rating for entry in entries
544
+ )
545
+ if rating_mismatch:
546
+ defects.append(
547
+ Defect(Severity.PAGE, "review_filter_mismatch", "строки содержат другую оценку")
548
+ )
549
+
550
+ rows_total = max(len(found.rows), found.children, len(tree.css(_ROW)))
551
+ rows_accepted = len(entries)
552
+ rows_rejected = len(found.rows) - rows_accepted
553
+
554
+ if rows_total and not rows_accepted:
555
+ raise ProtocolChangedError(
556
+ f"кандидатов в отзывы {rows_total}, собрать не удалось ни одного. "
557
+ "Это изменение разметки, а не пустой список"
558
+ )
559
+
560
+ # Без положительного признака пустой выборки ноль строк не доказывает
561
+ # пустоту: класс строки мог измениться, даже если таблица ещё узнаваема.
562
+ next_cursor = _next_cursor(tree, defects, user_id, rating)
563
+ if rating_mismatch:
564
+ next_cursor = None
565
+ if not rows_total:
566
+ completeness, reason = Completeness.UNKNOWN, "empty_list_not_observed"
567
+ elif any(one.severity is Severity.PAGE for one in defects):
568
+ completeness, reason = Completeness.PARTIAL, "page_defects"
569
+ elif defects:
570
+ completeness, reason = Completeness.PARTIAL, "row_defects"
571
+ else:
572
+ completeness, reason = _totality(tree)
573
+
574
+ return ReviewsPage(
575
+ completeness=completeness,
576
+ next_cursor=next_cursor,
577
+ reason=reason,
578
+ observed_at=observed_at,
579
+ rows_total=rows_total,
580
+ rows_accepted=rows_accepted,
581
+ rows_rejected=rows_rejected,
582
+ defects=tuple(defects),
583
+ _entries=tuple(entries),
584
+ )