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/_identity.py ADDED
@@ -0,0 +1,284 @@
1
+ """Сетевая идентичность: с какого адреса и через что идут запросы.
2
+
3
+ Спецификация привязывает бюджет не к клиенту и не к аккаунту, а к сетевой
4
+ идентичности - паре из исходящего адреса и целевого хоста. Именно её видит
5
+ площадка, и именно по ней применяет ограничения.
6
+
7
+ Отсюда всё устройство этого файла.
8
+
9
+ Прокси меняет исходящий адрес, то есть даёт ДРУГУЮ идентичность. У неё своё
10
+ ведро токенов, своё состояние остывания и свой счёт ограничений. Держать один
11
+ бюджет на все прокси значило бы считать чужие запросы своими, а держать по
12
+ бюджету на клиента - наоборот, не считать своих: два клиента одного процесса
13
+ через один прокси видны площадке как один источник.
14
+
15
+ Реестр общий на процесс намеренно. Изоляция возможна, но только явная: это
16
+ решение публичного конструктора, и спецификация фиксирует его прямо -
17
+ client_default: shared_runtime.
18
+
19
+ Реакция на ограничение живёт здесь же, а не в политике повторов. Политика
20
+ решает про ОДИН запрос: повторить ли его и когда. Ограничение частоты - про
21
+ источник целиком: после него медленнее должны идти все запросы этой
22
+ идентичности, а не только тот, который получил отказ.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import threading
29
+ from dataclasses import dataclass, field
30
+ from fractions import Fraction
31
+ from time import monotonic
32
+ from typing import Final
33
+
34
+ from ._budget import Budget, _exact
35
+ from .budget import RATE_LIMIT_RESPONSE, RequestClass
36
+ from .retry import RETRY_POLICIES
37
+
38
+ __all__ = ["Identity", "IdentityRegistry", "REGISTRY", "identity_of"]
39
+
40
+ _log = logging.getLogger("funora.identity")
41
+
42
+ #: Имя идентичности при прямом соединении, без прокси.
43
+ DIRECT: Final[str] = "direct"
44
+
45
+
46
+ def identity_of(proxy: str | None, host: str) -> str:
47
+ """Собирает имя сетевой идентичности.
48
+
49
+ Имя, а не сам адрес прокси: адрес может нести пароль, а имя уходит в
50
+ журналы и в сообщения об ошибках. Поэтому берётся то, чем прокси назвали, а
51
+ если не назвали - его положение в перечне.
52
+
53
+ Args:
54
+ proxy (str | None): Имя прокси либо None при прямом соединении.
55
+ host (str): Целевой хост.
56
+
57
+ Returns:
58
+ str: Имя идентичности вида «имя@хост».
59
+ """
60
+ return f"{proxy or DIRECT}@{host}"
61
+
62
+
63
+ @dataclass
64
+ class Identity:
65
+ """Одна сетевая идентичность и её состояние.
66
+
67
+ Attributes:
68
+ name (str): Имя вида «прокси@хост».
69
+ budget (Budget): Вложенные вёдра токенов этой идентичности.
70
+ capacity_factor (float): Во сколько раз ёмкость урезана относительно
71
+ объявленной. Единица означает полную.
72
+ cooldown_until (float): До какого момента идентичность не используется,
73
+ монотонные секунды. Ноль означает, что она готова.
74
+ limits_seen (int): Сколько ограничений получено в текущем окне.
75
+ window_started_at (float): Начало окна учёта ограничений.
76
+ successes (int): Сколько успешных запросов подряд после последнего
77
+ ограничения. По ним идёт восстановление ёмкости.
78
+ """
79
+
80
+ name: str
81
+ budget: Budget = field(default_factory=Budget)
82
+ capacity_factor: float | Fraction = 1.0
83
+ cooldown_until: float | Fraction = 0.0
84
+ limits_seen: int = 0
85
+ window_started_at: float | Fraction | None = None
86
+ successes: int = 0
87
+
88
+ def is_cooling(self, now: float) -> bool:
89
+ """Сообщает, остывает ли идентичность сейчас.
90
+
91
+ Args:
92
+ now (float): Текущий момент, монотонные секунды.
93
+
94
+ Returns:
95
+ bool: True, если пользоваться ею пока нельзя.
96
+ """
97
+ return _exact(now) < _exact(self.cooldown_until)
98
+
99
+ def note_limit(self, now: float, *, retry_after_ms: int | None = None) -> None:
100
+ """Учитывает полученное ограничение частоты.
101
+
102
+ Правило объявлено спецификацией и до сих пор не применялось нигде.
103
+ Ответ 429 переводился в ошибку и уходил в политику повторов, но ёмкость
104
+ ведра при этом не менялась: следующий залп был ровно таким же, каким был
105
+ до ограничения. Это худший из возможных ответов на ограничение.
106
+
107
+ Три ступени, каждая строже предыдущей: первое ограничение вдвое режет
108
+ ёмкость и даёт остыть, второе останавливает наблюдение и автоматику,
109
+ третье переводит идентичность в состояние ограниченной.
110
+
111
+ Args:
112
+ now (float): Текущий момент, монотонные секунды.
113
+ retry_after_ms (int | None): Сколько просила подождать площадка,
114
+ если просила. Заголовок Retry-After, уже урезанный политикой.
115
+
116
+ Returns:
117
+ None
118
+ """
119
+ window_ms = RATE_LIMIT_RESPONSE.window_ms
120
+ moment = _exact(now)
121
+ if (
122
+ self.window_started_at is None
123
+ or (moment - _exact(self.window_started_at)) * 1000 > window_ms
124
+ ):
125
+ self.window_started_at = moment
126
+ self.limits_seen = 0
127
+
128
+ self.limits_seen += 1
129
+ self.successes = 0
130
+ self.capacity_factor = max(
131
+ _exact(RATE_LIMIT_RESPONSE.min_capacity_factor),
132
+ _exact(self.capacity_factor) * _exact(RATE_LIMIT_RESPONSE.capacity_multiplier),
133
+ )
134
+ # Дольшее из двух: собственное остывание и просьба площадки.
135
+ #
136
+ # Дольшее, а не своё. Заголовок Retry-After - это просьба, и ждать
137
+ # меньше просимого значит спорить с площадкой без единого довода: она
138
+ # знает свою нагрузку, а мы нет. Ждать дольше безопасно всегда.
139
+ #
140
+ # Прежде ждали только своё: площадка просила пять минут, клиент
141
+ # отступал на минуту и возвращался. Ровно поведение, из-за которого
142
+ # ограничение и переходит в блокировку.
143
+ cooldown_ms = RATE_LIMIT_RESPONSE.cooldown_ms * self.limits_seen
144
+ if retry_after_ms is not None:
145
+ # Заголовок ограничен и для общей паузы, иначе предел политики
146
+ # повтора обходится вторым ожиданием перед следующим запросом.
147
+ limit = RETRY_POLICIES["funora.transport.rate_limited"].max_retry_after_ms
148
+ cooldown_ms = max(cooldown_ms, min(max(0, retry_after_ms), limit))
149
+ self.cooldown_until = moment + Fraction(cooldown_ms, 1000)
150
+ self.budget.scale(self.capacity_factor)
151
+
152
+ # Вторая ступень. Классы monitoring и automation снимаются с очереди до
153
+ # конца остывания: первый отменяется, второй ждёт. Остаются interactive
154
+ # и poll - то, без чего клиент перестаёт быть клиентом.
155
+ #
156
+ # Ступень выражается через классы запросов и потому была невыполнима,
157
+ # пока классов не было: она так и стояла объявленной и не сделанной.
158
+ if self.limits_seen >= 2:
159
+ self.budget.suspend(
160
+ (RequestClass.MONITORING, RequestClass.AUTOMATION),
161
+ until=self.cooldown_until,
162
+ )
163
+
164
+ _log.warning(
165
+ "идентичность %s получила ограничение (%d-е в окне): ёмкость урезана "
166
+ "до %.2f от объявленной, остывание %d мс",
167
+ self.name,
168
+ self.limits_seen,
169
+ self.capacity_factor,
170
+ cooldown_ms,
171
+ )
172
+
173
+ def note_success(self) -> None:
174
+ """Учитывает успешный запрос и понемногу возвращает ёмкость.
175
+
176
+ Восстановление медленнее падения намеренно. Симметричное восстановление
177
+ даёт автоколебания: система отступает, тут же возвращается к прежней
178
+ частоте, получает ограничение снова и так по кругу.
179
+
180
+ Returns:
181
+ None
182
+ """
183
+ if self.capacity_factor >= 1.0:
184
+ return
185
+
186
+ self.successes += 1
187
+ if self.successes < RATE_LIMIT_RESPONSE.successes_per_step:
188
+ return
189
+
190
+ self.successes = 0
191
+ self.capacity_factor = min(
192
+ Fraction(1),
193
+ _exact(self.capacity_factor) * _exact(RATE_LIMIT_RESPONSE.recovery_multiplier),
194
+ )
195
+ self.budget.scale(self.capacity_factor)
196
+ _log.info(
197
+ "идентичность %s восстанавливается: ёмкость %.2f от объявленной",
198
+ self.name,
199
+ self.capacity_factor,
200
+ )
201
+
202
+
203
+ class IdentityRegistry:
204
+ """Реестр сетевых идентичностей, общий на процесс.
205
+
206
+ Общий намеренно. Два клиента одного процесса, ходящие через один и тот же
207
+ адрес, видны площадке как один источник; изолированные бюджеты позволили бы
208
+ им вдвоём превысить предел, который каждый по отдельности соблюдает.
209
+
210
+ Реестр защищён замком: клиенты могут жить в разных потоках, а ведро токенов
211
+ - это счётчик, который два потока способны уменьшить дважды из одного
212
+ значения.
213
+ """
214
+
215
+ __slots__ = ("_by_name", "_lock")
216
+
217
+ def __init__(self) -> None:
218
+ self._by_name: dict[str, Identity] = {}
219
+ self._lock = threading.Lock()
220
+
221
+ def get(self, name: str) -> Identity:
222
+ """Возвращает идентичность по имени, заводя её при первом обращении.
223
+
224
+ Args:
225
+ name (str): Имя идентичности.
226
+
227
+ Returns:
228
+ Identity: Существующая либо только что заведённая.
229
+ """
230
+ with self._lock:
231
+ identity = self._by_name.get(name)
232
+ if identity is None:
233
+ identity = Identity(name=name)
234
+ self._by_name[name] = identity
235
+ return identity
236
+
237
+ def names(self) -> tuple[str, ...]:
238
+ """Перечисляет заведённые идентичности.
239
+
240
+ Returns:
241
+ tuple[str, ...]: Имена в порядке заведения.
242
+ """
243
+ with self._lock:
244
+ return tuple(self._by_name)
245
+
246
+ def reset(self) -> None:
247
+ """Забывает все идентичности.
248
+
249
+ Нужно проверкам: реестр общий на процесс, и состояние одной проверки
250
+ протекло бы в следующую.
251
+
252
+ Returns:
253
+ None
254
+ """
255
+ with self._lock:
256
+ self._by_name.clear()
257
+
258
+ def healthy(self, names: tuple[str, ...], now: float | None = None) -> str | None:
259
+ """Выбирает идентичность, которая сейчас не остывает.
260
+
261
+ Порядок перечня уважается: первый неостывающий и берётся. Перебирать по
262
+ кругу или случайно значило бы размазывать нагрузку ровным слоем по всем
263
+ адресам - а вызывающий назвал их в том порядке, в каком хочет ими
264
+ пользоваться.
265
+
266
+ Args:
267
+ names (tuple[str, ...]): Имена идентичностей в порядке предпочтения.
268
+ now (float | None): Момент. По умолчанию текущий.
269
+
270
+ Returns:
271
+ str | None: Имя пригодной идентичности либо None, если остывают все.
272
+ """
273
+ moment = monotonic() if now is None else now
274
+ for name in names:
275
+ if not self.get(name).is_cooling(moment):
276
+ return name
277
+ return None
278
+
279
+
280
+ #: Реестр идентичностей, общий на процесс.
281
+ #:
282
+ #: Спецификация фиксирует это прямо: Client без явно переданного бюджета
283
+ #: присоединяется к общему для процесса, изоляция только явная.
284
+ REGISTRY: Final[IdentityRegistry] = IdentityRegistry()
funora/_json.py ADDED
@@ -0,0 +1,31 @@
1
+ """JSON без неоднозначных ключей и неконечных чисел."""
2
+
3
+ import json
4
+ from math import isfinite
5
+ from typing import Any
6
+
7
+
8
+ def _unique_fields(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
9
+ result: dict[str, Any] = {}
10
+ for name, value in pairs:
11
+ if name in result:
12
+ raise ValueError("повтор поля JSON")
13
+ result[name] = value
14
+ return result
15
+
16
+
17
+ def _finite_number(raw: str) -> float:
18
+ value = float(raw)
19
+ if not isfinite(value):
20
+ raise ValueError("неконечное число JSON")
21
+ return value
22
+
23
+
24
+ def load_json(body: str) -> Any:
25
+ """Читает JSON; вызывающий переводит отказ в ошибку своей границы данных."""
26
+ return json.loads(
27
+ body,
28
+ object_pairs_hook=_unique_fields,
29
+ parse_float=_finite_number,
30
+ parse_constant=_finite_number,
31
+ )
funora/_listen.py ADDED
@@ -0,0 +1,312 @@
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
+ именно я пропустил». Канал ускоряет обнаружение, а полноту по-прежнему даёт
26
+ чтение страниц.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from dataclasses import dataclass, field
33
+ from enum import Enum
34
+ from typing import Final
35
+
36
+ from ._runner import RunnerContext
37
+ from ._updates import UpdatesAnswer
38
+
39
+ __all__ = [
40
+ "CHANNEL_INTERVAL_MS",
41
+ "CHANNEL_COOLDOWN_MS",
42
+ "CHANNEL_WATCHDOG_MS",
43
+ "ChannelSignal",
44
+ "ChannelState",
45
+ "ORDERS_COUNTERS",
46
+ "CHAT_BOOKMARKS",
47
+ "classify_signal",
48
+ "seed_tags",
49
+ "wanted_objects",
50
+ ]
51
+
52
+ _log = logging.getLogger("funora.listen")
53
+
54
+ #: Вид объекта со счётчиками покупок и продаж.
55
+ #:
56
+ #: Наблюдён и в подписке, и в ответе. В ответе несёт buyer и seller целыми - то
57
+ #: есть счётчик непрочитанного получается прямо, а не выводится из расхождения
58
+ #: двух позиций разметки.
59
+ ORDERS_COUNTERS: Final[str] = "orders_counters"
60
+
61
+ #: Вид объекта со счётчиками переписки.
62
+ #:
63
+ #: Наблюдён и в подписке, и в ответе. В ответе несёт counter и message целыми
64
+ #: плюс массив order длиной в число диалогов.
65
+ CHAT_BOOKMARKS: Final[str] = "chat_bookmarks"
66
+
67
+ #: С каким промежутком опрашивать канал.
68
+ #:
69
+ #: Пять секунд - СОБСТВЕННЫЙ темп площадки, наблюдённый, а не выбранный нами:
70
+ #: spec/extraction/updates.yaml, interval_s. Опрашивать чаще - ходить к каналу
71
+ #: чаще, чем ходит его же страница, то есть выделяться темпом без выигрыша:
72
+ #: ответ всё равно соберётся не раньше, чем площадка его обновит.
73
+ CHANNEL_INTERVAL_MS: Final[int] = 5000
74
+
75
+ #: Сколько не трогать канал после того, как он ответил непонятным.
76
+ #:
77
+ #: Минута, и она не про вежливость. Отказ канала бывает разовым - сеть моргнула,
78
+ #: - и бывает состоянием: истёкшая сессия, исчерпанный предел. Различить их в
79
+ #: момент отказа нечем, потому что ни того, ни другого никто не наблюдал.
80
+ #:
81
+ #: Пробовать снова сразу означало бы стучаться в отказывающую точку четыре раза
82
+ #: в минуту всё время, пока держится состояние. Ждать долго - терять скорость
83
+ #: там, где отказ был разовым. Минута - середина, и цена ошибки в обе стороны
84
+ #: здесь одна: опрос страниц, то есть прежнее поведение.
85
+ CHANNEL_COOLDOWN_MS: Final[int] = 60_000
86
+
87
+ #: Как долго доверять тишине канала, прежде чем перечитать страницы всё равно.
88
+ #:
89
+ #: СТОРОЖЕВОЙ СРОК - ТО, ЧТО ДЕЛАЕТ ВСЮ ЗАТЕЮ БЕЗОПАСНОЙ. Канал, замолчавший не
90
+ #: потому, что менять нечего, а потому, что перестал отвечать по существу, от
91
+ #: тишины неотличим: в обоих случаях объектов в ответе нет.
92
+ #:
93
+ #: Две минуты выбраны не наугад: это ПРЕЖНИЙ потолок интервала опроса
94
+ #: (SCHEDULING.max_interval_ms). Тем самым худший случай нового поведения ровно
95
+ #: равен обычному старому: даже канал, врущий тишину постоянно, не делает
96
+ #: наблюдение медленнее, чем оно было до него.
97
+ CHANNEL_WATCHDOG_MS: Final[int] = 120_000
98
+
99
+ #: Сколько непонятных ответов подряд считать состоянием, а не случайностью.
100
+ _FAILURES_BEFORE_COOLDOWN: Final[int] = 2
101
+
102
+
103
+ class ChannelSignal(Enum):
104
+ """Что канал сказал о изменениях.
105
+
106
+ Attributes:
107
+ CHANGED: Что-то изменилось, страницы надо прочитать сейчас.
108
+ QUIET: Изменений нет, чтение страниц можно пропустить.
109
+ DEGRADED: Канал непригоден, дальше работает опрос страниц.
110
+ """
111
+
112
+ CHANGED = "changed"
113
+ QUIET = "quiet"
114
+ DEGRADED = "degraded"
115
+
116
+
117
+ @dataclass
118
+ class ChannelState:
119
+ """Состояние слушателя канала между шагами.
120
+
121
+ Attributes:
122
+ tags (dict[tuple[str, str], str]): Метки по объектам, НАКОПЛЕННЫЕ за все
123
+ ответы. Накопление обязательно: ответ несёт только изменившиеся
124
+ объекты, и метки молчавших в нём нет вовсе.
125
+ failures (int): Сколько непонятных ответов подряд.
126
+ cooling_until (float): До какого момента монотонных секунд канал не
127
+ трогать. Ноль означает, что канал доступен.
128
+ quiet_since (float | None): С какого момента канал не приносил
129
+ изменений. None означает, что тишины ещё не было.
130
+ reason (str): Отчего канал признан непригодным в последний раз.
131
+ degraded_announced (bool): Сказали ли уже вызывающему о переходе к
132
+ опросу страниц. Одно событие на один переход, а не на каждый шаг.
133
+ """
134
+
135
+ tags: dict[tuple[str, str], str] = field(default_factory=dict)
136
+ failures: int = 0
137
+ cooling_until: float = 0.0
138
+ quiet_since: float | None = None
139
+ reason: str = ""
140
+ degraded_announced: bool = False
141
+
142
+ def available(self, now: float) -> bool:
143
+ """Говорит, можно ли сейчас обращаться к каналу.
144
+
145
+ Args:
146
+ now (float): Текущий момент, монотонные секунды.
147
+
148
+ Returns:
149
+ bool: True, если остывание кончилось.
150
+ """
151
+ return now >= self.cooling_until
152
+
153
+ def note_failure(self, reason: str, now: float) -> bool:
154
+ """Записывает непонятный ответ и решает, уходить ли в остывание.
155
+
156
+ ОДИН ОТКАЗ - ЕЩЁ НЕ СОСТОЯНИЕ. Сеть моргает, и уходить с быстрого пути
157
+ на минуту из-за одного моргания значило бы терять скорость чаще, чем
158
+ нужно. Два подряд - уже похоже на состояние.
159
+
160
+ Args:
161
+ reason (str): Машиночитаемая причина.
162
+ now (float): Текущий момент, монотонные секунды.
163
+
164
+ Returns:
165
+ bool: True, если канал переведён в остывание этим отказом.
166
+ """
167
+ self.failures += 1
168
+ self.reason = reason
169
+ if self.failures < _FAILURES_BEFORE_COOLDOWN:
170
+ return False
171
+ self.cooling_until = now + CHANNEL_COOLDOWN_MS / 1000
172
+ self.quiet_since = None
173
+ return True
174
+
175
+ def note_success(self, now: float, *, changed: bool) -> None:
176
+ """Записывает понятный ответ.
177
+
178
+ Args:
179
+ now (float): Текущий момент, монотонные секунды.
180
+ changed (bool): Принёс ли ответ изменения.
181
+
182
+ Returns:
183
+ None
184
+ """
185
+ self.failures = 0
186
+ self.reason = ""
187
+ self.degraded_announced = False
188
+ self.quiet_since = None if changed else (self.quiet_since or now)
189
+
190
+ def watchdog_expired(self, now: float) -> bool:
191
+ """Говорит, что тишина канала длится дольше сторожевого срока.
192
+
193
+ Тишина здесь не доказательство отсутствия изменений: канал, перестающий
194
+ отвечать по существу, выглядит точно так же. По истечении срока страницы
195
+ читаются независимо от того, что сказал канал.
196
+
197
+ Args:
198
+ now (float): Текущий момент, монотонные секунды.
199
+
200
+ Returns:
201
+ bool: True, если пора перечитать страницы всё равно.
202
+ """
203
+ if self.quiet_since is None:
204
+ return False
205
+ return (now - self.quiet_since) * 1000 >= CHANNEL_WATCHDOG_MS
206
+
207
+
208
+ def wanted_objects(own_user_id: str) -> list[tuple[str, str]]:
209
+ """Перечисляет объекты, на которые подписывается наблюдение.
210
+
211
+ ДВА, А НЕ N. Подписаться можно и на каждый диалог порознь, но объектов в
212
+ подписке принимается десять, а обрезка молчалива: продавец с пятнадцатью
213
+ диалогами не узнал бы, что пять из них не слушаются вовсе.
214
+
215
+ Эти два объекта - глобальные счётчики аккаунта, и их ровно столько же при
216
+ одном диалоге и при тысяче. Ответ по ним говорит «в продажах что-то
217
+ изменилось» и «в переписке что-то изменилось», а что именно - выяснит
218
+ чтение страниц, которое и без канала умеет это точно.
219
+
220
+ Args:
221
+ own_user_id (str): Собственный идентификатор, он же идентификатор обоих
222
+ объектов подписки: наблюдено, что оба берут id из data-user.
223
+
224
+ Returns:
225
+ list[tuple[str, str]]: Вид и идентификатор по объекту.
226
+ """
227
+ return [(ORDERS_COUNTERS, own_user_id), (CHAT_BOOKMARKS, own_user_id)]
228
+
229
+
230
+ def seed_tags(context: RunnerContext) -> dict[tuple[str, str], str]:
231
+ """Берёт со страницы те метки, которые на ней вправду видно.
232
+
233
+ ЧТО ЗДЕСЬ НАБЛЮДЕНО, А ЧТО НЕТ, и почему это не мешает.
234
+
235
+ Метка orders_counters наблюдена: она РАВНА значению data-orders, сверено в
236
+ живой вкладке 29.08.2026.
237
+
238
+ Метка chat_bookmarks не наблюдена. Кандидат - data-bookmarks-tag того же
239
+ узла - объявлен носителем метки подписки, но в перечень сверки не входил.
240
+ Он берётся здесь, и вот почему это безопасно: НЕУГАДАННАЯ МЕТКА НЕ ЛОМАЕТ
241
+ НИЧЕГО. Площадка принимает любую, в том числе выдуманную, и отвечает на неё
242
+ всем, что изменилось, - наблюдено 30.08.2026. То есть худший исход ошибки
243
+ здесь - один лишний полный ответ на первом опросе, после которого настоящая
244
+ метка приезжает в ответе и накапливается.
245
+
246
+ Ненаблюдённого поля не берётся: у объекта без метки её место займёт
247
+ UNSEEN_TAG при сборке подписки, и это тот же самый безопасный исход.
248
+
249
+ Args:
250
+ context (RunnerContext): Что прочитано со страницы.
251
+
252
+ Returns:
253
+ dict[tuple[str, str], str]: Метки, годные для первой подписки.
254
+ """
255
+ if not context.own_user_id.is_observed:
256
+ return {}
257
+
258
+ own = context.own_user_id.value
259
+ tags: dict[tuple[str, str], str] = {}
260
+ if context.orders_tag.is_observed and context.orders_tag.value:
261
+ tags[(ORDERS_COUNTERS, own)] = context.orders_tag.value
262
+ if context.bookmarks_tag.is_observed and context.bookmarks_tag.value:
263
+ tags[(CHAT_BOOKMARKS, own)] = context.bookmarks_tag.value
264
+ return tags
265
+
266
+
267
+ def classify_signal(answer: UpdatesAnswer) -> tuple[ChannelSignal, str]:
268
+ """Решает, что ответ канала означает для наблюдения.
269
+
270
+ ПОРЯДОК ШАГОВ ВАЖЕН, и он тот же, что у разбора ответа на отправку: сперва
271
+ объявленная ошибка, потом содержимое. Ответ с непустой ошибкой и пустыми
272
+ объектами иначе прочитался бы тишиной - то есть отказ выглядел бы
273
+ подтверждением, что менять нечего.
274
+
275
+ Args:
276
+ answer (UpdatesAnswer): Разобранный ответ канала.
277
+
278
+ Returns:
279
+ tuple[ChannelSignal, str]: Сигнал и машиночитаемая причина. Причина
280
+ пуста, когда объяснять нечего.
281
+ """
282
+ if answer.error is not None:
283
+ # Что именно площадка кладёт в это поле при неудаче, не наблюдал никто:
284
+ # пустым оно наблюдалось, непустым - ни разу. Поэтому непустое читается
285
+ # как «канал непригоден», а не толкуется по содержимому.
286
+ return ChannelSignal.DEGRADED, "channel_reported_error"
287
+
288
+ if answer.is_quiet:
289
+ return ChannelSignal.QUIET, ""
290
+
291
+ return ChannelSignal.CHANGED, ""
292
+
293
+
294
+ def unexpected_shape(answer: UpdatesAnswer) -> str:
295
+ """Называет то, чего в ответе быть не должно.
296
+
297
+ Опрос идёт БЕЗ ДЕЙСТВИЯ, и площадка отвечает на такой запрос логическим, а
298
+ не объектом - наблюдено. Объект в ответе означает, что мы получили ответ на
299
+ чужой запрос либо канал устроен не так, как наблюдался.
300
+
301
+ Ответ на это не «разобрать поаккуратнее», а вернуться к опросу страниц:
302
+ страницы читаются кодом, чьё поведение установлено.
303
+
304
+ Args:
305
+ answer (UpdatesAnswer): Разобранный ответ канала.
306
+
307
+ Returns:
308
+ str: Причина непригодности либо пустая строка, если ответ ожидаемый.
309
+ """
310
+ if answer.answered_action:
311
+ return "channel_answered_an_action_we_did_not_send"
312
+ return ""