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/_price_audit.py ADDED
@@ -0,0 +1,281 @@
1
+ """Журнал правок цены: что стояло у лота до того, как его тронул бот.
2
+
3
+ ЗАЧЕМ. Контракт требует у lots.update_price аудита before_state, и требование
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
28
+ from typing import Any, Final
29
+
30
+ from .errors import StateSchemaIncompatibleError
31
+
32
+ __all__ = [
33
+ "PriceChange",
34
+ "PriceAudit",
35
+ "DEFAULT_JOURNAL_LIMIT",
36
+ "UNSAFE_PRICE_CHANGES_WITHOUT_AUDIT",
37
+ ]
38
+
39
+ #: Имя послабления и имя отметки в состоянии здоровья. Одно на оба места
40
+ #: нарочно: два имени одного послабления расходятся молча.
41
+ UNSAFE_PRICE_CHANGES_WITHOUT_AUDIT: Final[str] = "unsafe_price_changes_without_audit"
42
+
43
+ #: Сколько правок журнал держит сверх первых записей о каждом лоте.
44
+ #:
45
+ #: Число выбрано не измерением, а прикидкой: пятьсот записей - это сотня
46
+ #: килобайт в файле состояния и заведомо больше, чем успевает набежать между
47
+ #: двумя взглядами человека на журнал.
48
+ DEFAULT_JOURNAL_LIMIT: Final[int] = 500
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class PriceChange:
53
+ """Запись о правке цены.
54
+
55
+ Attributes:
56
+ offer_id (str): Предложение, которому меняли цену.
57
+ node_id (str): Раздел, в котором оно лежит.
58
+ price_before (str): Цена, стоявшая в поле до правки.
59
+ price_after (str): Цена, которую отправили.
60
+ revision_before (str): Отпечаток лота до правки.
61
+ at_ms (int): Момент записи по стенным часам. Момент ЗАПИСИ, а не
62
+ подтверждения: запись идёт впереди сохранения, и подтверждения на
63
+ этот момент ещё нет.
64
+ """
65
+
66
+ offer_id: str
67
+ node_id: str
68
+ price_before: str
69
+ price_after: str
70
+ revision_before: str
71
+ at_ms: int
72
+
73
+
74
+ class PriceAudit:
75
+ """Журнал правок цены.
76
+
77
+ Args:
78
+ limit (int): Сколько записей держать сверх первой записи о каждом лоте.
79
+ Ноль и отрицательное означают «не вытеснять».
80
+ """
81
+
82
+ __slots__ = ("_first", "_journal", "_dropped", "_limit", "durable")
83
+
84
+ def __init__(self, limit: int = DEFAULT_JOURNAL_LIMIT) -> None:
85
+ #: Переживает ли журнал перезапуск. Ставится движком, когда файл
86
+ #: состояния есть либо когда вызывающий назвал послабление вслух.
87
+ #: Ложь означает отказ правки цены, а не молчаливую правку без следа.
88
+ self.durable: bool = False
89
+ #: Первая правка каждого лота. Не вытесняется никогда.
90
+ self._first: dict[str, PriceChange] = {}
91
+ #: Все правки по порядку, включая первые.
92
+ self._journal: list[PriceChange] = []
93
+ #: Сколько записей вытеснено пределом.
94
+ self._dropped: int = 0
95
+ self._limit = limit
96
+
97
+ def record(self, change: PriceChange) -> None:
98
+ """Записывает правку.
99
+
100
+ Аргументы:
101
+ change (PriceChange): Что и на что меняли.
102
+
103
+ Возвращает:
104
+ None
105
+ """
106
+ self._first.setdefault(change.offer_id, change)
107
+ self._journal.append(change)
108
+ if self._limit > 0:
109
+ while len(self._journal) > self._limit:
110
+ self._journal.pop(0)
111
+ self._dropped += 1
112
+
113
+ def original(self, offer_id: str) -> PriceChange | None:
114
+ """Возвращает первую известную правку лота.
115
+
116
+ Она и отвечает на вопрос «как было до бота»: промежуточные цены ставил
117
+ тот же бот, а эта стояла до него.
118
+
119
+ Аргументы:
120
+ offer_id (str): Предложение.
121
+
122
+ Возвращает:
123
+ PriceChange | None: Первая запись либо None, если лот не трогали.
124
+ """
125
+ return self._first.get(offer_id)
126
+
127
+ def history(self, offer_id: str | None = None) -> tuple[PriceChange, ...]:
128
+ """Возвращает записи журнала по порядку.
129
+
130
+ Аргументы:
131
+ offer_id (str | None): Отобрать по предложению либо None - все.
132
+
133
+ Возвращает:
134
+ tuple[PriceChange, ...]: Записи от старой к новой.
135
+ """
136
+ if offer_id is None:
137
+ return tuple(self._journal)
138
+ return tuple(one for one in self._journal if one.offer_id == offer_id)
139
+
140
+ @property
141
+ def dropped(self) -> int:
142
+ """Сколько записей вытеснено пределом.
143
+
144
+ Возвращает:
145
+ int: Число вытесненных. Ноль означает, что вытеснения не было.
146
+ """
147
+ return self._dropped
148
+
149
+ def snapshot(self) -> dict[str, Any]:
150
+ """Отдаёт состояние обычными значениями для файла состояния.
151
+
152
+ Возвращает:
153
+ dict[str, Any]: Состояние, пригодное для записи в файл.
154
+ """
155
+ return {
156
+ "journal": [_flatten(one) for one in self._journal],
157
+ "first": [_flatten(one) for one in self._first.values()],
158
+ "dropped": self._dropped,
159
+ }
160
+
161
+ def restore(self, payload: dict[str, Any], *, merge: bool = False) -> None:
162
+ """Восстанавливает состояние из файла.
163
+
164
+ Проверяется целиком до изменения живого журнала. Повреждённые записи
165
+ отклоняются: пропуск мог бы выдать промежуточную цену за исходную.
166
+
167
+ Аргументы:
168
+ payload (dict[str, Any]): Прочитанное из файла состояния.
169
+ merge (bool): Добавить записи текущего сеанса после сохранённых.
170
+ Первые цены с диска имеют приоритет; порядок не зависит от
171
+ перевода стенных часов.
172
+
173
+ Возвращает:
174
+ None
175
+
176
+ Raises:
177
+ StateSchemaIncompatibleError: Если раздел или запись повреждены.
178
+ """
179
+ if not isinstance(payload, dict):
180
+ raise StateSchemaIncompatibleError("раздел price_audit обязан быть объектом")
181
+ journal = _records(payload.get("journal", []))
182
+ first: dict[str, PriceChange] = {}
183
+ for one in _records(payload.get("first", [])):
184
+ if one.offer_id in first:
185
+ raise StateSchemaIncompatibleError("повтор первой цены лота в price_audit")
186
+ first[one.offer_id] = one
187
+ # Записи журнала тоже кандидаты в первые: файл мог быть записан прежней
188
+ # редакцией, у которой раздела first не было вовсе, и терять из-за
189
+ # этого «как было до бота» нельзя.
190
+ for one in journal:
191
+ if "first" in payload and one.offer_id not in first:
192
+ raise StateSchemaIncompatibleError("в price_audit отсутствует первая цена лота")
193
+ first.setdefault(one.offer_id, one)
194
+
195
+ dropped = payload.get("dropped", 0)
196
+ # Логическое исключается отдельно: истина в Python - это единица, и
197
+ # счётчик True прочитался бы как одна вытесненная запись.
198
+ if isinstance(dropped, bool) or not isinstance(dropped, int) or dropped < 0:
199
+ raise StateSchemaIncompatibleError("неверный счётчик вытеснения price_audit")
200
+
201
+ if merge:
202
+ journal.extend(self._journal)
203
+ for offer_id, one in self._first.items():
204
+ first.setdefault(offer_id, one)
205
+ dropped += self._dropped
206
+ if self._limit > 0 and len(journal) > self._limit:
207
+ dropped += len(journal) - self._limit
208
+ journal = journal[-self._limit :]
209
+
210
+ self._journal = journal
211
+ self._first = first
212
+ self._dropped = dropped
213
+
214
+ def __len__(self) -> int:
215
+ """Возвращает число записей в журнале.
216
+
217
+ Возвращает:
218
+ int: Сколько правок хранится. Вытесненные не считаются.
219
+ """
220
+ return len(self._journal)
221
+
222
+
223
+ def _flatten(change: PriceChange) -> dict[str, Any]:
224
+ """Раскладывает запись обычными значениями.
225
+
226
+ Аргументы:
227
+ change (PriceChange): Запись.
228
+
229
+ Возвращает:
230
+ dict[str, Any]: Поля записи.
231
+ """
232
+ return {
233
+ "offer_id": change.offer_id,
234
+ "node_id": change.node_id,
235
+ "price_before": change.price_before,
236
+ "price_after": change.price_after,
237
+ "revision_before": change.revision_before,
238
+ "at_ms": change.at_ms,
239
+ }
240
+
241
+
242
+ def _records(raw: Any) -> list[PriceChange]:
243
+ """Проверяет и собирает все записи без приведения и потери полей.
244
+
245
+ Аргументы:
246
+ raw (Any): Прочитанное из файла.
247
+
248
+ Возвращает:
249
+ list[PriceChange]: Пригодные записи по порядку.
250
+ """
251
+ if not isinstance(raw, list):
252
+ raise StateSchemaIncompatibleError("записи price_audit обязаны быть списком")
253
+
254
+ out: list[PriceChange] = []
255
+ for one in raw:
256
+ if not isinstance(one, dict):
257
+ raise StateSchemaIncompatibleError("запись price_audit обязана быть объектом")
258
+
259
+ offer_id = one.get("offer_id")
260
+ if not isinstance(offer_id, str) or not offer_id.strip():
261
+ raise StateSchemaIncompatibleError("неверный offer_id в price_audit")
262
+
263
+ for field in ("node_id", "price_before", "price_after", "revision_before"):
264
+ if not isinstance(one.get(field), str):
265
+ raise StateSchemaIncompatibleError(f"неверное поле {field} в price_audit")
266
+
267
+ at_ms = one.get("at_ms")
268
+ if isinstance(at_ms, bool) or not isinstance(at_ms, int):
269
+ raise StateSchemaIncompatibleError("неверное время записи price_audit")
270
+
271
+ out.append(
272
+ PriceChange(
273
+ offer_id=offer_id,
274
+ node_id=one["node_id"],
275
+ price_before=one["price_before"],
276
+ price_after=one["price_after"],
277
+ revision_before=one["revision_before"],
278
+ at_ms=at_ms,
279
+ )
280
+ )
281
+ return out
funora/_proxies.py ADDED
@@ -0,0 +1,232 @@
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
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from dataclasses import dataclass
30
+ from time import monotonic
31
+ from typing import Final
32
+
33
+ from ._identity import REGISTRY, IdentityRegistry, identity_of
34
+ from .errors import ConfigurationError
35
+
36
+ __all__ = ["Proxy", "ProxyPool", "DEFAULT_ACCOUNT"]
37
+
38
+ #: Имя аккаунта, под которым клиент выбирает идентичность до того, как
39
+ #: наблюдение назвало настоящее.
40
+ #:
41
+ #: Клиент заводится раньше, чем становится известен аккаунт: секрет у него
42
+ #: есть, а идентификатор аккаунта читается со страницы. Привязка при первом
43
+ #: же наблюдении перезаписывается настоящим именем.
44
+ DEFAULT_ACCOUNT: Final[str] = "self"
45
+
46
+ _log = logging.getLogger("funora.proxies")
47
+
48
+ #: Схемы, через которые ходить нельзя.
49
+ #:
50
+ #: Секрет уходит в заголовке каждого запроса, и прокси видит весь трафик. Схема
51
+ #: без шифрования до прокси означает, что ключ читает любой на пути до него -
52
+ #: то есть прокси, поставленный ради приватности, её и отменяет.
53
+ _INSECURE_SCHEMES: Final[frozenset[str]] = frozenset({"http"})
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Proxy:
58
+ """Один прокси из перечня.
59
+
60
+ Attributes:
61
+ name (str): Имя для журналов и для имени идентичности. Адрес сюда не
62
+ попадает намеренно: он может нести пароль, а имя уходит в журналы.
63
+ url (str): Адрес прокси в форме, которую понимает транспорт.
64
+ """
65
+
66
+ name: str
67
+ url: str
68
+
69
+
70
+ class ProxyPool:
71
+ """Перечень прокси и выбор пригодного.
72
+
73
+ Args:
74
+ proxies (tuple[Proxy, ...]): Прокси в порядке предпочтения. Пустой
75
+ перечень означает прямое соединение.
76
+ host (str): Целевой хост. Входит в имя идентичности: один и тот же
77
+ прокси к разным хостам - разные идентичности, потому что
78
+ ограничения применяет хост.
79
+ registry (IdentityRegistry): Реестр идентичностей. По умолчанию общий
80
+ на процесс.
81
+
82
+ Raises:
83
+ ConfigurationError: Если два прокси названы одинаково либо адрес
84
+ небезопасен.
85
+ """
86
+
87
+ __slots__ = ("_by_account", "_host", "_proxies", "_registry")
88
+
89
+ def __init__(
90
+ self,
91
+ proxies: tuple[Proxy, ...] = (),
92
+ *,
93
+ host: str,
94
+ registry: IdentityRegistry | None = None,
95
+ ) -> None:
96
+ names = [proxy.name for proxy in proxies]
97
+ if len(set(names)) != len(names):
98
+ raise ConfigurationError(
99
+ "имена прокси повторяются: имя входит в имя идентичности, и "
100
+ "одинаковые имена свели бы два разных адреса в одно ведро токенов"
101
+ )
102
+ for proxy in proxies:
103
+ scheme = proxy.url.split("://", 1)[0].lower()
104
+ if scheme in _INSECURE_SCHEMES:
105
+ raise ConfigurationError(
106
+ f"прокси {proxy.name} объявлен по схеме {scheme}: секрет "
107
+ "уходит в заголовке каждого запроса, и без шифрования до "
108
+ "прокси его читает любой на пути. Возьмите https или socks5"
109
+ )
110
+
111
+ self._proxies = proxies
112
+ self._host = host
113
+ self._registry = registry if registry is not None else REGISTRY
114
+ self._by_account: dict[str, str] = {}
115
+
116
+ @property
117
+ def proxies(self) -> tuple[Proxy, ...]:
118
+ """Перечень прокси в порядке предпочтения.
119
+
120
+ Returns:
121
+ tuple[Proxy, ...]: Объявленные прокси.
122
+ """
123
+ return self._proxies
124
+
125
+ def _identity_names(self) -> tuple[str, ...]:
126
+ """Собирает имена идентичностей в порядке предпочтения.
127
+
128
+ Returns:
129
+ tuple[str, ...]: Имена. При пустом перечне - одно прямое соединение.
130
+ """
131
+ if not self._proxies:
132
+ return (identity_of(None, self._host),)
133
+ return tuple(identity_of(proxy.name, self._host) for proxy in self._proxies)
134
+
135
+ def _url_by_identity(self, name: str) -> str | None:
136
+ """Находит адрес прокси по имени идентичности.
137
+
138
+ Args:
139
+ name (str): Имя идентичности.
140
+
141
+ Returns:
142
+ str | None: Адрес либо None при прямом соединении.
143
+ """
144
+ for proxy in self._proxies:
145
+ if identity_of(proxy.name, self._host) == name:
146
+ return proxy.url
147
+ return None
148
+
149
+ def choose(self, account_id: str, now: float | None = None) -> tuple[str, str | None]:
150
+ """Выбирает идентичность для аккаунта.
151
+
152
+ Привязка держится, пока прокси работает: аккаунт, ходивший с одного
153
+ адреса и вдруг сменивший его, выглядит иначе, чем аккаунт с постоянным
154
+ адресом. Смена происходит только когда прежний остывает после
155
+ ограничения либо был отставлен как неработающий.
156
+
157
+ Args:
158
+ account_id (str): Аккаунт, для которого идёт запрос.
159
+ now (float | None): Момент. По умолчанию текущий.
160
+
161
+ Returns:
162
+ tuple[str, str | None]: Имя идентичности и адрес прокси. Адрес None
163
+ означает прямое соединение.
164
+
165
+ Raises:
166
+ ConfigurationError: Если остывают все объявленные прокси. Отказ
167
+ вслух честнее молчаливого возврата к прямому соединению: прямое
168
+ соединение раскрывает адрес, который вызывающий намеренно
169
+ прятал.
170
+ """
171
+ moment = monotonic() if now is None else now
172
+ names = self._identity_names()
173
+
174
+ bound = self._by_account.get(account_id)
175
+ if bound in names and not self._registry.get(bound).is_cooling(moment):
176
+ return bound, self._url_by_identity(bound)
177
+
178
+ chosen = self._registry.healthy(names, moment)
179
+ if chosen is None:
180
+ cooling = ", ".join(
181
+ f"{name} ещё {self._registry.get(name).cooldown_until - moment:.0f} с"
182
+ for name in names
183
+ )
184
+ raise ConfigurationError(
185
+ "все объявленные прокси остывают после ограничения частоты: "
186
+ f"{cooling}. Возврат к прямому соединению не делается: он "
187
+ "раскрыл бы адрес, который вы намеренно прятали"
188
+ )
189
+
190
+ if bound is not None and bound != chosen:
191
+ _log.warning(
192
+ "аккаунт %s переведён с %s на %s: прежний остывает после ограничения частоты",
193
+ account_id,
194
+ bound,
195
+ chosen,
196
+ )
197
+ self._by_account[account_id] = chosen
198
+ return chosen, self._url_by_identity(chosen)
199
+
200
+ def note_limit(self, name: str, now: float | None = None) -> None:
201
+ """Учитывает ограничение частоты, полученное этой идентичностью.
202
+
203
+ Args:
204
+ name (str): Имя идентичности.
205
+ now (float | None): Момент. По умолчанию текущий.
206
+
207
+ Returns:
208
+ None
209
+ """
210
+ self._registry.get(name).note_limit(monotonic() if now is None else now)
211
+
212
+ def note_success(self, name: str) -> None:
213
+ """Учитывает успешный запрос этой идентичности.
214
+
215
+ Args:
216
+ name (str): Имя идентичности.
217
+
218
+ Returns:
219
+ None
220
+ """
221
+ self._registry.get(name).note_success()
222
+
223
+ def bound_to(self, account_id: str) -> str | None:
224
+ """Сообщает, к какой идентичности привязан аккаунт.
225
+
226
+ Args:
227
+ account_id (str): Аккаунт.
228
+
229
+ Returns:
230
+ str | None: Имя идентичности либо None, если привязки ещё нет.
231
+ """
232
+ return self._by_account.get(account_id)
funora/_raise.py ADDED
@@ -0,0 +1,150 @@
1
+ """Поднятие предложений раздела и разбор ответа площадки.
2
+
3
+ ЧТО ЭТА ОПЕРАЦИЯ ДЕЛАЕТ. Поднимает В ВЫДАЧЕ ВСЕ предложения раздела сразу, а не
4
+ одно: запрос несёт игру и раздел, идентификатора предложения в нём нет. Кнопка
5
+ на странице так и называется - «Поднять предложения».
6
+
7
+ ЧЕМ ОНА ОПАСНА. Поднятие тратит суточный предел, восстановить который нельзя.
8
+ Повтор при неоднозначном исходе запрещён контрактом: вместо него положена сверка
9
+ фактического состояния.
10
+
11
+ ПЛОЩАДКА ОТВЕЧАЕТ ПРИЗНАКОМ ОТКАЗА, А НЕ УСПЕХА - поле error. Это важно: читать
12
+ надо его отрицание, и молчаливое «нет поля error, значит успех» было бы неверно.
13
+ Отсутствие поля означает не успех, а непонятный ответ.
14
+
15
+ ОДНОГО ОТРИЦАНИЯ ERROR МАЛО, и это выяснилось сверкой с независимой реализацией
16
+ того же протокола. Ответ без признака отказа, но С КЛЮЧОМ url, поднятием не
17
+ является: площадка возвращает адрес окна выбора подкатегорий, то есть спрашивает,
18
+ что именно поднимать. Прочитать такой ответ успехом значило бы сказать «поднято»
19
+ там, где не поднято ничего, - ровно та молчаливая неправда, ради которой весь
20
+ этот разбор и написан подробным.
21
+
22
+ Сами мы такого ответа не наблюдали. Пометка - third_party_report, источник назван
23
+ в правиле извлечения; наше обращение с ним от чужого отличается тем, что мы не
24
+ толкуем незнакомое, а отказываемся объявлять успех.
25
+
26
+ Наблюдено 31.08.2026: POST /lots/raise, поля game_id и node_id, ответ
27
+ {error, msg, unlock_at, wait}.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass
33
+ from datetime import datetime
34
+ from typing import Any, Final
35
+
36
+ from ._observed import Observed
37
+ from .errors import ProtocolChangedError
38
+
39
+ __all__ = ["RaiseResult", "parse_raise", "RAISE_PATH"]
40
+
41
+ #: Адрес поднятия. Наблюдён записью запроса.
42
+ RAISE_PATH: Final[str] = "/lots/raise"
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class RaiseResult:
47
+ """Исход поднятия предложений раздела.
48
+
49
+ Attributes:
50
+ raised (bool): Состоялось ли поднятие. Читается отрицанием поля error
51
+ И отсутствием ключа url: ответ с адресом окна выбора подкатегорий
52
+ поднятием не является.
53
+ choice_url (Observed[str]): Адрес окна выбора подкатегорий, если площадка
54
+ его вернула. Присутствие означает, что поднятие НЕ состоялось и
55
+ площадка спрашивает, что поднимать.
56
+ message (str): Сообщение площадки человеку, как есть. Не разбирается:
57
+ это текст на локали интерфейса, и выводить из него причину значило
58
+ бы строить разбор на переводе.
59
+ unlock_at (Observed[str]): Момент следующего поднятия, как его назвала
60
+ площадка. ТЕКСТОМ: формат не установлен, и разбирать его в момент
61
+ времени значило бы гадать о часовом поясе.
62
+ wait_seconds (Observed[int]): Сколько ждать, как назвала площадка.
63
+ ЕДИНИЦА НЕ НАБЛЮДАЛАСЬ: поле называется wait и приходит целым, а
64
+ секунды это или что-то ещё - неизвестно.
65
+ observed_at (datetime): Момент получения ответа.
66
+ """
67
+
68
+ raised: bool
69
+ message: str
70
+ unlock_at: Observed[str]
71
+ wait_seconds: Observed[int]
72
+ choice_url: Observed[str]
73
+ observed_at: datetime
74
+
75
+
76
+ def parse_raise(payload: Any, *, observed_at: datetime) -> RaiseResult:
77
+ """Разбирает ответ площадки на поднятие.
78
+
79
+ ПРИЗНАК ОТКАЗА ОБЯЗАТЕЛЕН. Без него неизвестно, состоялось поднятие или нет,
80
+ а повторить, чтобы выяснить, нельзя: повтор тратит невосполнимый предел.
81
+ Поэтому ответ без error отвергается вслух, а не толкуется как успех.
82
+
83
+ Аргументы:
84
+ payload (Any): Разобранное тело ответа.
85
+ observed_at (datetime): Момент получения.
86
+
87
+ Возвращает:
88
+ RaiseResult: Исход.
89
+
90
+ Raises:
91
+ ProtocolChangedError: Если в ответе нет признака отказа.
92
+ """
93
+ if not isinstance(payload, dict):
94
+ raise ProtocolChangedError(
95
+ f"ответ на поднятие не объект, а {type(payload).__name__}. Что "
96
+ "случилось с предложениями - неизвестно, а повторить, чтобы "
97
+ "выяснить, нельзя: повтор тратит суточный предел"
98
+ )
99
+
100
+ error = payload.get("error")
101
+ if not isinstance(error, bool):
102
+ raise ProtocolChangedError(
103
+ "в ответе на поднятие нет признака отказа error. Площадка отвечает "
104
+ "признаком ОТКАЗА, а не успеха, и без него исход неизвестен; "
105
+ "считать отсутствие поля успехом нельзя - повтор здесь тратит "
106
+ "невосполнимый предел"
107
+ )
108
+
109
+ raw_wait = payload.get("wait")
110
+ # Логическое исключается отдельно: истина в Python - это единица, и
111
+ # wait=True прочиталось бы как «ждать одну единицу».
112
+ wait = (
113
+ Observed.present(raw_wait)
114
+ if isinstance(raw_wait, int) and not isinstance(raw_wait, bool)
115
+ else Observed.missing("wait_not_in_response")
116
+ )
117
+
118
+ raw_unlock = payload.get("unlock_at")
119
+ unlock = (
120
+ Observed.present(raw_unlock)
121
+ if isinstance(raw_unlock, str) and raw_unlock.strip()
122
+ else Observed.missing("unlock_at_not_in_response")
123
+ )
124
+
125
+ # КЛЮЧ url ОТМЕНЯЕТ УСПЕХ. Ответ без признака отказа, но с адресом окна
126
+ # выбора подкатегорий, означает не поднятие, а вопрос «что поднимать»:
127
+ # площадка ждёт перечня подкатегорий, которого в нашем запросе нет.
128
+ #
129
+ # Сами мы такого ответа не видели - знаем о нём от независимой реализации
130
+ # того же протокола. Поэтому обращение с ним осторожное: мы не берёмся
131
+ # толковать, что именно площадка спросила, и лишь отказываемся объявлять
132
+ # успех. Ошибиться здесь в другую сторону дороже - поднятие тратит
133
+ # невосполнимый суточный предел, и «поднято» вместо «не поднято» пошлёт
134
+ # вызывающего ждать сутки впустую.
135
+ raw_url = payload.get("url")
136
+ choice = (
137
+ Observed.present(raw_url)
138
+ if isinstance(raw_url, str) and raw_url.strip()
139
+ else Observed.missing("url_not_in_response")
140
+ )
141
+
142
+ raw_message = payload.get("msg")
143
+ return RaiseResult(
144
+ raised=not error and choice.or_none() is None,
145
+ message=raw_message if isinstance(raw_message, str) else "",
146
+ unlock_at=unlock,
147
+ wait_seconds=wait,
148
+ choice_url=choice,
149
+ observed_at=observed_at,
150
+ )