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/_chats.py ADDED
@@ -0,0 +1,392 @@
1
+ """Разбор списка диалогов со страницы /chat/.
2
+
3
+ Модуль чистый по тем же причинам, что и разбор заказов: результат обязан
4
+ повторяться на сохранённом снимке, иначе снимки бесполезны как способ заметить
5
+ изменение вёрстки.
6
+
7
+ Отличий от списка заказов три, и каждое стоит назвать.
8
+
9
+ Позиции непрозрачны. Атрибуты data-node-msg и data-user-msg наблюдаются как
10
+ строки и сравниваются только на равенство. Наблюдения показали, что это счётчик,
11
+ общий для площадки, а не для диалога, поэтому разность двух значений одного
12
+ диалога измеряет сообщения всей площадки, а не пропущенные здесь. Число,
13
+ полученное вычитанием, выглядит осмысленным и означает не то, что кажется, - это
14
+ хуже явной ошибки, и потому арифметика над ними запрещена.
15
+
16
+ Признак непрочитанного выведен, а не наблюдён. Расхождения пары при непрочитанном
17
+ диалоге мы не видели ни разу: во всех снимках счётчик непрочитанного скрыт и
18
+ позиции совпадают у всех диалогов снимка. Отсюда пометка inferred и
19
+ обязанность вызывающего считаться с тем, что вывод неверен.
20
+
21
+ Список ограничен и разметки дочитывания не имеет. Диалог, вытесненный за границу,
22
+ исчезает из наблюдения целиком, поэтому полнота списка объявляется честно, а не
23
+ считается полной по факту успешного разбора.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime
31
+ from typing import Final
32
+
33
+ from selectolax.parser import HTMLParser, Node
34
+
35
+ from ._extract import attribute as attribute_of
36
+ from ._observed import Confidence, Observed
37
+ from ._result import Completeness, Defect, Severity, collect_rows
38
+ from .errors import IncompleteResultError, ProtocolChangedError
39
+ from .extraction import ATTRIBUTES, SELECTORS
40
+
41
+ __all__ = ["ChatListEntry", "ChatsPage", "parse_chats_page"]
42
+
43
+ #: Внешний блок переписки. Присутствует и при выбранном диалоге, и без него.
44
+ _WIDGET: Final[str] = SELECTORS["chats.widget"]
45
+
46
+ #: Непосредственный контейнер строк. Второй, независимый от класса строки признак.
47
+ _ROWS_CONTAINER: Final[str] = SELECTORS["chats.list"]
48
+
49
+ #: Селектор строки диалога.
50
+ _ROW: Final[str] = SELECTORS["chats.contact_list.item"]
51
+
52
+ #: Счётчик непрочитанного в шапке.
53
+ _UNREAD_BADGE: Final[str] = SELECTORS["chats.unread_badge"]
54
+
55
+ #: Идентификатор диалога в адресе строки.
56
+ _NODE_IN_HREF: Final[re.Pattern[str]] = re.compile(r"[?&]node=([^&#]+)")
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class ChatListEntry:
61
+ """Запись диалога из списка.
62
+
63
+ Attributes:
64
+ node_id (str): Идентификатор диалога.
65
+ href (Observed[str]): Адрес переписки. Ненаблюдён, когда строка
66
+ опознана по атрибуту с идентификатором, а ссылки в ней нет.
67
+ row_index (int): Порядковый номер строки, с нуля.
68
+ last_message_position (Observed[str]): Позиция последнего сообщения в
69
+ диалоге. Непрозрачна: сравнивать можно только на равенство.
70
+ own_position (Observed[str]): Позиция, до которой аккаунт прочитал.
71
+ Смысл поля выведен, а не наблюдён.
72
+ unread (Observed[bool]): Признак непрочитанного. Выведен из расхождения
73
+ позиций и потому помечен как inferred.
74
+ counterparty_name (Observed[str]): Имя собеседника, текст.
75
+ preview_text (Observed[str]): Начало последнего сообщения, текст.
76
+ time_text (Observed[str]): Время последнего сообщения, текст. Точного
77
+ момента страница не даёт.
78
+ """
79
+
80
+ node_id: str
81
+ href: Observed[str]
82
+ row_index: int
83
+ last_message_position: Observed[str]
84
+ own_position: Observed[str]
85
+ unread: Observed[bool]
86
+ counterparty_name: Observed[str]
87
+ preview_text: Observed[str]
88
+ time_text: Observed[str]
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class ChatsPage:
93
+ """Результат чтения списка диалогов.
94
+
95
+ Записи получают через :meth:`rows` по той же причине, что и в списке
96
+ заказов: открытый список делает неполноту незаметной.
97
+
98
+ Attributes:
99
+ completeness (Completeness): Полнота прочитанного.
100
+ reason (str): Машиночитаемая причина, по которой полнота такова.
101
+ observed_at (datetime): Момент наблюдения.
102
+ rows_total (int): Сколько кандидатов в строки нашлось.
103
+ rows_accepted (int): Сколько записей собрано.
104
+ rows_rejected (int): Сколько строк отброшено.
105
+ unread_badge_visible (Observed[bool]): Виден ли счётчик непрочитанного.
106
+ Наблюдается структурно и не зависит от трактовки позиций.
107
+ defects (tuple[Defect, ...]): Обнаруженные повреждения.
108
+ """
109
+
110
+ completeness: Completeness
111
+ reason: str
112
+ observed_at: datetime
113
+ rows_total: int
114
+ rows_accepted: int
115
+ rows_rejected: int
116
+ unread_badge_visible: Observed[bool]
117
+ defects: tuple[Defect, ...]
118
+ _entries: tuple[ChatListEntry, ...] = field(repr=False, default=())
119
+
120
+ def rows(self, *, accept_incomplete: bool = False) -> tuple[ChatListEntry, ...]:
121
+ """Возвращает собранные записи.
122
+
123
+ Args:
124
+ accept_incomplete (bool): Признание готовности работать с неполным
125
+ результатом.
126
+
127
+ Returns:
128
+ tuple[ChatListEntry, ...]: Записи в порядке появления на странице.
129
+
130
+ Raises:
131
+ IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
132
+ не признана.
133
+ """
134
+ if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
135
+ raise IncompleteResultError(
136
+ f"результат неполон ({self.completeness}, причина: {self.reason}), "
137
+ f"собрано {self.rows_accepted} из {self.rows_total}, "
138
+ f"повреждений {len(self.defects)}. Передайте accept_incomplete=True, "
139
+ "если готовы работать с неполными данными"
140
+ )
141
+ return self._entries
142
+
143
+ def __len__(self) -> int:
144
+ """Возвращает число собранных записей.
145
+
146
+ Returns:
147
+ int: Число собранных записей.
148
+ """
149
+ return len(self._entries)
150
+
151
+
152
+ def _text(node: Node | None, name: str) -> Observed[str]:
153
+ """Извлекает текст узла как наблюдаемое значение.
154
+
155
+ Args:
156
+ node (Node | None): Узел или None.
157
+ name (str): Имя поля для причины отсутствия.
158
+
159
+ Returns:
160
+ Observed[str]: Наблюдение.
161
+ """
162
+ if node is None:
163
+ return Observed.missing(f"selector_no_match:{name}")
164
+ value = " ".join((node.text() or "").split())
165
+ return Observed.present(value) if value else Observed.empty("")
166
+
167
+
168
+ def _position(row: Node, attribute: str) -> Observed[str]:
169
+ """Извлекает непрозрачную позицию из атрибута строки.
170
+
171
+ Args:
172
+ row (Node): Узел строки диалога.
173
+ attribute (str): Имя атрибута.
174
+
175
+ Returns:
176
+ Observed[str]: Значение как строка. Ведущие нули сохраняются, ширина не
177
+ проверяется: наблюдённые десять знаков - факт о сегодняшнем дне, а не
178
+ правило, и переход площадки на одиннадцать не должен ломать разбор.
179
+ """
180
+ return attribute_of(row, attribute, attribute)
181
+
182
+
183
+ def _parse_row(row: Node, index: int) -> tuple[ChatListEntry | None, list[Defect]]:
184
+ """Разбирает одну строку диалога.
185
+
186
+ Args:
187
+ row (Node): Узел строки.
188
+ index (int): Порядковый номер строки.
189
+
190
+ Returns:
191
+ tuple[ChatListEntry | None, list[Defect]]: Запись либо None, если строка
192
+ непригодна, и перечень повреждений.
193
+ """
194
+ defects: list[Defect] = []
195
+
196
+ node_id = (
197
+ (row.attributes or {}).get(ATTRIBUTES["chats.contact_list.attributes.node_id"]) or ""
198
+ ).strip()
199
+ # Адрес наблюдаемый, а не голая строка. Идентификатор берётся из атрибута,
200
+ # если он есть, и строка со ссылкой принимается наравне со строкой без неё -
201
+ # то есть отсутствие адреса не отбрасывает запись. Голая строка отдала бы
202
+ # пустую, неотличимую от адреса, который площадка вправду вернула пустым.
203
+ href = attribute_of(row, "href", "href")
204
+ if not node_id:
205
+ match = _NODE_IN_HREF.search(href.get(""))
206
+ node_id = match.group(1) if match else ""
207
+
208
+ if not node_id:
209
+ defects.append(
210
+ Defect(
211
+ severity=Severity.ROW,
212
+ code="node_id_not_extractable",
213
+ detail="у строки нет ни атрибута с идентификатором, ни адреса с ним",
214
+ row_index=index,
215
+ )
216
+ )
217
+ return None, defects
218
+
219
+ last = _position(row, ATTRIBUTES["chats.contact_list.attributes.last_message_position"])
220
+ own = _position(row, ATTRIBUTES["chats.contact_list.attributes.own_position"])
221
+
222
+ if last.is_observed and own.is_observed:
223
+ # Правило выведено, а не наблюдено: расхождения пары при непрочитанном
224
+ # диалоге мы не видели ни разу. Пометка inferred обязывает вызывающего
225
+ # считаться с тем, что вывод неверен.
226
+ unread: Observed[bool] = Observed.present(last.value != own.value, Confidence.INFERRED)
227
+ else:
228
+ unread = Observed.missing("positions_incomplete")
229
+
230
+ entry = ChatListEntry(
231
+ node_id=node_id,
232
+ href=href,
233
+ row_index=index,
234
+ last_message_position=last,
235
+ own_position=own,
236
+ unread=unread,
237
+ counterparty_name=_text(
238
+ row.css_first(SELECTORS["chats.contact_list.fields.counterparty_name"]),
239
+ "counterparty_name",
240
+ ),
241
+ preview_text=_text(
242
+ row.css_first(SELECTORS["chats.contact_list.fields.preview_text"]), "preview_text"
243
+ ),
244
+ time_text=_text(
245
+ row.css_first(SELECTORS["chats.contact_list.fields.time_text"]), "time_text"
246
+ ),
247
+ )
248
+
249
+ for name in (
250
+ "last_message_position",
251
+ "own_position",
252
+ "counterparty_name",
253
+ "preview_text",
254
+ "time_text",
255
+ ):
256
+ if not getattr(entry, name).is_observed:
257
+ defects.append(
258
+ Defect(
259
+ severity=Severity.FIELD,
260
+ code="field_not_observed",
261
+ detail="поле строки не найдено там, где ожидалось",
262
+ row_index=index,
263
+ field_name=name,
264
+ )
265
+ )
266
+
267
+ return entry, defects
268
+
269
+
270
+ #: Поля, отсутствие которых во всех строках означает поломку страницы.
271
+ _PAGE_LEVEL_FIELDS: Final[tuple[str, ...]] = (
272
+ "last_message_position",
273
+ "own_position",
274
+ "counterparty_name",
275
+ # Превью стояло вне обоих перечней без единого слова о причине. Соседнее
276
+ # время в них было, и порча его давала partial и полсотни повреждений, а та
277
+ # же порча превью - complete и ноль: разбор объявлял страницу целой, потеряв
278
+ # поле у всех пятидесяти строк.
279
+ "preview_text",
280
+ "time_text",
281
+ )
282
+
283
+
284
+ def parse_chats_page(html: str, *, observed_at: datetime) -> ChatsPage:
285
+ """Разбирает страницу списка диалогов.
286
+
287
+ Args:
288
+ html (str): Тело страницы, уже признанное пригодным.
289
+ observed_at (datetime): Момент наблюдения.
290
+
291
+ Returns:
292
+ ChatsPage: Записи вместе с полнотой и перечнем повреждений.
293
+
294
+ Raises:
295
+ ProtocolChangedError: Если разметка изменилась настолько, что читать
296
+ нечего.
297
+ """
298
+ tree = HTMLParser(html)
299
+ defects: list[Defect] = []
300
+
301
+ if tree.css_first(_WIDGET) is None:
302
+ raise ProtocolChangedError(
303
+ f"на странице нет блока переписки ({_WIDGET}). Пустой список "
304
+ "вернуть нельзя: он неотличим от отсутствия диалогов"
305
+ )
306
+
307
+ if tree.css_first(_ROWS_CONTAINER) is None:
308
+ raise ProtocolChangedError(
309
+ f"на странице нет контейнера строк ({_ROWS_CONTAINER}). Без него "
310
+ "нельзя проверить, что строки найдены все"
311
+ )
312
+
313
+ found = collect_rows(tree, _ROWS_CONTAINER, _ROW)
314
+ defects.extend(found.defects)
315
+
316
+ entries: list[ChatListEntry] = []
317
+ for index, row in enumerate(found.rows):
318
+ entry, row_defects = _parse_row(row, index)
319
+ defects.extend(row_defects)
320
+ if entry is not None:
321
+ entries.append(entry)
322
+
323
+ rows_total = max(len(found.rows), found.children, len(tree.css(_ROW)))
324
+ rows_accepted = len(entries)
325
+ rows_rejected = len(found.rows) - rows_accepted
326
+
327
+ if rows_total and not rows_accepted:
328
+ raise ProtocolChangedError(
329
+ f"кандидатов в строки {rows_total}, собрать не удалось ни одной. "
330
+ "Это изменение разметки, а не пустой список"
331
+ )
332
+
333
+ # Дубли идентификаторов - повреждение уровня страницы. Курсор наблюдения
334
+ # хранит их множеством либо словарём, и два одинаковых схлопываются в один:
335
+ # число записей молча расходится с числом строк, а вызывающий получает
336
+ # список, в котором позиций меньше, чем было на странице.
337
+ #
338
+ # Проверка стала возможна только после того, как формат снимков научился
339
+ # различать идентификаторы: прежде маска схлопывала их сама, и проверка
340
+ # срабатывала бы на каждой фикстуре.
341
+ ids = [entry.node_id for entry in entries]
342
+ if len(set(ids)) != len(ids):
343
+ defects.append(
344
+ Defect(
345
+ severity=Severity.PAGE,
346
+ code="duplicate_identifiers",
347
+ detail=(
348
+ f"строк {len(ids)}, различимых идентификаторов {len(set(ids))}: "
349
+ "часть записей схлопнется в курсоре"
350
+ ),
351
+ field_name="node_id",
352
+ )
353
+ )
354
+
355
+ for name in _PAGE_LEVEL_FIELDS:
356
+ if entries and all(not getattr(entry, name).is_observed for entry in entries):
357
+ defects.append(
358
+ Defect(
359
+ severity=Severity.PAGE,
360
+ code="field_missing_in_all_rows",
361
+ detail=f"поле {name} отсутствует во всех собранных строках",
362
+ field_name=name,
363
+ )
364
+ )
365
+
366
+ badge = tree.css_first(_UNREAD_BADGE)
367
+ if badge is None:
368
+ unread_badge: Observed[bool] = Observed.missing("selector_no_match:unread_badge")
369
+ else:
370
+ hidden = "hidden" in ((badge.attributes or {}).get("class") or "").split()
371
+ unread_badge = Observed.present(not hidden)
372
+
373
+ if not rows_total:
374
+ completeness, reason = Completeness.UNKNOWN, "empty_list_not_observed"
375
+ elif not defects:
376
+ completeness, reason = Completeness.COMPLETE, "all_rows_parsed"
377
+ elif any(d.severity is Severity.PAGE for d in defects):
378
+ completeness, reason = Completeness.PARTIAL, "page_defects"
379
+ else:
380
+ completeness, reason = Completeness.PARTIAL, "row_defects"
381
+
382
+ return ChatsPage(
383
+ completeness=completeness,
384
+ reason=reason,
385
+ observed_at=observed_at,
386
+ rows_total=rows_total,
387
+ rows_accepted=rows_accepted,
388
+ rows_rejected=rows_rejected,
389
+ unread_badge_visible=unread_badge,
390
+ defects=tuple(defects),
391
+ _entries=tuple(entries),
392
+ )