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.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|