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/observe.py ADDED
@@ -0,0 +1,560 @@
1
+ """Инструмент наблюдения за протоколом.
2
+
3
+ Это не SDK и не его часть. Это инструмент, который отвечает на четыре вопроса,
4
+ без ответа на которые спецификацию нельзя перевести из состояния draft:
5
+
6
+ 1. Отдаёт ли канал обновлений устойчивую позицию, по которой можно переигрывать
7
+ пропущенное. От ответа зависит, остаётся ли гарантия at-least-once или
8
+ раздел о событиях переписывается вокруг сверки состояния.
9
+ 2. Есть ли структурный признак системного сообщения, отличный от текста. Если
10
+ нет, покупатель может подделать событие оплаты и получить товар.
11
+ 3. Локализован ли интерфейс и меняются ли тексты системных сообщений при смене
12
+ языка. Смена языка ломает распознавание событий, не ломая отпечаток
13
+ страницы: отказ получается тихим.
14
+ 4. Как выглядит протухшая сессия, страница блокировки и страница проверки.
15
+
16
+ Что инструмент делает и чего не делает. Он выполняет одно чтение, классифицирует
17
+ ответ и сохраняет структурный скелет страницы вместе с описанием происхождения.
18
+ Он не выполняет операций записи, не сохраняет сырой HTML и не ходит по ссылкам
19
+ дальше запрошенной страницы.
20
+
21
+ Отдельно стоят два режима, которых требует устройство скелета.
22
+
23
+ Режим ``--compare``. Он нужен для первого вопроса: скелет прячет
24
+ значения, а счётчик и хеш состояния различаются не формой, а поведением во
25
+ времени. Режим читает страницу дважды, сравнивает значения в памяти и печатает
26
+ только характер изменения. Файлов он не создаёт вовсе.
27
+
28
+ Режим ``--relations`` решает то, что сравнением двух чтений не решается вовсе.
29
+ Смысл атрибута data-user-msg не выводится из наблюдения за одним диалогом: своя
30
+ отправка двигает его и при трактовке «последнее прочитанное», и при трактовке
31
+ «последнее написанное». По списку целиком версии расходятся, и режим считает
32
+ соотношения по всем диалогам сразу, не показывая ни одного значения.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import sys
40
+ from collections.abc import Callable
41
+ from datetime import UTC, datetime
42
+ from hashlib import blake2s
43
+ from pathlib import Path
44
+ from time import monotonic
45
+
46
+ from ._classify import DEFAULT_IDENTITY_CSS, ResponseClass, classify
47
+ from ._secret import EnvSecretProvider, FileSecretProvider, SecretNotFoundError, SecretProvider
48
+ from ._signals import collect, compare, format_relations, format_report, relations
49
+ from ._skeleton import SKELETON_FORMAT, SkeletonError, mask_path, skeletonize
50
+ from ._transport import Fetcher, Observation, TransportSettings
51
+
52
+ __all__ = ["main", "observe", "observe_compare", "observe_relations", "build_provenance"]
53
+
54
+
55
+ def build_provenance(
56
+ *,
57
+ path: str,
58
+ observation: Observation,
59
+ verdict_cls: str,
60
+ verdict_reason: str,
61
+ provisional: bool,
62
+ locale: str,
63
+ ) -> dict[str, object]:
64
+ """Собирает описание происхождения фикстуры.
65
+
66
+ Описание отвечает на вопрос «откуда это взялось» через полгода, когда
67
+ выяснится, что фикстура больше не соответствует странице. Без него
68
+ единственный способ это узнать - снимать заново.
69
+
70
+ Args:
71
+ path (str): Запрошенный путь.
72
+ observation (Observation): Результат обращения. Конечный URL записывается
73
+ обязательно: без него нельзя понять, куда привёл редирект, и запрос
74
+ английской версии выглядит успешным, хотя вернул русскую страницу.
75
+ verdict_cls (str): Класс ответа по классификатору.
76
+ verdict_reason (str): Причина решения классификатора.
77
+ provisional (bool): Было ли решение принято непроверенной сигнатурой.
78
+ locale (str): Локаль интерфейса, под которой сделано наблюдение.
79
+
80
+ Returns:
81
+ dict[str, object]: Описание, пригодное для сохранения рядом с фикстурой.
82
+ Персональных данных и содержимого страницы не содержит.
83
+ """
84
+ return {
85
+ "path": mask_path(path),
86
+ "final_url": mask_path(observation.final_url),
87
+ "captured_at": datetime.now(UTC).isoformat(timespec="seconds"),
88
+ "locale": locale,
89
+ "http_status": observation.status,
90
+ "redirects": observation.redirects,
91
+ "content_length": observation.content_length,
92
+ "elapsed_ms": observation.elapsed_ms,
93
+ "classification": verdict_cls,
94
+ "classification_reason": verdict_reason,
95
+ "classification_provisional": provisional,
96
+ "format": SKELETON_FORMAT,
97
+ # Формат, в котором снимок снят, записывается отдельно от текущего.
98
+ # Одного поля не хватало: файл, преобразованный из прежней редакции
99
+ # повторной маскировкой, выглядел неотличимо от снятого нативно, и
100
+ # расхождение между ними объяснялось сменой формата с тем же успехом,
101
+ # что и сменой разметки. Здесь они совпадают всегда - файл снят прямо
102
+ # сейчас; расходятся они только у преобразованных.
103
+ "captured_format": SKELETON_FORMAT,
104
+ "converted": False,
105
+ "note": (
106
+ "Структурный скелет: текст заменён подписями, сегменты путей с "
107
+ "идентификаторами обезличены. Сырой HTML не сохраняется."
108
+ ),
109
+ }
110
+
111
+
112
+ def _stem_for(path: str) -> str:
113
+ """Строит основу имени файла по запрошенному пути.
114
+
115
+ Идентификаторы в имя файла не попадают. Строка запроса заменяется коротким
116
+ необратимым отпечатком, а числовые сегменты пути - буквой n. Причин две.
117
+ Windows не допускает вопросительный знак в имени файла, поэтому путь вида
118
+ ``/chat/?node=123`` иначе просто не сохранился бы. И, что важнее, имена
119
+ файлов видны в списке репозитория, в истории и в результатах поиска, где
120
+ идентификатору переписки делать нечего.
121
+
122
+ Отпечаток нужен, чтобы снимки разных переписок не затирали друг друга: без
123
+ него оба легли бы в файл chat.ru.skeleton.txt.
124
+
125
+ Args:
126
+ path (str): Запрошенный путь, возможно со строкой запроса.
127
+
128
+ Returns:
129
+ str: Основа имени файла из букв, цифр, дефиса и подчёркивания.
130
+ """
131
+ body, _, query = path.partition("?")
132
+ segments = [seg for seg in body.strip("/").split("/") if seg]
133
+ cleaned: list[str] = []
134
+ for seg in segments:
135
+ if any(ch.isdigit() for ch in seg) or not seg.isascii():
136
+ cleaned.append("n")
137
+ continue
138
+ cleaned.append("".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in seg))
139
+ stem = "_".join(x for x in cleaned if x) or "root"
140
+ if query:
141
+ stem += "-" + blake2s(query.encode("utf-8"), digest_size=8).hexdigest()[:6]
142
+ return stem
143
+
144
+
145
+ def observe(
146
+ *,
147
+ path: str,
148
+ out_dir: Path,
149
+ provider: SecretProvider,
150
+ secret_name: str = "golden_key",
151
+ identity_css: str | None = DEFAULT_IDENTITY_CSS,
152
+ locale: str = "ru",
153
+ settings: TransportSettings | None = None,
154
+ ) -> int:
155
+ """Выполняет одно наблюдение и сохраняет результат.
156
+
157
+ Args:
158
+ path (str): Путь страницы относительно базового адреса.
159
+ out_dir (Path): Каталог для сохранения скелета и описания.
160
+ provider (SecretProvider): Источник сессионного секрета.
161
+ secret_name (str): Логическое имя секрета в источнике.
162
+ identity_css (str | None): Селектор маркера вошедшего пользователя.
163
+ locale (str): Локаль интерфейса, под которой делается наблюдение.
164
+ settings (TransportSettings | None): Настройки транспорта.
165
+
166
+ Returns:
167
+ int: Код возврата процесса: 0 - страница получена и пригодна для разбора,
168
+ 2 - получена, но классифицирована как перехватчик или неизвестная,
169
+ 1 - обращение не удалось.
170
+ """
171
+ cfg = settings or TransportSettings()
172
+
173
+ try:
174
+ secret = provider.get(secret_name)
175
+ except SecretNotFoundError as exc:
176
+ print(f"секрет недоступен: {exc}", file=sys.stderr)
177
+ return 1
178
+
179
+ try:
180
+ with Fetcher(secret, settings=cfg) as fetcher:
181
+ obs = fetcher.fetch(path)
182
+ except Exception as exc:
183
+ print(f"обращение не удалось: {type(exc).__name__}", file=sys.stderr)
184
+ return 1
185
+
186
+ host = cfg.base_url.split("//", 1)[-1].split("/", 1)[0]
187
+ verdict = classify(
188
+ status=obs.status,
189
+ final_url=obs.final_url,
190
+ html=obs.html,
191
+ expected_host=host,
192
+ identity_css=identity_css,
193
+ )
194
+
195
+ # ОТВЕТ «ТАКОГО АДРЕСА НЕТ» СНИМКОМ СТРАНИЦЫ НЕ ЯВЛЯЕТСЯ.
196
+ #
197
+ # Страница входа, страница проверки, отказ в доступе - всё это НАСТОЯЩИЕ
198
+ # страницы площадки, и снимать их надо: на снимке гостя стоит половина
199
+ # проверок классификатора.
200
+ #
201
+ # А 404 - это ответ о том, что адрес неверен, и разметка у него общая для
202
+ # всей площадки. Сохранённый, он занимает имя, выведенное из пути, и
203
+ # ЗАКРЫВАЕТ дорогу настоящему снимку: тот отличается содержимым, а
204
+ # затирать чужой снимок инструмент отказывается - и отказывается верно.
205
+ #
206
+ # Так и вышло 31.08.2026: неверный адрес дал 404, 404 занял имя, и
207
+ # повторная съёмка верного адреса упёрлась в собственный мусор.
208
+ if verdict.cls is ResponseClass.TRANSPORT_ERROR:
209
+ print(
210
+ f"НЕ СОХРАНЕНО: площадка ответила {obs.status} - {verdict.reason}. "
211
+ "Это ответ об АДРЕСЕ, а не страница: снимок из него занял бы имя, "
212
+ "выведенное из пути, и закрыл бы дорогу настоящему. Проверьте путь "
213
+ f"{path!r} - у страниц площадки косая черта на конце значима",
214
+ file=sys.stderr,
215
+ )
216
+ return 2
217
+
218
+ try:
219
+ skeleton = skeletonize(obs.html)
220
+ except SkeletonError as exc:
221
+ print(f"скелет не построен: {exc}", file=sys.stderr)
222
+ return 1
223
+
224
+ out_dir.mkdir(parents=True, exist_ok=True)
225
+ stem = f"{_stem_for(path)}.{locale}"
226
+
227
+ # ЧУЖОЙ СНИМОК НЕ ЗАТИРАЕТСЯ. Имя строится по пути с обезличенными
228
+ # сегментами, и потому у РАЗНЫХ страниц оно совпадает: /users/111/ и
229
+ # /users/222/ обе дают users_n.
230
+ #
231
+ # Снимок невосполним. Он снят в конкретную минуту, при конкретном состоянии
232
+ # аккаунта, и второй раз такой же не сделать: заказы закрываются, диалоги
233
+ # уходят вниз, лоты правятся. Затирание молча уничтожало бы свидетельство,
234
+ # на котором стоят правила извлечения.
235
+ #
236
+ # Совпадающее содержимое переписывается: повторная съёмка той же страницы -
237
+ # обычное дело, и мешать ей незачем.
238
+ target = out_dir / f"{stem}.skeleton.txt"
239
+ if target.is_file() and target.read_text(encoding="utf-8") != skeleton:
240
+ print(
241
+ f"НЕ СОХРАНЕНО: под именем {target.name} уже лежит ДРУГОЙ снимок. "
242
+ "Имя строится по пути, а идентификаторы в нём обезличены, поэтому у "
243
+ "разных страниц имя совпадает. Снимок невосполним: он снят в "
244
+ "конкретную минуту и вторым таким же не будет. "
245
+ f"Снимайте в свой каталог: --out {out_dir}\\имя-снимка",
246
+ file=sys.stderr,
247
+ )
248
+ return 1
249
+
250
+ target.write_text(skeleton, encoding="utf-8", newline="\n")
251
+ provenance = build_provenance(
252
+ path=path,
253
+ observation=obs,
254
+ verdict_cls=str(verdict.cls),
255
+ verdict_reason=verdict.reason,
256
+ provisional=verdict.provisional,
257
+ locale=locale,
258
+ )
259
+ (out_dir / f"{stem}.provenance.json").write_text(
260
+ json.dumps(provenance, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
261
+ encoding="utf-8",
262
+ newline="\n",
263
+ )
264
+
265
+ print(f"класс ответа: {verdict.cls}")
266
+ print(f"причина: {verdict.reason}")
267
+ if verdict.provisional:
268
+ print("ВНИМАНИЕ: решение принято непроверенной сигнатурой, подтвердите вручную")
269
+ print(f"код HTTP: {obs.status}, переходов: {obs.redirects}")
270
+ print(f"скелет: {out_dir / (stem + '.skeleton.txt')}")
271
+ print(f"происхождение: {out_dir / (stem + '.provenance.json')}")
272
+
273
+ return 0 if verdict.is_ok else 2
274
+
275
+
276
+ def observe_compare(
277
+ *,
278
+ path: str,
279
+ provider: SecretProvider,
280
+ secret_name: str = "golden_key",
281
+ identity_css: str | None = DEFAULT_IDENTITY_CSS,
282
+ settings: TransportSettings | None = None,
283
+ wait: Callable[[str], str] = input,
284
+ ) -> int:
285
+ """Читает страницу дважды и сообщает, какие значения изменились.
286
+
287
+ Режим отвечает на вопрос, на который структурный скелет ответить не может.
288
+ Скелет прячет значения, а монотонный счётчик и хеш состояния различаются
289
+ только поведением во времени: у счётчика значение растёт на известную
290
+ величину, у хеша меняется без направления. От этого различия зависит,
291
+ остаётся ли в контракте гарантия доставки событий at-least-once.
292
+
293
+ Значения живут только в памяти процесса и на диск не попадают. Наружу
294
+ выходит характер изменения и величина шага, но не сами значения. Файлов
295
+ режим не создаёт вовсе.
296
+
297
+ Длительность паузы печатается вместе с отчётом. Без неё величина шага не
298
+ переводится в скорость выдачи идентификаторов, а именно эта скорость нужна,
299
+ чтобы подобрать частоту опроса, не полагаясь на догадку.
300
+
301
+ Args:
302
+ path (str): Путь страницы относительно базового адреса.
303
+ provider (SecretProvider): Источник сессионного секрета.
304
+ secret_name (str): Логическое имя секрета в источнике.
305
+ identity_css (str | None): Селектор маркера вошедшего пользователя.
306
+ settings (TransportSettings | None): Настройки транспорта.
307
+ wait (Callable[[str], str]): Как дождаться действия между чтениями.
308
+ Аргумент вынесен ради тестов, в которых паузы быть не должно.
309
+
310
+ Returns:
311
+ int: Код возврата процесса: 0 - сравнение выполнено, 2 - одно из чтений
312
+ не дало пригодной страницы, 1 - обращение не удалось.
313
+ """
314
+ cfg = settings or TransportSettings()
315
+ host = cfg.base_url.split("//", 1)[-1].split("/", 1)[0]
316
+
317
+ try:
318
+ secret = provider.get(secret_name)
319
+ except SecretNotFoundError as exc:
320
+ print(f"секрет недоступен: {exc}", file=sys.stderr)
321
+ return 1
322
+
323
+ def read(fetcher: Fetcher, ordinal: str) -> dict[tuple[str, str], str] | None:
324
+ """Выполняет одно чтение и извлекает сравниваемые значения.
325
+
326
+ Args:
327
+ fetcher (Fetcher): Открытый транспорт.
328
+ ordinal (str): Название чтения для сообщений.
329
+
330
+ Returns:
331
+ dict[tuple[str, str], str] | None: Значения или None, если страница
332
+ непригодна.
333
+ """
334
+ obs = fetcher.fetch(path)
335
+ verdict = classify(
336
+ status=obs.status,
337
+ final_url=obs.final_url,
338
+ html=obs.html,
339
+ expected_host=host,
340
+ identity_css=identity_css,
341
+ )
342
+ if not verdict.is_ok:
343
+ print(
344
+ f"{ordinal} чтение непригодно: {verdict.cls} ({verdict.reason})",
345
+ file=sys.stderr,
346
+ )
347
+ return None
348
+ values = collect(obs.html)
349
+ print(f"{ordinal} чтение: значений отслеживается {len(values)}")
350
+ return values
351
+
352
+ try:
353
+ with Fetcher(secret, settings=cfg) as fetcher:
354
+ before = read(fetcher, "первое")
355
+ if before is None:
356
+ return 2
357
+
358
+ started = monotonic()
359
+ print()
360
+ print("Сделайте на площадке изменение, которое хотите проверить, и нажмите Enter.")
361
+ print("Например: получите сообщение в переписке или прочитайте непрочитанное.")
362
+ try:
363
+ wait("")
364
+ except EOFError:
365
+ print("ввод недоступен, сравнение отменено", file=sys.stderr)
366
+ return 1
367
+ paused = monotonic() - started
368
+
369
+ after = read(fetcher, "второе")
370
+ if after is None:
371
+ return 2
372
+ except Exception as exc:
373
+ print(f"обращение не удалось: {type(exc).__name__}", file=sys.stderr)
374
+ return 1
375
+
376
+ print()
377
+ print(f" пауза между чтениями: {paused:.0f} с")
378
+ print(format_report(compare(before, after)))
379
+ return 0
380
+
381
+
382
+ def observe_relations(
383
+ *,
384
+ path: str,
385
+ provider: SecretProvider,
386
+ secret_name: str = "golden_key",
387
+ identity_css: str | None = DEFAULT_IDENTITY_CSS,
388
+ settings: TransportSettings | None = None,
389
+ ) -> int:
390
+ """Читает страницу один раз и сообщает соотношения между позициями.
391
+
392
+ Режим отвечает на вопрос, который сравнением двух чтений не решается.
393
+ Атрибут data-user-msg может означать «последнее прочитанное этим аккаунтом»
394
+ либо «последнее написанное этим аккаунтом», и своя отправка двигает его при
395
+ обеих трактовках, поэтому наблюдение за одним диалогом версии не разводит.
396
+ По списку целиком разводит: если счётчик непрочитанного пуст, а позиции
397
+ расходятся у многих диалогов, отметкой прочтения поле быть не может.
398
+
399
+ Наружу выходят только количества. Ни одно значение атрибута не печатается и
400
+ на диск не попадает.
401
+
402
+ Args:
403
+ path (str): Путь страницы списка диалогов.
404
+ provider (SecretProvider): Источник сессионного секрета.
405
+ secret_name (str): Логическое имя секрета в источнике.
406
+ identity_css (str | None): Селектор маркера вошедшего пользователя.
407
+ settings (TransportSettings | None): Настройки транспорта.
408
+
409
+ Returns:
410
+ int: Код возврата процесса: 0 - страница получена и пригодна для разбора,
411
+ 2 - получена, но классифицирована как перехватчик или неизвестная,
412
+ 1 - обращение не удалось.
413
+ """
414
+ cfg = settings or TransportSettings()
415
+ host = cfg.base_url.split("//", 1)[-1].split("/", 1)[0]
416
+
417
+ try:
418
+ secret = provider.get(secret_name)
419
+ except SecretNotFoundError as exc:
420
+ print(f"секрет недоступен: {exc}", file=sys.stderr)
421
+ return 1
422
+
423
+ try:
424
+ with Fetcher(secret, settings=cfg) as fetcher:
425
+ obs = fetcher.fetch(path)
426
+ except Exception as exc:
427
+ print(f"обращение не удалось: {type(exc).__name__}", file=sys.stderr)
428
+ return 1
429
+
430
+ verdict = classify(
431
+ status=obs.status,
432
+ final_url=obs.final_url,
433
+ html=obs.html,
434
+ expected_host=host,
435
+ identity_css=identity_css,
436
+ )
437
+ if not verdict.is_ok:
438
+ print(f"страница непригодна: {verdict.cls} ({verdict.reason})", file=sys.stderr)
439
+ return 2
440
+
441
+ print(format_relations(relations(obs.html)))
442
+ return 0
443
+
444
+
445
+ def main(argv: list[str] | None = None) -> int:
446
+ """Точка входа командной строки.
447
+
448
+ Args:
449
+ argv (list[str] | None): Аргументы командной строки. По умолчанию
450
+ берутся из sys.argv.
451
+
452
+ Returns:
453
+ int: Код возврата процесса.
454
+ """
455
+ parser = argparse.ArgumentParser(
456
+ prog="funora-observe",
457
+ description=(
458
+ "Однократное наблюдение за страницей площадки. Сохраняет структурный "
459
+ "скелет, сырой HTML не сохраняется никогда."
460
+ ),
461
+ )
462
+ parser.add_argument(
463
+ "path",
464
+ nargs="+",
465
+ help=(
466
+ "путь страницы, например /orders/trade. Можно указать несколько: "
467
+ "они снимаются подряд одним запуском"
468
+ ),
469
+ )
470
+ parser.add_argument("--out", type=Path, default=Path("observations"), help="каталог результата")
471
+ parser.add_argument(
472
+ "--secret-file",
473
+ type=Path,
474
+ default=None,
475
+ help=(
476
+ "файл с ключом либо каталог, где он лежит; "
477
+ "по умолчанию ключ читается из переменной окружения"
478
+ ),
479
+ )
480
+ parser.add_argument("--secret-name", default="golden_key", help="имя секрета")
481
+ parser.add_argument(
482
+ "--identity-css",
483
+ default=DEFAULT_IDENTITY_CSS,
484
+ help="селектор маркера вошедшего пользователя",
485
+ )
486
+ parser.add_argument("--locale", default="ru", help="локаль интерфейса наблюдения")
487
+ parser.add_argument("--base-url", default=TransportSettings().base_url, help="базовый адрес")
488
+ parser.add_argument(
489
+ "--compare",
490
+ action="store_true",
491
+ help=(
492
+ "два чтения страницы с паузой между ними; печатает, какие значения "
493
+ "изменились и на сколько. Файлы в этом режиме не создаются"
494
+ ),
495
+ )
496
+
497
+ parser.add_argument(
498
+ "--relations",
499
+ action="store_true",
500
+ help=(
501
+ "одно чтение; печатает, у скольких диалогов позиции совпадают, и "
502
+ "состояние счётчика непрочитанного. Значений не показывает и файлов "
503
+ "не создаёт"
504
+ ),
505
+ )
506
+
507
+ args = parser.parse_args(argv)
508
+
509
+ provider: SecretProvider
510
+ if args.secret_file is not None:
511
+ provider = FileSecretProvider(args.secret_file)
512
+ else:
513
+ provider = EnvSecretProvider()
514
+
515
+ settings = TransportSettings(base_url=args.base_url)
516
+
517
+ # Режимы разбора работают с одной страницей: они сравнивают её саму с собой
518
+ # во времени, и вторая страница в таком сравнении не участвует.
519
+ if (args.relations or args.compare) and len(args.path) > 1:
520
+ print("режимы --relations и --compare работают с одной страницей", file=sys.stderr)
521
+ return 2
522
+
523
+ if args.relations:
524
+ return observe_relations(
525
+ path=args.path[0],
526
+ provider=provider,
527
+ secret_name=args.secret_name,
528
+ identity_css=args.identity_css,
529
+ settings=settings,
530
+ )
531
+ if args.compare:
532
+ return observe_compare(
533
+ path=args.path[0],
534
+ provider=provider,
535
+ secret_name=args.secret_name,
536
+ identity_css=args.identity_css,
537
+ settings=settings,
538
+ )
539
+ worst = 0
540
+ for index, path in enumerate(args.path):
541
+ if index:
542
+ print()
543
+ code = observe(
544
+ path=path,
545
+ out_dir=args.out,
546
+ provider=provider,
547
+ secret_name=args.secret_name,
548
+ identity_css=args.identity_css,
549
+ locale=args.locale,
550
+ settings=settings,
551
+ )
552
+ # Отказ на одной странице не отменяет остальные: снимки независимы, а
553
+ # прервать цикл значило бы заставить человека повторять всё сначала.
554
+ # Код возврата при этом худший из полученных.
555
+ worst = max(worst, code)
556
+ return worst
557
+
558
+
559
+ if __name__ == "__main__":
560
+ raise SystemExit(main())