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/_thread.py ADDED
@@ -0,0 +1,574 @@
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
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime
29
+ from enum import StrEnum
30
+ from typing import Final
31
+
32
+ from selectolax.parser import HTMLParser, Node
33
+
34
+ from ._extract import attribute
35
+ from ._host import host_of, same_host
36
+ from ._observed import Observed
37
+ from ._result import Completeness, Defect, Severity, collect_rows
38
+ from .errors import IncompleteResultError, ProtocolChangedError
39
+ from .extraction import SELECTOR_GROUPS, SELECTORS
40
+
41
+ __all__ = ["Origin", "Message", "Thread", "parse_thread"]
42
+
43
+ #: Контейнер сообщений.
44
+ _LIST: Final[str] = SELECTORS["chats.message.container"]
45
+
46
+ #: Селектор сообщения.
47
+ _MESSAGE: Final[str] = SELECTORS["chats.message.item"]
48
+
49
+ #: Тело сообщения.
50
+ _BODY: Final[str] = SELECTORS["chats.message.fields.body"]
51
+
52
+ #: Обёртка предупреждения внутри тела. Селектор обязан включать тело: класс
53
+ #: alert встречается на страницах и вне переписки, где к происхождению сообщения
54
+ #: отношения не имеет.
55
+ _ALERT: Final[str] = SELECTOR_GROUPS["chats.system_message.markers"][1]
56
+
57
+ #: Ссылка на профиль автора.
58
+ _AUTHOR_LINK: Final[str] = SELECTORS["chats.message.fields.author_link"]
59
+
60
+ #: Текст сообщения.
61
+ _TEXT: Final[str] = SELECTORS["chats.message.fields.text"]
62
+
63
+ #: Дата сообщения.
64
+ _DATE: Final[str] = SELECTORS["chats.message.fields.time_text"]
65
+
66
+ #: Имя автора.
67
+ #:
68
+ #: Берётся из ссылки на профиль, а не из содержащего её узла. Узел содержит ещё
69
+ #: ярлык роли и дату, и текст его целиком склеивал всё вместе: значение выходило
70
+ #: вида «имя, ярлык, время» - причём на неизменённом снимке, безо всякой порчи
71
+ #: разметки.
72
+ #:
73
+ #: У системного сообщения ссылки нет, и имя честно оказывается ненаблюдённым.
74
+ #: Подставлять туда ярлык роли было бы удобно и неверно: ярлык говорит, кем
75
+ #: сообщение отправлено, а не кем подписано, и спецификация прямо называет автора
76
+ #: отсутствующим у сообщений площадки.
77
+ _AUTHOR_NAME: Final[str] = _AUTHOR_LINK
78
+
79
+
80
+ class Origin(StrEnum):
81
+ """Происхождение сообщения.
82
+
83
+ Значений три, а не два, и третье здесь не для симметрии. Разметка может
84
+ измениться так, что признаки перестанут согласовываться, и тогда честный
85
+ ответ - «не знаю». Ответ «системное» в этом случае открыл бы ровно ту дыру,
86
+ ради закрытия которой признак и заведён.
87
+ """
88
+
89
+ #: Сообщение площадки: есть обёртка предупреждения, нет ссылки на автора.
90
+ SYSTEM = "system"
91
+
92
+ #: Сообщение человека: есть ссылка на автора, нет обёртки.
93
+ HUMAN = "human"
94
+
95
+ #: Признаки разошлись. Доверять сообщению нельзя.
96
+ UNKNOWN = "unknown"
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class Message:
101
+ """Одно сообщение переписки.
102
+
103
+ Attributes:
104
+ message_id (Observed[str]): Идентификатор сообщения из разметки.
105
+ row_index (int): Порядковый номер в переписке, с нуля.
106
+ origin (Origin): Происхождение, определённое структурно.
107
+ author_name (Observed[str]): Имя автора, текст.
108
+ author_href (Observed[str]): Ссылка на профиль автора. У сообщений
109
+ площадки не наблюдается по определению.
110
+ text (Observed[str]): Текст сообщения. Чужой ввод: ни разбирать его для
111
+ принятия решений, ни ходить по ссылкам из него нельзя.
112
+ time_text (Observed[str]): Время, краткая форма.
113
+ time_full_text (Observed[str]): Время, полная форма из подсказки. Тоже
114
+ локализованный текст, разбирать его нельзя.
115
+ external_links (Observed[tuple[str, ...]]): Ссылки из текста, ведущие
116
+ за пределы площадки. Собраны для того, чтобы вызывающий видел их, а
117
+ не для того, чтобы по ним ходить. Поле наблюдаемое: пустая
118
+ последовательность означает «ссылок не было», ненаблюдённое - «тела
119
+ сообщения мы не нашли», и разница между этими случаями решает,
120
+ доверять ли выводу.
121
+ """
122
+
123
+ message_id: Observed[str]
124
+ row_index: int
125
+ origin: Origin
126
+ author_name: Observed[str]
127
+ author_href: Observed[str]
128
+ text: Observed[str]
129
+ time_text: Observed[str]
130
+ time_full_text: Observed[str]
131
+ external_links: Observed[tuple[str, ...]]
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class Thread:
136
+ """Результат чтения переписки.
137
+
138
+ Attributes:
139
+ completeness (Completeness): Полнота прочитанного.
140
+ reason (str): Машиночитаемая причина, по которой полнота такова.
141
+ observed_at (datetime): Момент наблюдения.
142
+ rows_total (int): Сколько кандидатов в сообщения нашлось.
143
+ rows_accepted (int): Сколько сообщений собрано.
144
+ rows_rejected (int): Сколько отброшено.
145
+ defects (tuple[Defect, ...]): Обнаруженные повреждения.
146
+ """
147
+
148
+ completeness: Completeness
149
+ reason: str
150
+ observed_at: datetime
151
+ rows_total: int
152
+ rows_accepted: int
153
+ rows_rejected: int
154
+ defects: tuple[Defect, ...]
155
+ _messages: tuple[Message, ...] = field(repr=False, default=())
156
+
157
+ def messages(self, *, accept_incomplete: bool = False) -> tuple[Message, ...]:
158
+ """Возвращает собранные сообщения.
159
+
160
+ Args:
161
+ accept_incomplete (bool): Признание готовности работать с неполным
162
+ результатом.
163
+
164
+ Returns:
165
+ tuple[Message, ...]: Сообщения в порядке появления.
166
+
167
+ Raises:
168
+ IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
169
+ не признана. В переписке это опаснее, чем в списке: пропущенное
170
+ сообщение выглядит как ненаписанное.
171
+ """
172
+ if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
173
+ raise IncompleteResultError(
174
+ f"переписка прочитана не полностью ({self.completeness}, причина: "
175
+ f"{self.reason}), собрано {self.rows_accepted} из {self.rows_total}. "
176
+ "Передайте accept_incomplete=True, если готовы работать с неполными "
177
+ "данными"
178
+ )
179
+ return self._messages
180
+
181
+ def __len__(self) -> int:
182
+ """Возвращает число собранных сообщений.
183
+
184
+ Returns:
185
+ int: Число собранных сообщений.
186
+ """
187
+ return len(self._messages)
188
+
189
+
190
+ def _text(node: Node | None, name: str) -> Observed[str]:
191
+ """Извлекает текст узла как наблюдаемое значение.
192
+
193
+ Args:
194
+ node (Node | None): Узел или None.
195
+ name (str): Имя поля для причины отсутствия.
196
+
197
+ Returns:
198
+ Observed[str]: Наблюдение.
199
+ """
200
+ if node is None:
201
+ return Observed.missing(f"selector_no_match:{name}")
202
+ value = " ".join((node.text() or "").split())
203
+ return Observed.present(value) if value else Observed.empty("")
204
+
205
+
206
+ def _origin(message: Node) -> Origin:
207
+ """Определяет происхождение сообщения по разметке.
208
+
209
+ Требуются оба признака сразу, и это правило с закрытым отказом. Достаточно
210
+ было бы одного, если бы разметка не менялась; она меняется.
211
+
212
+ Args:
213
+ message (Node): Узел сообщения.
214
+
215
+ Returns:
216
+ Origin: Происхождение. UNKNOWN, если признаки разошлись.
217
+ """
218
+ has_alert = message.css_first(_ALERT) is not None
219
+ has_author_link = message.css_first(_AUTHOR_LINK) is not None
220
+
221
+ if has_alert and not has_author_link:
222
+ return Origin.SYSTEM
223
+ if has_author_link and not has_alert:
224
+ return Origin.HUMAN
225
+ return Origin.UNKNOWN
226
+
227
+
228
+ def _external_links(message: Node, host: str) -> Observed[tuple[str, ...]]:
229
+ """Собирает ссылки из текста, ведущие за пределы площадки.
230
+
231
+ Ссылки собираются, чтобы вызывающий их видел. Ходить по ним нельзя: их пишет
232
+ собеседник, и переход означал бы, что содержимое переписки управляет
233
+ поведением клиента.
234
+
235
+ Поле наблюдаемое, а не голой последовательностью. Разница здесь та же, что и
236
+ у остальных полей: пустая последовательность означает «ссылок не было», а
237
+ ненаблюдённое - «тела сообщения мы не нашли». Прежде эти два случая
238
+ совпадали, и переименование класса тела давало ноль ссылок при полноте
239
+ complete и нуле повреждений - то есть неотличимо от сообщения без ссылок.
240
+
241
+ Args:
242
+ message (Node): Узел сообщения.
243
+ host (str): Хост площадки.
244
+
245
+ Returns:
246
+ Observed[tuple[str, ...]]: Адреса, ведущие на другие хосты, либо
247
+ ненаблюдённое значение, если тело сообщения не найдено.
248
+ """
249
+ body = message.css_first(_TEXT)
250
+ if body is None:
251
+ return Observed.missing("selector_no_match:external_links")
252
+
253
+ found: list[str] = []
254
+ for link in body.css("a[href]"):
255
+ href = ((link.attributes or {}).get("href") or "").strip()
256
+ if not href:
257
+ continue
258
+ # Пустой хост - это не чужой хост. Относительная ссылка, якорь, mailto и
259
+ # javascript хоста не имеют вовсе, и объявлять их внешними значило бы
260
+ # выдавать за адрес другой площадки то, что адресом другой площадки не
261
+ # является. Условие взято из _skeleton.mask_path, где оно с самого начала
262
+ # написано верно: разошедшиеся копии одного правила - ровно то, из-за
263
+ # чего заводился _host.py.
264
+ if not host_of(href):
265
+ continue
266
+ # Сравнение подстрокой здесь стояло раньше и выглядело работающим.
267
+ # Адрес funpay.com.evil.example содержит имя площадки и проходил такую
268
+ # проверку - то есть ссылка на подставной сайт числилась своей и в
269
+ # перечень внешних не попадала.
270
+ if not same_host(href, host):
271
+ found.append(href)
272
+ return Observed.present(tuple(found)) if found else Observed.empty(())
273
+
274
+
275
+ def _parse_message(message: Node, index: int, host: str) -> tuple[Message, list[Defect]]:
276
+ """Разбирает одно сообщение.
277
+
278
+ Args:
279
+ message (Node): Узел сообщения.
280
+ index (int): Порядковый номер в переписке.
281
+ host (str): Хост площадки, для отделения внешних ссылок.
282
+
283
+ Returns:
284
+ tuple[Message, list[Defect]]: Сообщение и перечень повреждений.
285
+ """
286
+ defects: list[Defect] = []
287
+ origin = _origin(message)
288
+
289
+ if origin is Origin.UNKNOWN:
290
+ defects.append(
291
+ Defect(
292
+ severity=Severity.ROW,
293
+ code="origin_indeterminate",
294
+ detail=(
295
+ "признаки происхождения разошлись: обёртка предупреждения и "
296
+ "ссылка на автора присутствуют либо отсутствуют одновременно"
297
+ ),
298
+ row_index=index,
299
+ )
300
+ )
301
+
302
+ raw_id = ((message.attributes or {}).get("id") or "").strip()
303
+ author_link = message.css_first(_AUTHOR_LINK)
304
+
305
+ entry = Message(
306
+ message_id=(
307
+ Observed.present(raw_id) if raw_id else Observed.missing("attribute_absent:id")
308
+ ),
309
+ row_index=index,
310
+ origin=origin,
311
+ author_name=_text(message.css_first(_AUTHOR_NAME), "author_name"),
312
+ author_href=attribute(author_link, "href", "author_href"),
313
+ text=_text(message.css_first(_TEXT), "text"),
314
+ time_text=_text(message.css_first(_DATE), "time_text"),
315
+ time_full_text=_title(message.css_first(_DATE)),
316
+ external_links=_external_links(message, host),
317
+ )
318
+
319
+ for name in _CHECKED_FIELDS:
320
+ if not getattr(entry, name).is_observed:
321
+ defects.append(
322
+ Defect(
323
+ severity=Severity.FIELD,
324
+ code="field_not_observed",
325
+ detail="поле сообщения не найдено там, где ожидалось",
326
+ row_index=index,
327
+ field_name=name,
328
+ )
329
+ )
330
+
331
+ return entry, defects
332
+
333
+
334
+ #: Поля, отсутствие которых у сообщения - повреждение уровня поля.
335
+ #:
336
+ #: Перечень узкий намеренно. Имя и время автора помечены в схеме как возможно
337
+ #: ненаблюдаемые - у системного сообщения автора нет вовсе, и в снимке шесть
338
+ #: таких из одиннадцати. Повреждение на каждое из них было бы ложной тревогой,
339
+ #: то есть шумом, за которым перестают следить.
340
+ _CHECKED_FIELDS: Final[tuple[str, ...]] = ("message_id", "text")
341
+
342
+ #: Поля, отсутствие которых во ВСЕХ сообщениях означает поломку разметки.
343
+ #:
344
+ #: Здесь перечень шире. Одно сообщение без автора - обычное дело, все сообщения
345
+ #: без автора - изменившаяся вёрстка, и разница между этими случаями видна
346
+ #: только по странице целиком. Прежде не было ни того перечня, ни этого:
347
+ #: переименование класса имени, даты или тела давало complete и ноль
348
+ #: повреждений, тогда как у соседних разборов та же порча даёт partial.
349
+ _PAGE_LEVEL_FIELDS: Final[tuple[str, ...]] = (
350
+ "message_id",
351
+ "text",
352
+ "author_name",
353
+ "time_text",
354
+ )
355
+
356
+
357
+ def _title(node: Node | None) -> Observed[str]:
358
+ """Извлекает подсказку с полной формой времени.
359
+
360
+ Args:
361
+ node (Node | None): Узел даты или None.
362
+
363
+ Returns:
364
+ Observed[str]: Значение подсказки. На странице заказов подсказки нет
365
+ вовсе, поэтому её отсутствие здесь - наблюдение, а не поломка.
366
+ """
367
+ if node is None:
368
+ return Observed.missing("selector_no_match:time_full_text")
369
+ raw = ((node.attributes or {}).get("title") or "").strip()
370
+ return Observed.present(raw) if raw else Observed.missing("attribute_absent:title")
371
+
372
+
373
+ def _shape_defects(tree: HTMLParser) -> list[Defect]:
374
+ """Проверяет, что дерево сообщений имеет наблюдённую форму.
375
+
376
+ Проверка нужна против одной угрозы: текст сообщения, попавший в разметку как
377
+ разметка. Закрой отправитель ровно столько элементов, сколько нужно, и
378
+ следом открой поддельное сообщение с обёрткой предупреждения - разбор
379
+ прочтёт его как сообщение площадки, а бот выдачи товара примет за
380
+ уведомление об оплате.
381
+
382
+ Что проверка закрывает и чего не закрывает, надо назвать точно, иначе она
383
+ даёт ложное спокойствие.
384
+
385
+ Закрывает всякую несбалансированную попытку. Закрыто меньше, чем нужно, -
386
+ поддельное сообщение оказывается внутри настоящего. Больше - оказывается вне
387
+ контейнера. Оба случая здесь и ловятся.
388
+
389
+ НЕ закрывает попытку с точным числом. Закрыв ровно столько элементов,
390
+ сколько лежит между текстом и контейнером, отправитель получает поддельное
391
+ сообщение, которое является законным прямым потомком контейнера и от
392
+ настоящего структурно неотличимо. Отличить его нечем в принципе: следа
393
+ вставки в разобранном дереве не остаётся.
394
+
395
+ Отсюда две вещи. Первая: проверка поднимает цену - отправителю нужно знать
396
+ точную глубину вёрстки, а она меняется при любой правке шаблона. Вторая, и
397
+ она важнее: последним рубежом остаётся правило, записанное в docstring
398
+ модуля, - **происхождение system не является подтверждением оплаты**. Ни
399
+ одна проверка формы этого правила не заменяет.
400
+
401
+ Наблюдалось при этом, что площадка текст сообщения экранирует: по скелету
402
+ это непроверяемо (класс p покрывает и угловые скобки), но косвенно на это
403
+ указывает разметка ссылок в сообщениях - она построена площадкой из простого
404
+ текста, а не сохранена как есть.
405
+
406
+ Args:
407
+ tree (HTMLParser): Разобранный документ.
408
+
409
+ Returns:
410
+ list[Defect]: Повреждения уровня страницы. Пустой перечень, если форма
411
+ совпадает с наблюдённой.
412
+ """
413
+ defects: list[Defect] = []
414
+ items = tree.css(_MESSAGE)
415
+
416
+ # Считается число совпадений внутри узла, а не сравнение узлов между собой:
417
+ # selectolax отдаёт новую обёртку на каждое обращение, и проверка по
418
+ # тождеству срабатывала бы на неизменённой разметке. Совпадение ровно одно -
419
+ # сам узел; больше одного означает вложенность.
420
+ inside_message = sum(1 for node in items if len(node.css(_MESSAGE)) > 1)
421
+ if inside_message:
422
+ defects.append(
423
+ Defect(
424
+ severity=Severity.PAGE,
425
+ code="message_nested_in_message",
426
+ detail=(
427
+ f"сообщений, вложенных в другое сообщение: {inside_message}. "
428
+ "В наблюдённой разметке такого не бывает, и вложенность "
429
+ "означает либо смену вёрстки, либо разметку внутри текста"
430
+ ),
431
+ )
432
+ )
433
+
434
+ inside_text = sum(1 for node in tree.css(_TEXT) if node.css_first(_MESSAGE) is not None)
435
+ if inside_text:
436
+ defects.append(
437
+ Defect(
438
+ severity=Severity.PAGE,
439
+ code="message_inside_message_text",
440
+ detail=(
441
+ f"сообщений внутри текста другого сообщения: {inside_text}. "
442
+ "Текст пишет собеседник, и разметка в нём означает, что он "
443
+ "управляет разбором"
444
+ ),
445
+ )
446
+ )
447
+
448
+ stray = sum(
449
+ 1
450
+ for node in items
451
+ if node.parent is None
452
+ or "chat-message-list" not in ((node.parent.attributes or {}).get("class") or "")
453
+ )
454
+ if stray:
455
+ defects.append(
456
+ Defect(
457
+ severity=Severity.PAGE,
458
+ code="message_outside_the_list",
459
+ detail=(
460
+ f"сообщений не на своём месте в дереве: {stray}. В наблюдённой "
461
+ "разметке каждое сообщение - прямой потомок контейнера"
462
+ ),
463
+ )
464
+ )
465
+
466
+ return defects
467
+
468
+
469
+ def parse_thread(html: str, *, observed_at: datetime, host: str = "funpay.com") -> Thread:
470
+ """Разбирает переписку.
471
+
472
+ Args:
473
+ html (str): Тело страницы, уже признанное пригодным.
474
+ observed_at (datetime): Момент наблюдения.
475
+ host (str): Хост площадки, для отделения внешних ссылок в тексте.
476
+
477
+ Returns:
478
+ Thread: Сообщения вместе с полнотой и перечнем повреждений.
479
+
480
+ Raises:
481
+ ProtocolChangedError: Если контейнера сообщений нет либо кандидаты были,
482
+ а собрать не удалось ни одного.
483
+ """
484
+ tree = HTMLParser(html)
485
+ defects: list[Defect] = []
486
+
487
+ if tree.css_first(_LIST) is None:
488
+ raise ProtocolChangedError(
489
+ f"на странице нет контейнера сообщений ({_LIST}). Пустую переписку "
490
+ "вернуть нельзя: она неотличима от несуществующей"
491
+ )
492
+
493
+ found = collect_rows(tree, _LIST, _MESSAGE)
494
+ defects.extend(found.defects)
495
+
496
+ entries: list[Message] = []
497
+ for index, node in enumerate(found.rows):
498
+ entry, message_defects = _parse_message(node, index, host)
499
+ defects.extend(message_defects)
500
+ entries.append(entry)
501
+
502
+ rows_total = max(len(found.rows), found.children, len(tree.css(_MESSAGE)))
503
+ rows_accepted = len(entries)
504
+
505
+ if rows_total and not rows_accepted:
506
+ raise ProtocolChangedError(
507
+ f"кандидатов в сообщения {rows_total}, собрать не удалось ни одного. "
508
+ "Это изменение разметки, а не пустая переписка"
509
+ )
510
+
511
+ defects.extend(_shape_defects(tree))
512
+
513
+ # Идентификаторы сообщений хранятся множеством: курсор переписки - это набор
514
+ # уже виденных. Два одинаковых схлопываются в один, и второе сообщение
515
+ # объявляется виденным, не будучи доставленным ни разу.
516
+ #
517
+ # Для списков заказов и диалогов такая проверка уже стоит, а здесь её не
518
+ # было, и цена ошибки тут выше: непрочитанное сообщение покупателя.
519
+ seen = [entry.message_id.value for entry in entries if entry.message_id.is_observed]
520
+ if len(set(seen)) != len(seen):
521
+ defects.append(
522
+ Defect(
523
+ severity=Severity.PAGE,
524
+ code="duplicate_identifiers",
525
+ detail=(
526
+ f"сообщений с прочитанным идентификатором {len(seen)}, "
527
+ f"различимых {len(set(seen))}: часть событий не породится никогда"
528
+ ),
529
+ field_name="message_id",
530
+ )
531
+ )
532
+
533
+ for name in _PAGE_LEVEL_FIELDS:
534
+ if entries and all(not getattr(entry, name).is_observed for entry in entries):
535
+ defects.append(
536
+ Defect(
537
+ severity=Severity.PAGE,
538
+ code="field_missing_in_all_rows",
539
+ detail=f"поле {name} отсутствует у всех собранных сообщений",
540
+ field_name=name,
541
+ )
542
+ )
543
+
544
+ if entries and all(m.origin is Origin.UNKNOWN for m in entries):
545
+ defects.append(
546
+ Defect(
547
+ severity=Severity.PAGE,
548
+ code="origin_indeterminate_in_all_messages",
549
+ detail=(
550
+ "происхождение не определяется ни у одного сообщения: "
551
+ "разметка изменилась, и различать площадку и собеседника нечем"
552
+ ),
553
+ )
554
+ )
555
+
556
+ if not rows_total:
557
+ completeness, reason = Completeness.UNKNOWN, "empty_thread_not_observed"
558
+ elif not defects:
559
+ completeness, reason = Completeness.COMPLETE, "all_messages_parsed"
560
+ elif any(d.severity is Severity.PAGE for d in defects):
561
+ completeness, reason = Completeness.PARTIAL, "page_defects"
562
+ else:
563
+ completeness, reason = Completeness.PARTIAL, "row_defects"
564
+
565
+ return Thread(
566
+ completeness=completeness,
567
+ reason=reason,
568
+ observed_at=observed_at,
569
+ rows_total=rows_total,
570
+ rows_accepted=rows_accepted,
571
+ rows_rejected=rows_total - rows_accepted,
572
+ defects=tuple(defects),
573
+ _messages=tuple(entries),
574
+ )