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/_signals.py ADDED
@@ -0,0 +1,375 @@
1
+ """Сравнение изменяемых значений страницы между двумя чтениями.
2
+
3
+ Зачем понадобился отдельный модуль. Структурный скелет намеренно прячет значения
4
+ атрибутов: это то, что делает его безопасным. Но ровно из-за этого по скелетам
5
+ нельзя ответить на главный вопрос спецификации - отдаёт ли площадка устойчивую
6
+ позицию, по которой можно переиграть пропущенные события. Монотонный счётчик и
7
+ хеш состояния в скелете выглядят одинаково, потому что различает их не форма, а
8
+ поведение во времени.
9
+
10
+ Как это решено. Значения извлекаются, сравниваются в памяти и уничтожаются
11
+ вместе с процессом. На диск не попадает ничего. Наружу выходит только характер
12
+ изменения - выросло, уменьшилось, изменилось, не изменилось - и величина шага
13
+ для числовых значений. Гарантия скелета при этом не ослабляется, потому что сам
14
+ формат скелета не меняется.
15
+
16
+ Белый список атрибутов задан явно. Смысл списка не в защите - защита в том, что
17
+ значения не сохраняются, - а в том, чтобы сравнение не превращалось в обход всей
18
+ страницы: сравнивать нужно кандидатов в позицию канала обновлений, а не разметку.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from enum import StrEnum
25
+ from hashlib import blake2s
26
+ from typing import Final
27
+
28
+ from selectolax.parser import HTMLParser, Node
29
+
30
+ from .extraction import ATTRIBUTES, SELECTORS
31
+
32
+ __all__ = [
33
+ "BLANK",
34
+ "ATTR_ALLOWLIST",
35
+ "Change",
36
+ "ChangeKind",
37
+ "collect",
38
+ "compare",
39
+ "format_report",
40
+ "Relations",
41
+ "relations",
42
+ "format_relations",
43
+ ]
44
+
45
+ #: Атрибуты, значения которых сравниваются между чтениями.
46
+ #:
47
+ #: Список выведен из снимков от 18.08.2026 и обоснован в docs/observations.md.
48
+ #: Это кандидаты в позицию канала обновлений и идентификаторы, по которым
49
+ #: элементы опознаются между чтениями.
50
+ ATTR_ALLOWLIST: Final[frozenset[str]] = frozenset(
51
+ {
52
+ "data-app-data",
53
+ "data-chat",
54
+ "data-orders",
55
+ "data-bookmarks-tag",
56
+ "data-user",
57
+ }
58
+ # Имена атрибутов списка диалогов берутся из объявления, а не повторяются
59
+ # здесь: повторённое имя расходится с объявленным молча, и признак
60
+ # непрочитанного перестал бы выводиться на первой же смене разметки.
61
+ | {
62
+ ATTRIBUTES["chats.contact_list.attributes.node_id"],
63
+ ATTRIBUTES["chats.contact_list.attributes.last_message_position"],
64
+ ATTRIBUTES["chats.contact_list.attributes.own_position"],
65
+ }
66
+ )
67
+
68
+ #: Длина короткого отпечатка, которым элемент опознаётся в отчёте.
69
+ _FINGERPRINT_LEN: Final[int] = 6
70
+
71
+
72
+ class ChangeKind(StrEnum):
73
+ """Характер изменения значения между двумя чтениями."""
74
+
75
+ UNCHANGED = "не изменилось"
76
+ GREW = "выросло"
77
+ SHRANK = "уменьшилось"
78
+ CHANGED = "изменилось"
79
+ APPEARED = "появилось"
80
+ DISAPPEARED = "исчезло"
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class Change:
85
+ """Изменение одного значения.
86
+
87
+ Args:
88
+ key (str): Обезличенный ключ элемента: тег, класс и отпечаток.
89
+ attr (str): Имя атрибута.
90
+ kind (ChangeKind): Характер изменения.
91
+ delta (int | None): Величина шага для числовых значений. Для нечисловых
92
+ и для отсутствующих значений равна None.
93
+ numeric (bool): Было ли значение полностью числовым.
94
+ """
95
+
96
+ key: str
97
+ attr: str
98
+ kind: ChangeKind
99
+ delta: int | None = None
100
+ numeric: bool = False
101
+
102
+
103
+ def _fingerprint(value: str) -> str:
104
+ """Строит короткий необратимый отпечаток значения.
105
+
106
+ Отпечаток нужен, чтобы опознать один и тот же элемент в двух чтениях после
107
+ того, как список переупорядочился. Позиция в списке для этого не годится:
108
+ пришедшее сообщение поднимает диалог наверх, и сравнение по индексу
109
+ сопоставило бы разные диалоги.
110
+
111
+ Args:
112
+ value (str): Исходное значение.
113
+
114
+ Returns:
115
+ str: Шестнадцатеричный отпечаток фиксированной длины.
116
+ """
117
+ return blake2s(value.encode("utf-8"), digest_size=8).hexdigest()[:_FINGERPRINT_LEN]
118
+
119
+
120
+ def _element_key(node: Node, index: int) -> str:
121
+ """Строит обезличенный ключ элемента.
122
+
123
+ Args:
124
+ node (Node): Узел документа.
125
+ index (int): Порядковый номер элемента среди однотипных. Используется
126
+ только если у элемента нет собственного идентификатора.
127
+
128
+ Returns:
129
+ str: Ключ вида ``a.contact-item#1f4c9e``, пригодный для отчёта.
130
+ """
131
+ attrs = node.attributes or {}
132
+ tag = node.tag or "?"
133
+ classes = (attrs.get("class") or "").split()
134
+ label = tag + ("." + classes[0] if classes else "")
135
+ own = attrs.get(ATTRIBUTES["chats.contact_list.attributes.node_id"]) or attrs.get("id")
136
+ mark = _fingerprint(own) if own else "i" + str(index)
137
+ return label + "#" + mark
138
+
139
+
140
+ #: Чем обозначается атрибут без значения.
141
+ #:
142
+ #: Разборщик отдаёт None и для `data-chat`, и для `data-chat=""`. Прежде такая
143
+ #: пара молча выбрасывалась, и сравнение о ней не говорило ничего: атрибут,
144
+ #: стоящий без значения в обоих чтениях, не попадал в отчёт вовсе, а появление у
145
+ #: него значения выглядело как появление самого атрибута.
146
+ #:
147
+ #: Знак выбран невозможный внутри значения: разборщики HTML заменяют нулевой
148
+ #: байт на символ замены, так что совпасть с настоящим значением он не может.
149
+ BLANK: Final[str] = "\x00"
150
+
151
+
152
+ def collect(html: str, attrs: frozenset[str] = ATTR_ALLOWLIST) -> dict[tuple[str, str], str]:
153
+ """Извлекает значения атрибутов из белого списка.
154
+
155
+ Результат существует только в памяти вызывающего кода. Записывать его на
156
+ диск нельзя: в нём настоящие значения со страницы под авторизацией.
157
+
158
+ Args:
159
+ html (str): Тело ответа.
160
+ attrs (frozenset[str]): Имена атрибутов, значения которых собираются.
161
+
162
+ Returns:
163
+ dict[tuple[str, str], str]: Отображение пары «ключ элемента, атрибут» в
164
+ значение.
165
+ """
166
+ tree = HTMLParser(html)
167
+ found: dict[tuple[str, str], str] = {}
168
+ counters: dict[str, int] = {}
169
+
170
+ for node in tree.css("*"):
171
+ node_attrs = node.attributes or {}
172
+ present = [name for name in node_attrs if name in attrs]
173
+ if not present:
174
+ continue
175
+ tag = node.tag or "?"
176
+ counters[tag] = counters.get(tag, 0) + 1
177
+ key = _element_key(node, counters[tag])
178
+ for name in present:
179
+ value = node_attrs[name]
180
+ # Атрибут без значения обозначается явно, а не выбрасывается:
181
+ # выброшенный он неотличим от отсутствующего, и сравнение назвало бы
182
+ # появление значения появлением атрибута.
183
+ found[(key, name)] = BLANK if value is None else value
184
+ return found
185
+
186
+
187
+ def compare(
188
+ before: dict[tuple[str, str], str],
189
+ after: dict[tuple[str, str], str],
190
+ ) -> list[Change]:
191
+ """Сравнивает два набора значений.
192
+
193
+ Args:
194
+ before (dict[tuple[str, str], str]): Значения первого чтения.
195
+ after (dict[tuple[str, str], str]): Значения второго чтения.
196
+
197
+ Returns:
198
+ list[Change]: Изменения, упорядоченные по ключу и имени атрибута.
199
+ """
200
+ changes: list[Change] = []
201
+ for slot in sorted(set(before) | set(after)):
202
+ key, attr = slot
203
+ old = before.get(slot)
204
+ new = after.get(slot)
205
+
206
+ if old is None:
207
+ changes.append(Change(key=key, attr=attr, kind=ChangeKind.APPEARED))
208
+ continue
209
+ if new is None:
210
+ changes.append(Change(key=key, attr=attr, kind=ChangeKind.DISAPPEARED))
211
+ continue
212
+ if old == new:
213
+ changes.append(Change(key=key, attr=attr, kind=ChangeKind.UNCHANGED))
214
+ continue
215
+
216
+ if old.isdigit() and new.isdigit():
217
+ delta = int(new) - int(old)
218
+ kind = ChangeKind.GREW if delta > 0 else ChangeKind.SHRANK
219
+ changes.append(Change(key=key, attr=attr, kind=kind, delta=abs(delta), numeric=True))
220
+ continue
221
+
222
+ changes.append(Change(key=key, attr=attr, kind=ChangeKind.CHANGED))
223
+ return changes
224
+
225
+
226
+ def format_report(changes: list[Change], *, only_changed: bool = True) -> str:
227
+ """Готовит отчёт о сравнении.
228
+
229
+ В отчёте нет значений: только характер изменения и величина шага для
230
+ числовых. Отчёт можно показывать и прикладывать к обсуждению.
231
+
232
+ Args:
233
+ changes (list[Change]): Изменения.
234
+ only_changed (bool): Показывать только изменившееся. Неизменившееся
235
+ сводится в итоговую строку.
236
+
237
+ Returns:
238
+ str: Готовый текст отчёта.
239
+ """
240
+ shown = [c for c in changes if not only_changed or c.kind is not ChangeKind.UNCHANGED]
241
+ stable = sum(1 for c in changes if c.kind is ChangeKind.UNCHANGED)
242
+
243
+ lines: list[str] = []
244
+ for change in sorted(shown, key=lambda x: (x.attr, x.key)):
245
+ tail = "" if change.delta is None else " на " + str(change.delta)
246
+ lines.append(" " + change.attr.ljust(20) + change.key.ljust(26) + str(change.kind) + tail)
247
+
248
+ if not lines:
249
+ lines.append(" изменений нет")
250
+ lines.append("")
251
+ lines.append(" всего значений: " + str(len(changes)) + ", без изменений: " + str(stable))
252
+ return "\n".join(lines)
253
+
254
+
255
+ @dataclass(frozen=True, slots=True)
256
+ class Relations:
257
+ """Соотношения между значениями внутри одного снимка.
258
+
259
+ Наружу выходят только количества. Ни одно значение атрибута в результат не
260
+ попадает, поэтому режим не ослабляет защиту, ради которой скелет прячет
261
+ значения.
262
+
263
+ Args:
264
+ contacts (int): Сколько элементов диалога найдено.
265
+ equal (int): У скольких data-node-msg совпадает с data-user-msg.
266
+ differing (int): У скольких значения различаются.
267
+ incomplete (int): У скольких одного из атрибутов нет.
268
+ unread_badge (str): Состояние счётчика непрочитанного в шапке:
269
+ ``"скрыт"``, ``"виден"`` либо ``"не найден"``.
270
+ """
271
+
272
+ contacts: int
273
+ equal: int
274
+ differing: int
275
+ incomplete: int
276
+ unread_badge: str
277
+
278
+
279
+ def relations(html: str) -> Relations:
280
+ """Считает соотношения пары позиций по всем диалогам снимка.
281
+
282
+ Режим отвечает на вопрос, который сравнением двух чтений не решается.
283
+ data-user-msg может означать «последнее прочитанное этим аккаунтом» либо
284
+ «последнее написанное этим аккаунтом», и своя отправка двигает поле при
285
+ обеих трактовках. Но по списку целиком версии расходятся: если счётчик
286
+ непрочитанного пуст, а значения расходятся у многих диалогов, то поле не
287
+ может быть отметкой прочтения.
288
+
289
+ Args:
290
+ html (str): Тело страницы списка диалогов.
291
+
292
+ Returns:
293
+ Relations: Количества и состояние счётчика непрочитанного.
294
+ """
295
+ tree = HTMLParser(html)
296
+
297
+ equal = differing = incomplete = 0
298
+ contacts = tree.css(SELECTORS["chats.contact_list.item"])
299
+ for node in contacts:
300
+ attrs = node.attributes or {}
301
+ node_msg = attrs.get(ATTRIBUTES["chats.contact_list.attributes.last_message_position"])
302
+ user_msg = attrs.get(ATTRIBUTES["chats.contact_list.attributes.own_position"])
303
+ if not node_msg or not user_msg:
304
+ incomplete += 1
305
+ elif node_msg == user_msg:
306
+ equal += 1
307
+ else:
308
+ differing += 1
309
+
310
+ badge = tree.css_first(SELECTORS["chats.unread_badge"])
311
+ if badge is None:
312
+ state = "не найден"
313
+ else:
314
+ state = "скрыт" if "hidden" in (badge.attributes.get("class") or "") else "виден"
315
+
316
+ return Relations(
317
+ contacts=len(contacts),
318
+ equal=equal,
319
+ differing=differing,
320
+ incomplete=incomplete,
321
+ unread_badge=state,
322
+ )
323
+
324
+
325
+ def format_relations(rel: Relations) -> str:
326
+ """Составляет отчёт о соотношениях и о том, что из них следует.
327
+
328
+ Толкование печатается рядом с числами намеренно. Без него отчёт выглядит
329
+ набором цифр, а решение по нему всё равно придётся принимать, и принято оно
330
+ будет по памяти.
331
+
332
+ Args:
333
+ rel (Relations): Посчитанные соотношения.
334
+
335
+ Returns:
336
+ str: Готовый к печати отчёт.
337
+ """
338
+ lines = [
339
+ f" диалогов: {rel.contacts}",
340
+ f" позиции совпадают: {rel.equal}",
341
+ f" позиции различаются: {rel.differing}",
342
+ f" атрибут отсутствует: {rel.incomplete}",
343
+ f" счётчик непрочитанного: {rel.unread_badge}",
344
+ "",
345
+ ]
346
+
347
+ if rel.contacts == 0:
348
+ lines.append(" диалогов не найдено, толковать нечего")
349
+ return "\n".join(lines)
350
+
351
+ if rel.unread_badge == "скрыт" and rel.differing > 0:
352
+ lines += [
353
+ f" Непрочитанного нет, а позиции расходятся у {rel.differing} диалогов.",
354
+ " Значит data-user-msg не отметка прочтения: будь она отметкой,",
355
+ " при пустом счётчике расхождений не было бы ни одного.",
356
+ " Остаётся трактовка «последнее написанное этим аккаунтом», и",
357
+ " признак непрочитанного придётся искать иначе.",
358
+ ]
359
+ elif rel.unread_badge == "скрыт" and rel.differing == 0:
360
+ lines += [
361
+ " Непрочитанного нет, и позиции совпадают у всех диалогов.",
362
+ " Это отвечает трактовке «последнее прочитанное». Обратная",
363
+ " трактовка потребовала бы, чтобы последнее сообщение во всех",
364
+ f" {rel.contacts} диалогах было написано вами, что неправдоподобно.",
365
+ ]
366
+ elif rel.unread_badge == "виден":
367
+ lines += [
368
+ " Непрочитанное есть. Число расхождений имеет смысл сравнить с",
369
+ " числом непрочитанных диалогов в интерфейсе: совпало - отметка",
370
+ " прочтения, расхождений заметно больше - последнее написанное.",
371
+ ]
372
+ else:
373
+ lines.append(" счётчик непрочитанного не найден, вывод сделать нельзя")
374
+
375
+ return "\n".join(lines)