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/_transport.py ADDED
@@ -0,0 +1,1023 @@
1
+ """Транспортный слой наблюдения.
2
+
3
+ Слой намеренно тонкий: задача - не построить клиент, а один раз аккуратно
4
+ сходить на страницу и не оставить следов, которых не должно быть.
5
+
6
+ Что здесь сделано осознанно:
7
+
8
+ * Секрет не хранится в объекте клиента и не кладётся в cookie jar. Он
9
+ разворачивается в момент сборки запроса и живёт ровно до его отправки.
10
+ Cookie jar - это структура, которую легко напечатать целиком при отладке.
11
+ * Клиент не следует за переходами автоматически: конечный URL нужен
12
+ классификатору, а автоматический переход прячет тот факт, что нас увели на
13
+ страницу входа.
14
+ * Переход на чужой хост не выполняется вовсе. Разбор нашёл здесь дыру, стоящую
15
+ аккаунта целиком: одного заголовка Location хватало, чтобы секрет ушёл на
16
+ произвольный адрес. Проверка после отправки бесполезна - секрет уже ушёл бы.
17
+ * Хранилище cookie отключено. С включённым площадка одним Set-Cookie
18
+ подкладывала свой golden_key, и он уходил следующим запросом впереди
19
+ настоящего: сервер читает первое вхождение, и клиент молча читал чужой
20
+ аккаунт как свой.
21
+ * При запуске проверяется уровень журналирования HTTP-стека. httpx на уровне
22
+ DEBUG печатает заголовки, то есть и сессионный ключ. Предупреждение выдаётся
23
+ один раз: молча работать в таком режиме нельзя, а падать - слишком.
24
+ * Числа таймаутов и пределов взяты из spec/runtime/budget.yaml и помечены там
25
+ как провизорные.
26
+
27
+ Транспортов два - синхронный и асинхронный, - но решение о переходе у них одно
28
+ на двоих и живёт в [_hops.py]. Разъехаться правилу безопасности здесь дороже,
29
+ чем любому другому: цена расхождения - чужой доступ к аккаунту.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ from dataclasses import dataclass
36
+ from typing import Final
37
+ from urllib.parse import urljoin
38
+
39
+ import httpx
40
+
41
+ from ._hops import Follow, Reject, next_hop
42
+ from ._host import host_of
43
+ from ._secret import Secret
44
+ from .budget import (
45
+ MAX_CONNECTIONS_PER_HOST,
46
+ MAX_DECOMPRESSED_BYTES,
47
+ MAX_REDIRECTS,
48
+ MAX_RESPONSE_BYTES,
49
+ )
50
+ from .contract import SUPPORTED_LOCALES
51
+ from .errors import ConfigurationError, NetworkError, RemoteServerError, TimeoutError
52
+
53
+ __all__ = ["Observation", "Fetcher", "AsyncFetcher", "TransportSettings"]
54
+
55
+ _log = logging.getLogger("funora.transport")
56
+
57
+ #: Журналы, которые печатают заголовки запроса на уровне DEBUG.
58
+ _RISKY_LOGGERS: Final[tuple[str, ...]] = ("httpx", "httpcore", "urllib3")
59
+
60
+ #: Флаг, чтобы предупреждение о журналировании выдавалось один раз за процесс.
61
+ _warned = False
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class TransportSettings:
66
+ """Настройки транспорта.
67
+
68
+ Числа берутся из порождённого модуля budget, а не пишутся здесь. Прежде они
69
+ были литералами «по мотивам» спецификации, и правка спецификации меняла
70
+ порождённый файл, не меняя поведения: предел переходов, размера ответа и
71
+ числа соединений оставался прежним. Молча.
72
+
73
+ В спецификации числа помечены провизорными и будут уточнены по результатам
74
+ наблюдений - тем важнее, чтобы уточнение доходило до транспорта само.
75
+
76
+ Args:
77
+ base_url (str): Базовый адрес площадки.
78
+ connect_timeout_s (float): Предел на установку соединения, секунды.
79
+ read_timeout_s (float): Предел на чтение ответа, секунды.
80
+ max_connections (int): Предел одновременных соединений на хост.
81
+ max_response_bytes (int): Предел размера полученного тела, байты.
82
+ max_decompressed_bytes (int): Предел размера тела после распаковки,
83
+ байты. Отдельный предел, а не тот же самый: сжатый ответ в мегабайт
84
+ разворачивается в сотни, и одна проверка на двоих ловит только тот
85
+ случай, который и так виден.
86
+ max_redirects (int): Предел числа переходов при ручном следовании.
87
+ proxy_url (str | None): Через что ходить. None означает прямое
88
+ соединение. Прокси меняет исходящий адрес, то есть сетевую
89
+ идентичность целиком: у неё свой запас токенов и своё остывание
90
+ после ограничения частоты.
91
+ user_agent (str): Значение заголовка User-Agent. Задаётся спецификацией,
92
+ а не оставляется на усмотрение реализации: одинаковое поведение
93
+ шести SDK начинается с того, как они представляются.
94
+ """
95
+
96
+ base_url: str = "https://funpay.com"
97
+ connect_timeout_s: float = 10.0
98
+ read_timeout_s: float = 20.0
99
+ max_connections: int = MAX_CONNECTIONS_PER_HOST
100
+ max_response_bytes: int = MAX_RESPONSE_BYTES
101
+ max_decompressed_bytes: int = MAX_DECOMPRESSED_BYTES
102
+ max_redirects: int = MAX_REDIRECTS
103
+ proxy_url: str | None = None
104
+ user_agent: str = "Funora/0.0.1 (+https://github.com/Funora-Develop)"
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class Observation:
109
+ """Результат одного обращения к странице.
110
+
111
+ Тело сохраняется, потому что дальше из него строится структурный скелет.
112
+ В диагностику и в файлы попадает только скелет, но не это поле.
113
+
114
+ Args:
115
+ status (int): Код состояния HTTP.
116
+ final_url (str): URL после всех переходов.
117
+ html (str): Тело ответа.
118
+ elapsed_ms (int): Длительность запроса, миллисекунды.
119
+ redirects (int): Сколько переходов было выполнено.
120
+ content_length (int): Размер полученного тела в байтах.
121
+ content_encoding (str): Кодирование тела, как его объявил сервер. Пустая
122
+ строка означает, что сервер послушался просьбы не сжимать, и длину
123
+ можно сравнивать с объявленной.
124
+ declared_length (int | None): Длина, объявленная заголовком
125
+ Content-Length, если он был. Нужна для проверки целостности:
126
+ страница, оборванная посреди таблицы, проходит и классификацию, и
127
+ разбор, а вызывающий получает половину заказов с нулём повреждений.
128
+ Это правдоподобный неверный ответ, о неверности которого узнать
129
+ неоткуда, и сверка длин - единственный способ его заметить.
130
+ retry_after_ms (int | None): Значение заголовка Retry-After в
131
+ миллисекундах, если площадка его прислала.
132
+ requests_sent (int): Сколько запросов ушло на самом деле, вместе с
133
+ переходами. Расходуется бюджет именно по этому числу: спецификация
134
+ требует считать отправленные запросы, а не логические операции, и
135
+ переход - тоже запрос.
136
+ """
137
+
138
+ status: int
139
+ final_url: str
140
+ html: str
141
+ elapsed_ms: int
142
+ redirects: int
143
+ content_length: int
144
+ content_encoding: str = ""
145
+ declared_length: int | None = None
146
+ retry_after_ms: int | None = None
147
+ requests_sent: int = 1
148
+
149
+
150
+ def _warn_if_headers_logged() -> None:
151
+ """Предупреждает, если HTTP-стек печатает заголовки в журнал.
152
+
153
+ На уровне DEBUG httpx выводит заголовки запроса, среди которых Cookie с
154
+ сессионным ключом. Граница защиты секрета проходит по краю этого проекта, и
155
+ об этом лучше сказать вслух один раз, чем не сказать вовсе.
156
+
157
+ Returns:
158
+ None: Побочный эффект - запись в журнал.
159
+ """
160
+ global _warned
161
+ if _warned:
162
+ return
163
+ for name in _RISKY_LOGGERS:
164
+ logger = logging.getLogger(name)
165
+ if logger.isEnabledFor(logging.DEBUG):
166
+ _log.warning(
167
+ "журнал %s включён на уровне DEBUG: он печатает заголовки запроса, "
168
+ "включая сессионный ключ. Funora не может этому помешать",
169
+ name,
170
+ )
171
+ _warned = True
172
+ return
173
+ _warned = True
174
+
175
+
176
+ def _accept_language() -> str:
177
+ """Собирает заголовок предпочитаемых языков из перечня спецификации.
178
+
179
+ Первый язык перечня просится без веса, остальные - с убывающим: так принято
180
+ в HTTP, и так площадка поймёт порядок предпочтения.
181
+
182
+ Returns:
183
+ str: Значение заголовка Accept-Language.
184
+ """
185
+ if not SUPPORTED_LOCALES:
186
+ raise ConfigurationError(
187
+ "перечень поддерживаемых локалей пуст: клиент не может назвать язык, "
188
+ "для которого у него есть снимки страниц"
189
+ )
190
+ head, *rest = SUPPORTED_LOCALES
191
+ parts = [head]
192
+ for index, locale in enumerate(rest, start=1):
193
+ parts.append(f"{locale};q={max(0.1, 1.0 - index * 0.2):.1f}")
194
+ return ",".join(parts)
195
+
196
+
197
+ def _client_kwargs(settings: TransportSettings) -> dict[str, object]:
198
+ """Собирает одинаковые для обоих транспортов настройки httpx.
199
+
200
+ Общая функция здесь не ради краткости. Из перечисленных настроек две -
201
+ отключённое хранилище cookie и отключённое следование за переходами -
202
+ держат обе найденные разбором дыры закрытыми. Заданные по отдельности, они
203
+ расходятся при первой же правке, и расхождение это молчаливое.
204
+
205
+ Args:
206
+ settings (TransportSettings): Настройки транспорта.
207
+
208
+ Returns:
209
+ dict[str, object]: Аргументы конструктора клиента httpx.
210
+ """
211
+ return {
212
+ "timeout": httpx.Timeout(
213
+ connect=settings.connect_timeout_s,
214
+ read=settings.read_timeout_s,
215
+ write=settings.read_timeout_s,
216
+ pool=settings.connect_timeout_s,
217
+ ),
218
+ "limits": httpx.Limits(
219
+ max_connections=settings.max_connections,
220
+ max_keepalive_connections=settings.max_connections,
221
+ ),
222
+ "follow_redirects": False,
223
+ # Хранилище cookie отключено намеренно. С включённым площадка одним
224
+ # заголовком Set-Cookie подкладывала свой golden_key, и он уходил
225
+ # следующим запросом ВПЕРЕДИ настоящего: сервер читает первое вхождение,
226
+ # и клиент молча читал чужой аккаунт как свой. Ни исключения, ни
227
+ # повреждений, ни строки в журнале - правдоподобные данные не того
228
+ # аккаунта. Заголовок Cookie собирается вручную.
229
+ "cookies": None,
230
+ # Прокси передаётся библиотеке как есть. Проверку схемы делает пул: там
231
+ # же, где прокси объявляются, - иначе она обошлась бы передачей готового
232
+ # транспорта.
233
+ "proxy": settings.proxy_url,
234
+ "headers": {
235
+ "User-Agent": settings.user_agent,
236
+ # Перечень берётся из спецификации, а не пишется здесь. Локаль
237
+ # привязана к аккаунту и запросом не переключается, но заголовок
238
+ # обязан называть ровно те языки, для которых у проекта есть
239
+ # снимки: попросив язык без снимков, клиент получил бы страницу,
240
+ # которую не умеет разбирать, и объявил бы это изменением вёрстки.
241
+ "Accept-Language": _accept_language(),
242
+ # Сжатие запрашивается отключённым, и это не про экономию, а про
243
+ # единственную защиту от обрыва тела.
244
+ #
245
+ # Библиотека распаковывает ответ прозрачно, а заголовок
246
+ # Content-Length объявляет длину СЖАТОГО тела. Проверка целостности
247
+ # сравнивала распакованную длину с объявленной сжатой - двести тысяч
248
+ # байт против двухсот пятидесяти, - и проходила всегда, в том числе
249
+ # на оборванном ответе. Проверка была мертва ровно там, где нужна.
250
+ #
251
+ # Распаковка обрыв тоже не ловит: оборванный gzip разворачивается
252
+ # частично и без ошибки. Проверено - половина потока дала 88 тысяч
253
+ # байт правдоподобного текста.
254
+ #
255
+ # Цена - трафик. Запросов от этого больше не становится, а страница,
256
+ # оборванная посреди таблицы, проходит и классификацию как
257
+ # пригодную, и разбор как полный: вызывающий получает половину
258
+ # заказов с нулём повреждений.
259
+ "Accept-Encoding": "identity",
260
+ },
261
+ }
262
+
263
+
264
+ def _translate(exc: httpx.HTTPError, path: str) -> Exception:
265
+ """Переводит отказ HTTP-стека в иерархию ошибок Funora.
266
+
267
+ Перевод делается здесь, а не у вызывающего. Иначе обработчик, ловящий
268
+ FunoraError, пропускал бы обрыв связи мимо себя, и цикл наблюдения падал бы
269
+ целиком вместо повтора - при том, что политика повторов для сетевых отказов
270
+ написана и покрыта тестами.
271
+
272
+ Args:
273
+ exc (httpx.HTTPError): Исходный отказ.
274
+ path (str): Путь, по которому шло обращение. Нужен для сообщения.
275
+
276
+ Returns:
277
+ Exception: TimeoutError при истечении предела ожидания, иначе
278
+ NetworkError.
279
+ """
280
+ if isinstance(exc, httpx.TimeoutException):
281
+ return TimeoutError(f"истёк предел ожидания при обращении к {path}")
282
+ return NetworkError(f"сетевой отказ при обращении к {path}: {type(exc).__name__}")
283
+
284
+
285
+ def _observe(
286
+ response: httpx.Response,
287
+ *,
288
+ settings: TransportSettings,
289
+ rejected_url: str | None,
290
+ redirects: int,
291
+ sent: int,
292
+ elapsed: float,
293
+ ) -> Observation:
294
+ """Собирает наблюдение из ответа.
295
+
296
+ Args:
297
+ response (httpx.Response): Последний полученный ответ.
298
+ settings (TransportSettings): Настройки транспорта.
299
+ rejected_url (str | None): Адрес отвергнутого перехода либо None.
300
+ redirects (int): Число выполненных переходов.
301
+ sent (int): Число отправленных запросов.
302
+ elapsed (float): Суммарная длительность запросов, секунды.
303
+
304
+ Returns:
305
+ Observation: Наблюдение.
306
+
307
+ Raises:
308
+ RemoteServerError: Если ответ превысил предел размера.
309
+ """
310
+ raw = response.content
311
+ # Два предела, а не один. Полученное тело меряется одним, распакованное -
312
+ # другим: сжатый ответ в мегабайт разворачивается в сотни, и одна проверка
313
+ # на двоих ловит только тот случай, который и так виден.
314
+ encoded = (response.headers.get("content-encoding") or "").strip().lower()
315
+ limit = (
316
+ settings.max_decompressed_bytes
317
+ if encoded not in ("", "identity")
318
+ else settings.max_response_bytes
319
+ )
320
+ if len(raw) > limit:
321
+ raise RemoteServerError(f"ответ превысил предел {limit} байт: получено {len(raw)}")
322
+
323
+ return Observation(
324
+ status=response.status_code,
325
+ final_url=rejected_url or str(response.url),
326
+ html=response.text,
327
+ elapsed_ms=int(elapsed * 1000),
328
+ redirects=redirects,
329
+ requests_sent=sent,
330
+ content_length=len(raw),
331
+ content_encoding=(response.headers.get("content-encoding") or "").strip().lower(),
332
+ declared_length=_header_int(response, "content-length"),
333
+ retry_after_ms=_retry_after_ms(response),
334
+ )
335
+
336
+
337
+ def _log_rejected(target: str, expected: str) -> None:
338
+ """Пишет в журнал об отвергнутом переходе.
339
+
340
+ Args:
341
+ target (str): Адрес, куда нас пытались увести.
342
+ expected (str): Ожидаемый хост площадки.
343
+
344
+ Returns:
345
+ None: Побочный эффект - запись в журнал.
346
+ """
347
+ _log.warning(
348
+ "переход отклонён: %s не принадлежит %s либо понижает схему",
349
+ host_of(target) or "адрес без хоста",
350
+ expected,
351
+ )
352
+
353
+
354
+ class Fetcher:
355
+ """Выполняет одиночные запросы к площадке.
356
+
357
+ Args:
358
+ secret (Secret | None): Сессионный секрет. Разворачивается только
359
+ при сборке запроса. None создаёт анонимный транспорт.
360
+ cookie_name (str): Имя cookie, в которой передаётся секрет.
361
+ settings (TransportSettings): Настройки транспорта.
362
+ """
363
+
364
+ __slots__ = ("_client", "_cookie_name", "_secret", "_settings")
365
+
366
+ def __init__(
367
+ self,
368
+ secret: Secret | None,
369
+ cookie_name: str = "golden_key",
370
+ settings: TransportSettings | None = None,
371
+ ) -> None:
372
+ _warn_if_headers_logged()
373
+ self._secret = secret
374
+ self._cookie_name = cookie_name
375
+ self._settings = settings or TransportSettings()
376
+ self._client = httpx.Client(**_client_kwargs(self._settings)) # type: ignore[arg-type]
377
+
378
+ def __enter__(self) -> Fetcher:
379
+ """Входит в контекстный менеджер.
380
+
381
+ Returns:
382
+ Fetcher: Сам объект.
383
+ """
384
+ return self
385
+
386
+ def __exit__(self, *exc: object) -> None:
387
+ """Закрывает соединения при выходе из контекстного менеджера.
388
+
389
+ Args:
390
+ *exc (object): Сведения об исключении. Не используются.
391
+
392
+ Returns:
393
+ None
394
+ """
395
+ self.close()
396
+
397
+ def close(self) -> None:
398
+ """Закрывает пул соединений.
399
+
400
+ Returns:
401
+ None
402
+ """
403
+ self._client.close()
404
+
405
+ def fetch(self, path: str) -> Observation:
406
+ """Загружает одну страницу.
407
+
408
+ Переходы выполняются вручную, а не средствами клиента: конечный URL нужен
409
+ классификатору, и автоматический переход скрыл бы тот факт, что нас увели
410
+ на страницу входа. Решение о каждом переходе принимает [_hops.next_hop] -
411
+ то же самое, что и в асинхронном транспорте.
412
+
413
+ Args:
414
+ path (str): Путь или полный адрес страницы.
415
+
416
+ Returns:
417
+ Observation: Результат обращения. Если переход уводил на чужой хост
418
+ либо понижал схему, возвращается ответ-перенаправление с чужим
419
+ конечным адресом: решение принимает классификатор, а секрет туда не
420
+ уходит вовсе.
421
+
422
+ Raises:
423
+ TimeoutError: Если истёк предел ожидания.
424
+ NetworkError: При любом другом сетевом отказе.
425
+ RemoteServerError: Если ответ превысил предел размера.
426
+ """
427
+ expected = host_of(self._settings.base_url)
428
+ url = _start_url(self._settings, path)
429
+ rejected_url: str | None = None
430
+ redirects = 0
431
+ sent = 0
432
+ elapsed = 0.0
433
+
434
+ while True:
435
+ sent += 1
436
+ try:
437
+ # Секрет разворачивается здесь и уходит только на проверенный
438
+ # хост. Заголовок собирается вручную: хранилище cookie
439
+ # отключено, чтобы присланное площадкой значение не оседало.
440
+ response = self._client.get(url, headers=self._cookie())
441
+ except httpx.HTTPError as exc:
442
+ raise _translate(exc, path) from exc
443
+ elapsed += response.elapsed.total_seconds()
444
+
445
+ hop = next_hop(
446
+ current=url,
447
+ is_redirect=response.is_redirect,
448
+ location=response.headers.get("location", ""),
449
+ redirects=redirects,
450
+ max_redirects=self._settings.max_redirects,
451
+ expected=expected,
452
+ )
453
+ if isinstance(hop, Follow):
454
+ url = hop.url
455
+ redirects += 1
456
+ continue
457
+ if isinstance(hop, Reject):
458
+ _log_rejected(hop.url, expected)
459
+ rejected_url = hop.url
460
+ redirects += 1
461
+ break
462
+
463
+ return _observe(
464
+ response,
465
+ settings=self._settings,
466
+ rejected_url=rejected_url,
467
+ redirects=redirects,
468
+ sent=sent,
469
+ elapsed=elapsed,
470
+ )
471
+
472
+ def submit(self, path: str, fields: dict[str, str], headers: dict[str, str]) -> Observation:
473
+ """Отправляет форму и возвращает ответ.
474
+
475
+ ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ, и это не упрощение. Повторить отправку по
476
+ переходу значило бы отправить второй раз: у сообщения покупателю нет
477
+ отмены, а повтор при неоднозначном исходе - второе сообщение. Переход в
478
+ ответ на запись возвращается как есть, и решение принимает вызывающий,
479
+ видящий, ЧТО именно он отправлял.
480
+
481
+ Секрет уходит только на проверенный хост - тот же порядок, что и у
482
+ чтения. Адрес собирается от базового, а не берётся у вызывающего
483
+ целиком.
484
+
485
+ Args:
486
+ path (str): Путь обращения.
487
+ fields (dict[str, str]): Поля формы.
488
+ headers (dict[str, str]): Заголовки запроса, кроме Cookie.
489
+
490
+ Returns:
491
+ Observation: Результат обращения. Число переходов всегда ноль.
492
+
493
+ Raises:
494
+ TimeoutError: Если истёк предел ожидания.
495
+ NetworkError: При любом другом сетевом отказе.
496
+ RemoteServerError: Если ответ превысил предел размера.
497
+ """
498
+ url = _start_url(self._settings, path)
499
+ try:
500
+ response = self._client.post(url, data=fields, headers={**headers, **self._cookie()})
501
+ except httpx.HTTPError as exc:
502
+ raise _translate(exc, path) from exc
503
+
504
+ return _observe(
505
+ response,
506
+ settings=self._settings,
507
+ rejected_url=None,
508
+ redirects=0,
509
+ sent=1,
510
+ elapsed=response.elapsed.total_seconds(),
511
+ )
512
+
513
+ def ask(self, path: str, headers: dict[str, str]) -> Observation:
514
+ """Спрашивает методом GET, но получает ОБЪЕКТ, а не разметку страницы.
515
+
516
+ ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У fetch. Отличий от чтения страницы два -
517
+ заголовки и то, что ответ разбирается как объект, - и оба можно было бы
518
+ сделать признаками. Признак означал бы, что два разных чтения живут в
519
+ одном месте и различаются условием, а условие однажды упростят.
520
+
521
+ ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ. Точка отвечает объектом; переход отсюда означает
522
+ не «страница переехала», а «нас выкинуло на страницу» - и разбирать её
523
+ как объект нельзя. Пусть лучше это станет видно отказом.
524
+
525
+ Args:
526
+ path (str): Путь обращения вместе с параметрами.
527
+ headers (dict[str, str]): Заголовки, кроме Cookie.
528
+
529
+ Returns:
530
+ Observation: Результат обращения.
531
+
532
+ Raises:
533
+ TimeoutError: Если истёк предел ожидания.
534
+ NetworkError: При любом другом сетевом отказе.
535
+ RemoteServerError: Если ответ превысил предел размера.
536
+ """
537
+ url = _start_url(self._settings, path)
538
+ try:
539
+ response = self._client.get(
540
+ url,
541
+ headers={**headers, **self._cookie()},
542
+ )
543
+ except httpx.HTTPError as exc:
544
+ raise _translate(exc, path) from exc
545
+
546
+ return _observe(
547
+ response,
548
+ settings=self._settings,
549
+ rejected_url=None,
550
+ redirects=0,
551
+ sent=1,
552
+ elapsed=response.elapsed.total_seconds(),
553
+ )
554
+
555
+ def query(self, path: str, payload: object, headers: dict[str, str]) -> Observation:
556
+ """Спрашивает СТРУКТУРНО: тело JSON, ответ JSON.
557
+
558
+ ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У submit, и правило у него своё - причём
559
+ обратное. Отправка формы не повторяется по переходу, потому что запись
560
+ повторять нельзя. Здесь ЧТЕНИЕ, выполненное методом POST: повтор
561
+ безвреден, и переходы выполняются как у обычного чтения.
562
+
563
+ Защитного токена здесь нет вовсе - это отличает семейство /api/ от форм.
564
+
565
+ Args:
566
+ path (str): Путь обращения.
567
+ payload (object): Тело запроса. Кодируется в JSON.
568
+ headers (dict[str, str]): Заголовки, кроме Cookie и Content-Type.
569
+
570
+ Returns:
571
+ Observation: Результат обращения.
572
+
573
+ Raises:
574
+ TimeoutError: Если истёк предел ожидания.
575
+ NetworkError: При любом другом сетевом отказе.
576
+ RemoteServerError: Если ответ превысил предел размера.
577
+ """
578
+ url = _start_url(self._settings, path)
579
+ try:
580
+ response = self._client.post(
581
+ url,
582
+ json=payload,
583
+ headers={**headers, **self._cookie()},
584
+ )
585
+ except httpx.HTTPError as exc:
586
+ raise _translate(exc, path) from exc
587
+
588
+ return _observe(
589
+ response,
590
+ settings=self._settings,
591
+ rejected_url=None,
592
+ redirects=0,
593
+ sent=1,
594
+ elapsed=response.elapsed.total_seconds(),
595
+ )
596
+
597
+ def upload(
598
+ self,
599
+ path: str,
600
+ *,
601
+ field: str,
602
+ filename: str,
603
+ content: bytes,
604
+ content_type: str,
605
+ headers: dict[str, str],
606
+ ) -> Observation:
607
+ """Отправляет ФАЙЛ и возвращает ответ.
608
+
609
+ ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У submit, и это не оформление. Тело здесь
610
+ собирается иначе - составное, с границей частей, - и правило у него своё:
611
+ размер тела ограничивает ПЛОЩАДКА, и предел она объявляет на странице.
612
+ Признак у submit означал бы, что оба правила живут в одном месте и
613
+ различаются условием; условие однажды упростят.
614
+
615
+ ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ, как и у submit: повторить отправку по переходу
616
+ значило бы отправить файл второй раз.
617
+
618
+ Args:
619
+ path (str): Путь обращения.
620
+ field (str): Имя поля, в котором уходит файл.
621
+ filename (str): Имя файла, как его увидит площадка.
622
+ content (bytes): Содержимое файла.
623
+ content_type (str): Тип содержимого.
624
+ headers (dict[str, str]): Заголовки запроса, кроме Cookie.
625
+
626
+ Returns:
627
+ Observation: Результат обращения. Число переходов всегда ноль.
628
+
629
+ Raises:
630
+ TimeoutError: Если истёк предел ожидания.
631
+ NetworkError: При любом другом сетевом отказе.
632
+ RemoteServerError: Если ответ превысил предел размера.
633
+ """
634
+ url = _start_url(self._settings, path)
635
+ try:
636
+ response = self._client.post(
637
+ url,
638
+ files={field: (filename, content, content_type)},
639
+ headers={**headers, **self._cookie()},
640
+ )
641
+ except httpx.HTTPError as exc:
642
+ raise _translate(exc, path) from exc
643
+
644
+ return _observe(
645
+ response,
646
+ settings=self._settings,
647
+ rejected_url=None,
648
+ redirects=0,
649
+ sent=1,
650
+ elapsed=response.elapsed.total_seconds(),
651
+ )
652
+
653
+ def _cookie(self) -> dict[str, str]:
654
+ """Собирает заголовок с сессионным секретом.
655
+
656
+ Returns:
657
+ dict[str, str]: Заголовок с секретом либо пустой набор для гостя.
658
+ """
659
+ if self._secret is None:
660
+ return {}
661
+ return {"Cookie": f"{self._cookie_name}={self._secret.reveal()}"}
662
+
663
+
664
+ class AsyncFetcher:
665
+ """Асинхронный близнец [Fetcher].
666
+
667
+ Отличается ровно тем, чем должен: ожиданием ответа. Решение о переходе,
668
+ настройки клиента, сборка заголовка с секретом, перевод отказов и сборка
669
+ наблюдения - общие с синхронным транспортом и живут в этом же модуле. Дважды
670
+ написанное правило безопасности расходится, и цена расхождения здесь - чужой
671
+ доступ к аккаунту.
672
+
673
+ Args:
674
+ secret (Secret | None): Сессионный секрет. Разворачивается только
675
+ при сборке запроса. None создаёт анонимный транспорт.
676
+ cookie_name (str): Имя cookie, в которой передаётся секрет.
677
+ settings (TransportSettings): Настройки транспорта.
678
+ """
679
+
680
+ __slots__ = ("_client", "_cookie_name", "_secret", "_settings")
681
+
682
+ def __init__(
683
+ self,
684
+ secret: Secret | None,
685
+ cookie_name: str = "golden_key",
686
+ settings: TransportSettings | None = None,
687
+ ) -> None:
688
+ _warn_if_headers_logged()
689
+ self._secret = secret
690
+ self._cookie_name = cookie_name
691
+ self._settings = settings or TransportSettings()
692
+ self._client = httpx.AsyncClient(**_client_kwargs(self._settings)) # type: ignore[arg-type]
693
+
694
+ async def __aenter__(self) -> AsyncFetcher:
695
+ """Входит в асинхронный контекстный менеджер.
696
+
697
+ Returns:
698
+ AsyncFetcher: Сам объект.
699
+ """
700
+ return self
701
+
702
+ async def __aexit__(self, *exc: object) -> None:
703
+ """Закрывает соединения при выходе из контекстного менеджера.
704
+
705
+ Args:
706
+ *exc (object): Сведения об исключении. Не используются.
707
+
708
+ Returns:
709
+ None
710
+ """
711
+ await self.close()
712
+
713
+ async def close(self) -> None:
714
+ """Закрывает пул соединений.
715
+
716
+ Returns:
717
+ None
718
+ """
719
+ await self._client.aclose()
720
+
721
+ async def fetch(self, path: str) -> Observation:
722
+ """Загружает одну страницу.
723
+
724
+ Args:
725
+ path (str): Путь или полный адрес страницы.
726
+
727
+ Returns:
728
+ Observation: Результат обращения, устроенный так же, как у
729
+ синхронного транспорта.
730
+
731
+ Raises:
732
+ TimeoutError: Если истёк предел ожидания.
733
+ NetworkError: При любом другом сетевом отказе.
734
+ RemoteServerError: Если ответ превысил предел размера.
735
+ """
736
+ expected = host_of(self._settings.base_url)
737
+ url = _start_url(self._settings, path)
738
+ rejected_url: str | None = None
739
+ redirects = 0
740
+ sent = 0
741
+ elapsed = 0.0
742
+
743
+ while True:
744
+ sent += 1
745
+ try:
746
+ response = await self._client.get(url, headers=self._cookie())
747
+ except httpx.HTTPError as exc:
748
+ raise _translate(exc, path) from exc
749
+ elapsed += response.elapsed.total_seconds()
750
+
751
+ hop = next_hop(
752
+ current=url,
753
+ is_redirect=response.is_redirect,
754
+ location=response.headers.get("location", ""),
755
+ redirects=redirects,
756
+ max_redirects=self._settings.max_redirects,
757
+ expected=expected,
758
+ )
759
+ if isinstance(hop, Follow):
760
+ url = hop.url
761
+ redirects += 1
762
+ continue
763
+ if isinstance(hop, Reject):
764
+ _log_rejected(hop.url, expected)
765
+ rejected_url = hop.url
766
+ redirects += 1
767
+ break
768
+
769
+ return _observe(
770
+ response,
771
+ settings=self._settings,
772
+ rejected_url=rejected_url,
773
+ redirects=redirects,
774
+ sent=sent,
775
+ elapsed=elapsed,
776
+ )
777
+
778
+ async def submit(
779
+ self, path: str, fields: dict[str, str], headers: dict[str, str]
780
+ ) -> Observation:
781
+ """Отправляет форму и возвращает ответ.
782
+
783
+ ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ, и это не упрощение. Повторить отправку по
784
+ переходу значило бы отправить второй раз: у сообщения покупателю нет
785
+ отмены, а повтор при неоднозначном исходе - второе сообщение. Переход в
786
+ ответ на запись возвращается как есть, и решение принимает вызывающий,
787
+ видящий, ЧТО именно он отправлял.
788
+
789
+ Секрет уходит только на проверенный хост - тот же порядок, что и у
790
+ чтения. Адрес собирается от базового, а не берётся у вызывающего
791
+ целиком.
792
+
793
+ Args:
794
+ path (str): Путь обращения.
795
+ fields (dict[str, str]): Поля формы.
796
+ headers (dict[str, str]): Заголовки запроса, кроме Cookie.
797
+
798
+ Returns:
799
+ Observation: Результат обращения. Число переходов всегда ноль.
800
+
801
+ Raises:
802
+ TimeoutError: Если истёк предел ожидания.
803
+ NetworkError: При любом другом сетевом отказе.
804
+ RemoteServerError: Если ответ превысил предел размера.
805
+ """
806
+ url = _start_url(self._settings, path)
807
+ try:
808
+ response = await self._client.post(
809
+ url, data=fields, headers={**headers, **self._cookie()}
810
+ )
811
+ except httpx.HTTPError as exc:
812
+ raise _translate(exc, path) from exc
813
+
814
+ return _observe(
815
+ response,
816
+ settings=self._settings,
817
+ rejected_url=None,
818
+ redirects=0,
819
+ sent=1,
820
+ elapsed=response.elapsed.total_seconds(),
821
+ )
822
+
823
+ async def ask(self, path: str, headers: dict[str, str]) -> Observation:
824
+ """Спрашивает методом GET, но получает ОБЪЕКТ, а не разметку страницы.
825
+
826
+ ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У fetch. Отличий от чтения страницы два -
827
+ заголовки и то, что ответ разбирается как объект, - и оба можно было бы
828
+ сделать признаками. Признак означал бы, что два разных чтения живут в
829
+ одном месте и различаются условием, а условие однажды упростят.
830
+
831
+ ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ. Точка отвечает объектом; переход отсюда означает
832
+ не «страница переехала», а «нас выкинуло на страницу» - и разбирать её
833
+ как объект нельзя. Пусть лучше это станет видно отказом.
834
+
835
+ Args:
836
+ path (str): Путь обращения вместе с параметрами.
837
+ headers (dict[str, str]): Заголовки, кроме Cookie.
838
+
839
+ Returns:
840
+ Observation: Результат обращения.
841
+
842
+ Raises:
843
+ TimeoutError: Если истёк предел ожидания.
844
+ NetworkError: При любом другом сетевом отказе.
845
+ RemoteServerError: Если ответ превысил предел размера.
846
+ """
847
+ url = _start_url(self._settings, path)
848
+ try:
849
+ response = await self._client.get(
850
+ url,
851
+ headers={**headers, **self._cookie()},
852
+ )
853
+ except httpx.HTTPError as exc:
854
+ raise _translate(exc, path) from exc
855
+
856
+ return _observe(
857
+ response,
858
+ settings=self._settings,
859
+ rejected_url=None,
860
+ redirects=0,
861
+ sent=1,
862
+ elapsed=response.elapsed.total_seconds(),
863
+ )
864
+
865
+ async def query(self, path: str, payload: object, headers: dict[str, str]) -> Observation:
866
+ """Спрашивает СТРУКТУРНО: тело JSON, ответ JSON.
867
+
868
+ ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У submit, и правило у него своё - причём
869
+ обратное. Отправка формы не повторяется по переходу, потому что запись
870
+ повторять нельзя. Здесь ЧТЕНИЕ, выполненное методом POST: повтор
871
+ безвреден, и переходы выполняются как у обычного чтения.
872
+
873
+ Защитного токена здесь нет вовсе - это отличает семейство /api/ от форм.
874
+
875
+ Args:
876
+ path (str): Путь обращения.
877
+ payload (object): Тело запроса. Кодируется в JSON.
878
+ headers (dict[str, str]): Заголовки, кроме Cookie и Content-Type.
879
+
880
+ Returns:
881
+ Observation: Результат обращения.
882
+
883
+ Raises:
884
+ TimeoutError: Если истёк предел ожидания.
885
+ NetworkError: При любом другом сетевом отказе.
886
+ RemoteServerError: Если ответ превысил предел размера.
887
+ """
888
+ url = _start_url(self._settings, path)
889
+ try:
890
+ response = await self._client.post(
891
+ url,
892
+ json=payload,
893
+ headers={**headers, **self._cookie()},
894
+ )
895
+ except httpx.HTTPError as exc:
896
+ raise _translate(exc, path) from exc
897
+
898
+ return _observe(
899
+ response,
900
+ settings=self._settings,
901
+ rejected_url=None,
902
+ redirects=0,
903
+ sent=1,
904
+ elapsed=response.elapsed.total_seconds(),
905
+ )
906
+
907
+ async def upload(
908
+ self,
909
+ path: str,
910
+ *,
911
+ field: str,
912
+ filename: str,
913
+ content: bytes,
914
+ content_type: str,
915
+ headers: dict[str, str],
916
+ ) -> Observation:
917
+ """Отправляет ФАЙЛ и возвращает ответ.
918
+
919
+ ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У submit, и это не оформление. Тело здесь
920
+ собирается иначе - составное, с границей частей, - и правило у него своё:
921
+ размер тела ограничивает ПЛОЩАДКА, и предел она объявляет на странице.
922
+ Признак у submit означал бы, что оба правила живут в одном месте и
923
+ различаются условием; условие однажды упростят.
924
+
925
+ ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ, как и у submit: повторить отправку по переходу
926
+ значило бы отправить файл второй раз.
927
+
928
+ Args:
929
+ path (str): Путь обращения.
930
+ field (str): Имя поля, в котором уходит файл.
931
+ filename (str): Имя файла, как его увидит площадка.
932
+ content (bytes): Содержимое файла.
933
+ content_type (str): Тип содержимого.
934
+ headers (dict[str, str]): Заголовки запроса, кроме Cookie.
935
+
936
+ Returns:
937
+ Observation: Результат обращения. Число переходов всегда ноль.
938
+
939
+ Raises:
940
+ TimeoutError: Если истёк предел ожидания.
941
+ NetworkError: При любом другом сетевом отказе.
942
+ RemoteServerError: Если ответ превысил предел размера.
943
+ """
944
+ url = _start_url(self._settings, path)
945
+ try:
946
+ response = await self._client.post(
947
+ url,
948
+ files={field: (filename, content, content_type)},
949
+ headers={**headers, **self._cookie()},
950
+ )
951
+ except httpx.HTTPError as exc:
952
+ raise _translate(exc, path) from exc
953
+
954
+ return _observe(
955
+ response,
956
+ settings=self._settings,
957
+ rejected_url=None,
958
+ redirects=0,
959
+ sent=1,
960
+ elapsed=response.elapsed.total_seconds(),
961
+ )
962
+
963
+ def _cookie(self) -> dict[str, str]:
964
+ """Собирает заголовок с сессионным секретом.
965
+
966
+ Returns:
967
+ dict[str, str]: Заголовок с секретом либо пустой набор для гостя.
968
+ """
969
+ if self._secret is None:
970
+ return {}
971
+ return {"Cookie": f"{self._cookie_name}={self._secret.reveal()}"}
972
+
973
+
974
+ def _start_url(settings: TransportSettings, path: str) -> str:
975
+ """Приводит путь к полному адресу.
976
+
977
+ Args:
978
+ settings (TransportSettings): Настройки транспорта.
979
+ path (str): Путь либо полный адрес.
980
+
981
+ Returns:
982
+ str: Полный адрес запроса.
983
+ """
984
+ return urljoin(settings.base_url, path)
985
+
986
+
987
+ def _header_int(response: httpx.Response, name: str) -> int | None:
988
+ """Читает целочисленный заголовок ответа.
989
+
990
+ Args:
991
+ response (httpx.Response): Ответ.
992
+ name (str): Имя заголовка.
993
+
994
+ Returns:
995
+ int | None: Значение либо None, если заголовка нет или он не число.
996
+ """
997
+ raw = response.headers.get(name)
998
+ if raw is None:
999
+ return None
1000
+ try:
1001
+ return int(raw.strip())
1002
+ except ValueError:
1003
+ return None
1004
+
1005
+
1006
+ def _retry_after_ms(response: httpx.Response) -> int | None:
1007
+ """Читает заголовок Retry-After в миллисекундах.
1008
+
1009
+ Разбирается только числовая форма, в секундах. Форма с датой не
1010
+ поддерживается намеренно: она требует доверия к часам площадки и к
1011
+ согласованности часовых поясов, а ошибка здесь выражается в неверной паузе -
1012
+ то есть в поведении, которое потом объясняют чем угодно, кроме заголовка.
1013
+
1014
+ Args:
1015
+ response (httpx.Response): Ответ.
1016
+
1017
+ Returns:
1018
+ int | None: Пауза в миллисекундах либо None.
1019
+ """
1020
+ seconds = _header_int(response, "retry-after")
1021
+ if seconds is None or seconds < 0:
1022
+ return None
1023
+ return seconds * 1000