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
@@ -0,0 +1,367 @@
1
+ """Дочитывание переписки назад от курсора.
2
+
3
+ ЗАПРОС ЗДЕСЬ ЗАИМСТВОВАН ЦЕЛИКОМ, и это отличает модуль от всех прочих. У
4
+ прежних заимствований чужим был ВЫВОД о действии запроса, который мы наблюдали
5
+ сами; здесь чужие и адрес, и оба имени параметров, и форма ответа. Своего
6
+ наблюдения этой точки нет ни одного: наш способ прочитать переписку - целая
7
+ страница /chat/?node=, и она отдаёт лишь то, что площадка показала сразу.
8
+
9
+ Согласия операция не спрашивает: это чтение, а правило о согласии разводит
10
+ чтение и запись нарочно. Ошибка чтения видна сразу и необратимого следа не
11
+ оставляет.
12
+
13
+ НАПРАВЛЕНИЕ ПРОВЕРЯЕТСЯ ЗДЕСЬ ЖЕ. Утверждение «курсор отдаёт сообщения СТАРШЕ
14
+ него» - чужое и наблюдением не подтверждённое. Молча отданный список выглядит
15
+ одинаково правильным в обе стороны, и непроверяемым это осталось бы навсегда,
16
+ поэтому разбор сверяет пришедшие идентификаторы с посланным курсором сам.
17
+
18
+ ОТКУДА БЕРЁТСЯ ИДЕНТИФИКАТОР СООБЩЕНИЯ. Из поля ответа, а не из разметки. На
19
+ странице он лежит в атрибуте узла сообщения, здесь же приходит отдельным числом
20
+ рядом с разметкой - и это надёжнее: атрибут может не попасть во фрагмент вовсе.
21
+ Ради этого разбор страницы переиспользуется, а идентификатор подменяется после
22
+ него; повреждение «идентификатор не найден» для такой строки снимается, потому
23
+ что найден он в другом месте.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime
31
+ from typing import Final
32
+
33
+ from selectolax.parser import HTMLParser
34
+
35
+ from ._cursor import encode_cursor
36
+ from ._observed import Observed
37
+ from ._result import Completeness, Defect, Severity
38
+ from ._thread import Message, _parse_message
39
+ from .errors import CursorIncompatibleError, IncompleteResultError, UnexpectedResponseError
40
+ from .extraction import SELECTORS
41
+
42
+ __all__ = ["CHAT_HISTORY_PATH", "HISTORY_HEADERS", "ChatHistory", "parse_history"]
43
+
44
+ #: Адрес догрузки. Заимствован; своего наблюдения нет.
45
+ CHAT_HISTORY_PATH: Final[str] = "/chat/history"
46
+
47
+ #: Заголовки запроса. Признак «спрашивает сценарий страницы» обязателен по тому
48
+ #: же чужому сообщению, что и адрес: без него площадка отвечает страницей.
49
+ HISTORY_HEADERS: Final[dict[str, str]] = {
50
+ "accept": "*/*",
51
+ "x-requested-with": "XMLHttpRequest",
52
+ }
53
+
54
+ #: Узел одного сообщения в разметке. Тот же, что на странице переписки.
55
+ _MESSAGE: Final[str] = SELECTORS["chats.message.item"]
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ChatHistory:
60
+ """Итог дочитывания переписки назад.
61
+
62
+ Attributes:
63
+ chat_id (str): Узел переписки, у которого спрашивали.
64
+ cursor_sent (str): Посланный курсор - идентификатор, от которого
65
+ просили назад. Хранится ради двух вещей: на нём стоит сверка
66
+ направления, и по нему вызывающий видит, сдвинулось ли листание.
67
+ exhausted (bool): Площадка не отдала ни одного сообщения. Это
68
+ наблюдение «начало переписки», а не сбой.
69
+ completeness (Completeness): Полнота прочитанного.
70
+ reason (str): Машиночитаемая причина, по которой полнота такова.
71
+ observed_at (datetime): Момент наблюдения.
72
+ rows_total (int): Сколько записей отдала площадка.
73
+ rows_accepted (int): Сколько разобрано.
74
+ rows_rejected (int): Сколько отброшено.
75
+ defects (tuple[Defect, ...]): Обнаруженные повреждения.
76
+ """
77
+
78
+ chat_id: str
79
+ cursor_sent: str
80
+ exhausted: bool
81
+ completeness: Completeness
82
+ reason: str
83
+ observed_at: datetime
84
+ rows_total: int
85
+ rows_accepted: int
86
+ rows_rejected: int
87
+ defects: tuple[Defect, ...]
88
+ _messages: tuple[Message, ...] = field(repr=False, default=())
89
+
90
+ @property
91
+ def next_cursor(self) -> str | None:
92
+ """Позиция следующей страницы; повреждённая страница не даёт её пропустить.
93
+
94
+ None означает подтверждённый конец. Неполное чтение вызывает
95
+ IncompleteResultError: продолжение от части строк потеряло бы остальные.
96
+ """
97
+ messages = self.messages()
98
+ if self.exhausted:
99
+ return None
100
+ oldest = min(int(message.message_id.value) for message in messages)
101
+ return encode_cursor("chats.history_before", self.chat_id, str(oldest))
102
+
103
+ def messages(self, *, accept_incomplete: bool = False) -> tuple[Message, ...]:
104
+ """Возвращает догруженные сообщения.
105
+
106
+ Args:
107
+ accept_incomplete (bool): Признание готовности работать с неполным
108
+ результатом.
109
+
110
+ Returns:
111
+ tuple[Message, ...]: Сообщения в порядке ответа площадки.
112
+
113
+ Raises:
114
+ IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
115
+ не признана. Пропущенное при листании сообщение не вернётся:
116
+ следующий шаг возьмёт курсор от того, что дошло.
117
+ """
118
+ if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
119
+ raise IncompleteResultError(
120
+ f"переписка догружена не полностью ({self.completeness}, причина: "
121
+ f"{self.reason}), собрано {self.rows_accepted} из {self.rows_total}. "
122
+ "Передайте accept_incomplete=True, если готовы работать с неполными "
123
+ "данными"
124
+ )
125
+ return self._messages
126
+
127
+ def __len__(self) -> int:
128
+ """Возвращает число догруженных сообщений.
129
+
130
+ Returns:
131
+ int: Число догруженных сообщений.
132
+ """
133
+ return len(self._messages)
134
+
135
+
136
+ def _entries(payload: object) -> list[object]:
137
+ """Достаёт перечень сообщений из ответа, отвергая всё непонятное.
138
+
139
+ ОТСУТСТВИЕ КЛЮЧА И ПУСТОЙ ПЕРЕЧЕНЬ РАЗВЕДЕНЫ НАРОЧНО. Соседняя реализация
140
+ отвечает пустым списком на оба случая, и «переписка кончилась» становится у
141
+ неё неотличимо от «ответ изменился». Пустой перечень - положительный признак
142
+ конца; отсутствие ключа не признак ничего.
143
+
144
+ Args:
145
+ payload (object): Разобранное тело ответа.
146
+
147
+ Returns:
148
+ list[object]: Записи сообщений в порядке ответа.
149
+
150
+ Raises:
151
+ UnexpectedResponseError: Если ответ не той формы.
152
+ """
153
+ if not isinstance(payload, dict):
154
+ raise UnexpectedResponseError(
155
+ f"ответ догрузки ожидался объектом, получен {type(payload).__name__}"
156
+ )
157
+ chat = payload.get("chat")
158
+ if not isinstance(chat, dict):
159
+ raise UnexpectedResponseError(
160
+ "в ответе догрузки нет объекта chat. Пустой переписки это не "
161
+ "означает: у кончившейся переписки chat есть, а messages пуст"
162
+ )
163
+ if "messages" not in chat:
164
+ raise UnexpectedResponseError(
165
+ "в ответе догрузки нет ключа chat.messages. Отсутствие ключа - не "
166
+ "признак конца переписки, а признак того, что ответ стал другим"
167
+ )
168
+ entries = chat["messages"]
169
+ if not isinstance(entries, list):
170
+ raise UnexpectedResponseError(
171
+ f"chat.messages ожидалось перечнем, получено {type(entries).__name__}"
172
+ )
173
+ return entries
174
+
175
+
176
+ def valid_message_position(value: str) -> bool:
177
+ """Проверяет позицию до int(): isdigit допускает непреобразуемые символы."""
178
+ return len(value) <= 128 and value.isascii() and value.isdecimal()
179
+
180
+
181
+ def _identifier(entry: object, index: int) -> tuple[str, Defect | None]:
182
+ """Читает идентификатор записи.
183
+
184
+ Args:
185
+ entry (object): Запись сообщения из ответа.
186
+ index (int): Порядковый номер записи.
187
+
188
+ Returns:
189
+ tuple[str, Defect | None]: Идентификатор и повреждение, если он непригоден.
190
+ """
191
+ raw = entry.get("id") if isinstance(entry, dict) else None
192
+ # Признак строгий: у идентификатора сообщения только цифры. Строка «12a»
193
+ # сравнивается с курсором как угодно, и сверка направления на ней молчит.
194
+ if isinstance(raw, int):
195
+ text = str(raw) if 0 <= raw < 10**128 else ""
196
+ else:
197
+ text = raw if isinstance(raw, str) else ""
198
+ if not valid_message_position(text):
199
+ return "", Defect(
200
+ severity=Severity.ROW,
201
+ code="identifier_unreadable",
202
+ detail=(
203
+ f"идентификатор записи не число: {text!r}. Сверить направление "
204
+ "листания по нему нельзя, и запись отброшена"
205
+ ),
206
+ row_index=index,
207
+ field_name="message_id",
208
+ )
209
+ return text, None
210
+
211
+
212
+ def _message(entry: object, identifier: str, index: int, host: str) -> tuple[Message, list[Defect]]:
213
+ """Разбирает разметку одной записи тем же разбором, что и страницу.
214
+
215
+ УЗЕЛ БЕРЁТСЯ ГИБКО. Приходит ли во фрагменте обёртка сообщения или только
216
+ его нутро - у нас не наблюдалось, а разбор работает в обоих случаях: если
217
+ обёртки нет, указатели ищутся от корня фрагмента и находят то же самое.
218
+
219
+ Args:
220
+ entry (object): Запись сообщения из ответа.
221
+ identifier (str): Идентификатор, прочитанный из поля ответа.
222
+ index (int): Порядковый номер записи.
223
+ host (str): Хост площадки, для отделения внешних ссылок.
224
+
225
+ Returns:
226
+ tuple[Message, list[Defect]]: Сообщение и перечень повреждений.
227
+
228
+ Raises:
229
+ UnexpectedResponseError: Если разметки в записи нет.
230
+ """
231
+ markup = entry.get("html") if isinstance(entry, dict) else None
232
+ if not isinstance(markup, str):
233
+ raise UnexpectedResponseError(
234
+ f"у записи {index} нет разметки в поле html: {type(markup).__name__}"
235
+ )
236
+
237
+ tree = HTMLParser(markup)
238
+ node = tree.css_first(_MESSAGE) or tree.body
239
+ if node is None:
240
+ raise UnexpectedResponseError(f"разметку записи {index} не удалось разобрать вовсе")
241
+
242
+ parsed, defects = _parse_message(node, index, host)
243
+ # Идентификатор подменяется прочитанным из поля ответа: оно надёжнее
244
+ # атрибута, которого во фрагменте может не быть вовсе. Повреждение о его
245
+ # отсутствии снимается тем же движением - иначе разбор жалуется на то, что
246
+ # у него есть.
247
+ parsed = dataclasses.replace(parsed, message_id=Observed.present(identifier))
248
+ defects = [
249
+ one
250
+ for one in defects
251
+ if not (one.code == "field_not_observed" and one.field_name == "message_id")
252
+ ]
253
+ return parsed, defects
254
+
255
+
256
+ def _check_direction(collected: list[tuple[str, Message]], cursor: str) -> None:
257
+ """Сверяет направление листания с посланным курсором.
258
+
259
+ ЗДЕСЬ ПРОВЕРЯЕТСЯ ЧУЖОЕ УТВЕРЖДЕНИЕ, и ради него всё и затевалось. Что
260
+ курсор отдаёт сообщения СТАРШЕ него, известно от независимой реализации того
261
+ же протокола и нами не наблюдалось.
262
+
263
+ Args:
264
+ collected (list[tuple[str, Message]]): Разобранные записи.
265
+ cursor (str): Посланный курсор.
266
+
267
+ Returns:
268
+ None
269
+
270
+ Raises:
271
+ CursorIncompatibleError: Если площадка вернула не ту сторону.
272
+ """
273
+ edge = int(cursor)
274
+ wrong = sorted(int(one) for one, _ in collected if int(one) >= edge)
275
+ if wrong:
276
+ raise CursorIncompatibleError(
277
+ f"догрузка от курсора {cursor} вернула {len(wrong)} сообщений НЕ СТАРШЕ "
278
+ f"него (например {wrong[0]}). Направление листания у этой точки взято "
279
+ "у независимой реализации того же протокола и нами не наблюдалось; "
280
+ "расхождение означает, что взято оно неверно либо площадка его "
281
+ "изменила. Ничего не возвращаем: список, отданный молча, выглядит "
282
+ "одинаково правильным в обе стороны"
283
+ )
284
+
285
+
286
+ def parse_history(
287
+ payload: object,
288
+ *,
289
+ chat_id: str,
290
+ cursor: str,
291
+ observed_at: datetime,
292
+ host: str = "funpay.com",
293
+ ) -> ChatHistory:
294
+ """Разбирает ответ догрузки переписки.
295
+
296
+ Args:
297
+ payload (object): Разобранное тело ответа.
298
+ chat_id (str): Узел переписки, у которого спрашивали.
299
+ cursor (str): Посланный курсор.
300
+ observed_at (datetime): Момент наблюдения.
301
+ host (str): Хост площадки, для отделения внешних ссылок.
302
+
303
+ Returns:
304
+ ChatHistory: Сообщения вместе с полнотой и признаком конца.
305
+
306
+ Raises:
307
+ UnexpectedResponseError: Если ответ не той формы.
308
+ CursorIncompatibleError: Если площадка вернула не ту сторону листания.
309
+ """
310
+ entries = _entries(payload)
311
+ defects: list[Defect] = []
312
+ collected: list[tuple[str, Message]] = []
313
+
314
+ for index, entry in enumerate(entries):
315
+ identifier, broken = _identifier(entry, index)
316
+ if broken is not None:
317
+ defects.append(broken)
318
+ continue
319
+ parsed, row_defects = _message(entry, identifier, index, host)
320
+ defects.extend(row_defects)
321
+ collected.append((identifier, parsed))
322
+
323
+ # Сверка направления идёт ДО объявления полноты и до всего прочего: список
324
+ # не с той стороны непригоден целиком, и полнота у него бессмысленна.
325
+ _check_direction(collected, cursor)
326
+
327
+ seen = [one for one, _ in collected]
328
+ if len(set(seen)) != len(seen):
329
+ defects.append(
330
+ Defect(
331
+ severity=Severity.PAGE,
332
+ code="duplicate_identifiers",
333
+ detail=(
334
+ f"записей с прочитанным идентификатором {len(seen)}, "
335
+ f"различимых {len(set(seen))}: листание встанет на месте"
336
+ ),
337
+ field_name="message_id",
338
+ )
339
+ )
340
+
341
+ rows_total = len(entries)
342
+ rows_accepted = len(collected)
343
+
344
+ if not rows_total:
345
+ # Пустой ответ - ПОЛНОЕ наблюдение, а не неизвестность. Площадке нечего
346
+ # отдать назад, и это ровно тот ответ, ради которого спрашивали.
347
+ completeness, reason = Completeness.COMPLETE, "history_exhausted"
348
+ elif not defects:
349
+ completeness, reason = Completeness.COMPLETE, "all_messages_parsed"
350
+ elif any(one.severity is Severity.PAGE for one in defects):
351
+ completeness, reason = Completeness.PARTIAL, "page_defects"
352
+ else:
353
+ completeness, reason = Completeness.PARTIAL, "row_defects"
354
+
355
+ return ChatHistory(
356
+ chat_id=chat_id,
357
+ cursor_sent=cursor,
358
+ exhausted=not rows_total,
359
+ completeness=completeness,
360
+ reason=reason,
361
+ observed_at=observed_at,
362
+ rows_total=rows_total,
363
+ rows_accepted=rows_accepted,
364
+ rows_rejected=rows_total - rows_accepted,
365
+ defects=tuple(defects),
366
+ _messages=tuple(one for _, one in collected),
367
+ )