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/_whoami.py ADDED
@@ -0,0 +1,546 @@
1
+ """Чтение аккаунта, проверка сессии и профиль возможностей.
2
+
3
+ Три операции об одном: от чьего имени работает клиент и годна ли его сессия.
4
+
5
+ Имя модуля - _whoami, а не _identity: последнее занято сетевой идентичностью,
6
+ то есть парой «исходящий адрес и целевой хост». Разные вещи с похожими именами
7
+ стоят рядом, и путать их дорого: одно про то, кем нас видит площадка, другое про
8
+ то, откуда мы к ней приходим.
9
+
10
+ ЧИТАЕТСЯ СТРАНИЦА С ВИДЖЕТОМ ПЕРЕПИСКИ, и это не безразличная подробность.
11
+ Собственный идентификатор лежит в атрибуте data-user, а атрибут этот есть только
12
+ там, где виджет есть: на списке продаж и на странице баланса его нет вовсе. С
13
+ любой авторизованной страницы прочесть себя нельзя.
14
+
15
+ ИМЯ И ИДЕНТИФИКАТОР ЧИТАЮТСЯ ПО-РАЗНОМУ. Идентификатор - из атрибута, имя - из
16
+ текста в шапке, и узлов имени ДВА: настольное меню и мобильное. Значение в них
17
+ одно и то же, и разбор обязан их сверить: разошлись - разметка изменилась.
18
+
19
+ ПРОФИЛЬ ВОЗМОЖНОСТЕЙ СОБИРАЕТСЯ БЕЗ СЕТИ. Он отвечает на «что этот клиент умеет
20
+ прямо сейчас», а это уже известно из того, что наблюдалось. Неуспешная проба
21
+ даёт unknown, а не unsupported: отсутствие объявляется только по положительному
22
+ свидетельству, и неудача таковым не является.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from dataclasses import dataclass, field
29
+ from datetime import UTC, datetime
30
+ from threading import RLock
31
+ from typing import Final, Literal
32
+
33
+ from selectolax.parser import HTMLParser, Node
34
+
35
+ from ._classify import ResponseClass, Verdict
36
+ from ._observed import Observed
37
+ from ._result import Defect, Severity
38
+ from ._secret import Secret
39
+ from .capabilities import CAPABILITY_INITIAL, Capability, CapabilityState
40
+ from .errors import ProtocolChangedError
41
+ from .extraction import SELECTORS
42
+
43
+ __all__ = [
44
+ "Account",
45
+ "AppData",
46
+ "CapabilityProfile",
47
+ "CapabilityEvaluation",
48
+ "SessionHealth",
49
+ "parse_account",
50
+ "parse_app_data",
51
+ ]
52
+
53
+ _OWN_ID: Final[str] = SELECTORS["session.identity.own_user_id"]
54
+ _OWN_NAME: Final[str] = SELECTORS["session.identity.own_username"]
55
+ _APP_DATA: Final[str] = SELECTORS["session.identity.app_data"]
56
+
57
+ #: Имя ключа защитного токена внутри объекта настроек страницы.
58
+ _CSRF_KEY: Final[str] = "csrf-token"
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class Account:
63
+ """Аккаунт, от имени которого работает клиент.
64
+
65
+ Балансов здесь нет намеренно: они лежат на другой странице, и читать её ради
66
+ профиля значило бы ходить на площадку дважды за одним ответом. Отсутствие
67
+ поля означает «не наблюдали», а не «нулю равно».
68
+
69
+ Attributes:
70
+ user_id (Observed[str]): Собственный идентификатор из атрибута.
71
+ username (Observed[str]): Собственное отображаемое имя.
72
+ locale (Observed[str]): Метка языка страницы.
73
+ observed_at (datetime): Момент наблюдения.
74
+ defects (tuple[Defect, ...]): Замеченные повреждения.
75
+ """
76
+
77
+ user_id: Observed[str]
78
+ username: Observed[str]
79
+ locale: Observed[str]
80
+ observed_at: datetime
81
+ defects: tuple[Defect, ...] = ()
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class SessionHealth:
86
+ """Пригодность сессии.
87
+
88
+ Attributes:
89
+ response_class (ResponseClass): Класс ответа площадки.
90
+ is_usable (bool): Годится ли сессия для работы прямо сейчас.
91
+ reason (str): Машиночитаемая причина решения классификатора.
92
+ provisional (bool): Принято ли решение непроверенной сигнатурой.
93
+ checked_at (datetime): Момент проверки.
94
+ from_cache (bool): Отдан ли ответ из кэша, а не получен запросом.
95
+ unsafe_marks (frozenset[str]): Какие защиты вызывающий снял с себя сам.
96
+
97
+ Поле заведено потому, что отметки ставились и не читались. Понижение
98
+ нижнего предела опроса помечалось внутри расписания с самого его
99
+ появления, и не смотрел на эту пометку никто: защита снималась, след
100
+ оставался, и вёл он в никуда.
101
+
102
+ Перечень имён закрыт контрактом. Снять защиту молча нельзя: новое
103
+ послабление обязано сперва получить имя в спецификации.
104
+ """
105
+
106
+ response_class: ResponseClass
107
+ is_usable: bool
108
+ reason: str
109
+ provisional: bool
110
+ checked_at: datetime
111
+ from_cache: bool
112
+ unsafe_marks: frozenset[str] = frozenset()
113
+
114
+ @classmethod
115
+ def of(
116
+ cls,
117
+ verdict: Verdict,
118
+ checked_at: datetime,
119
+ *,
120
+ from_cache: bool,
121
+ unsafe_marks: frozenset[str] = frozenset(),
122
+ ) -> SessionHealth:
123
+ """Собирает ответ из вердикта классификатора.
124
+
125
+ Годной сессия считается РОВНО при классе ok. Прочие классы означают
126
+ разное, но ни один из них не означает «можно работать»: правило «всё,
127
+ кроме явного отказа, годится» однажды приняло бы страницу проверки за
128
+ рабочую.
129
+
130
+ Args:
131
+ verdict (Verdict): Вердикт классификатора.
132
+ checked_at (datetime): Момент проверки.
133
+ from_cache (bool): Отдан ли ответ из кэша.
134
+ unsafe_marks (frozenset[str]): Снятые вызывающим защиты.
135
+
136
+ Returns:
137
+ SessionHealth: Пригодность сессии.
138
+ """
139
+ return cls(
140
+ response_class=verdict.cls,
141
+ is_usable=verdict.cls is ResponseClass.OK,
142
+ reason=verdict.reason,
143
+ provisional=verdict.provisional,
144
+ checked_at=checked_at,
145
+ from_cache=from_cache,
146
+ unsafe_marks=unsafe_marks,
147
+ )
148
+
149
+
150
+ @dataclass(frozen=True, slots=True)
151
+ class CapabilityEvaluation:
152
+ """Основание состояния одной возможности.
153
+
154
+ evaluated_at - время оценки, которое чтение профиля не обновляет.
155
+ source описывает свидетельство: таблица адаптера, результат операции или
156
+ сброс после отказа. Это не source из реестра способов определения функций.
157
+ """
158
+
159
+ state: CapabilityState
160
+ evaluated_at: datetime
161
+ source: Literal["static", "probe", "observed_failure"]
162
+
163
+
164
+ @dataclass(frozen=True, slots=True)
165
+ class CapabilityProfile:
166
+ """Снимок возможностей без сети.
167
+
168
+ observed_at - время сборки снимка. Время и основание каждой оценки
169
+ доступны отдельно через evaluation_of; получение снимка их не освежает.
170
+ """
171
+
172
+ observed_at: datetime
173
+ _evaluations: dict[Capability, CapabilityEvaluation] = field(repr=False, default_factory=dict)
174
+
175
+ def states(self) -> dict[Capability, CapabilityState]:
176
+ """Возвращает независимую копию состояний всех возможностей."""
177
+ return {key: value.state for key, value in self._evaluations.items()}
178
+
179
+ def state_of(self, capability: Capability) -> CapabilityState:
180
+ """Возвращает состояние; отсутствующий ключ означает дефект профиля."""
181
+ return self._evaluations[capability].state
182
+
183
+ def evaluations(self) -> dict[Capability, CapabilityEvaluation]:
184
+ """Возвращает копию оценок с их основаниями и временем."""
185
+ return dict(self._evaluations)
186
+
187
+ def evaluation_of(self, capability: Capability) -> CapabilityEvaluation:
188
+ """Возвращает неизменяемую оценку одной возможности."""
189
+ return self._evaluations[capability]
190
+
191
+
192
+ class _CapabilityStates:
193
+ """Хранит состояние и его основание одной неделимой записью.
194
+
195
+ Обновление из существующих операций остаётся индексным присваиванием.
196
+ Снимок и сброс защищены той же блокировкой, чтобы параллельное чтение
197
+ профиля не соединяло состояние до сброса с его новым основанием.
198
+ """
199
+
200
+ def __init__(self) -> None:
201
+ self._lock = RLock()
202
+ self._evaluations: dict[Capability, CapabilityEvaluation] = {}
203
+ self.reset()
204
+
205
+ def __getitem__(self, capability: Capability) -> CapabilityState:
206
+ with self._lock:
207
+ return self._evaluations[capability].state
208
+
209
+ def __setitem__(self, capability: Capability, state: CapabilityState) -> None:
210
+ with self._lock:
211
+ self._evaluations[capability] = CapabilityEvaluation(state, datetime.now(UTC), "probe")
212
+
213
+ def snapshot(self) -> CapabilityProfile:
214
+ with self._lock:
215
+ return CapabilityProfile(datetime.now(UTC), dict(self._evaluations))
216
+
217
+ def reset(self) -> None:
218
+ with self._lock:
219
+ now = datetime.now(UTC)
220
+ self._evaluations = {
221
+ cap: CapabilityEvaluation(state, now, "static")
222
+ for cap, state in CAPABILITY_INITIAL.items()
223
+ }
224
+
225
+ def invalidate(self) -> None:
226
+ with self._lock:
227
+ now = datetime.now(UTC)
228
+ self._evaluations = {
229
+ cap: CapabilityEvaluation(
230
+ # Отказ не снимает запрет или требование согласия из контракта.
231
+ initial if not initial.usable else CapabilityState.UNKNOWN,
232
+ now,
233
+ "observed_failure",
234
+ )
235
+ for cap, initial in CAPABILITY_INITIAL.items()
236
+ }
237
+
238
+
239
+ def _text(node: Node | None, name: str) -> Observed[str]:
240
+ """Извлекает текст узла как наблюдение.
241
+
242
+ Args:
243
+ node (Node | None): Узел либо None.
244
+ name (str): Имя поля для причины отсутствия.
245
+
246
+ Returns:
247
+ Observed[str]: Наблюдение.
248
+ """
249
+ if node is None:
250
+ return Observed.missing(f"selector_no_match:{name}")
251
+ value = " ".join((node.text() or "").split())
252
+ return Observed.present(value) if value else Observed.empty("")
253
+
254
+
255
+ def _attribute(node: Node | None, name: str, field_name: str) -> Observed[str]:
256
+ """Читает атрибут, различая три исхода.
257
+
258
+ Args:
259
+ node (Node | None): Узел либо None.
260
+ name (str): Имя атрибута.
261
+ field_name (str): Имя поля для причины отсутствия.
262
+
263
+ Returns:
264
+ Observed[str]: Наблюдение.
265
+ """
266
+ if node is None:
267
+ return Observed.missing(f"selector_no_match:{field_name}")
268
+ attributes = node.attributes or {}
269
+ if name not in attributes:
270
+ return Observed.missing(f"attribute_absent:{field_name}")
271
+ value = (attributes.get(name) or "").strip()
272
+ return Observed.present(value) if value else Observed.empty("")
273
+
274
+
275
+ def _username(tree: HTMLParser) -> tuple[Observed[str], list[Defect]]:
276
+ """Читает собственное имя и сверяет два его носителя.
277
+
278
+ Узлов имени два - настольное меню и мобильное, - и значение в них одно и то
279
+ же. Взять первый попавшийся значило бы повторить ошибку, на которой уже
280
+ спотыкались разбор списка продаж и разбор отзывов.
281
+
282
+ Args:
283
+ tree (HTMLParser): Разобранная страница.
284
+
285
+ Returns:
286
+ tuple[Observed[str], list[Defect]]: Имя и перечень повреждений.
287
+ """
288
+ nodes = tree.css(_OWN_NAME)
289
+ if not nodes:
290
+ return Observed.missing(f"selector_no_match:{_OWN_NAME}"), []
291
+
292
+ values = {" ".join((one.text() or "").split()) for one in nodes}
293
+ if len(values) > 1:
294
+ return (
295
+ Observed.missing("username_carriers_disagree"),
296
+ [
297
+ Defect(
298
+ severity=Severity.PAGE,
299
+ code="username_carriers_disagree",
300
+ detail=(
301
+ f"узлы имени разошлись, прочитано {sorted(values)}. "
302
+ "Взять любое значило бы выбрать наугад"
303
+ ),
304
+ field_name="username",
305
+ )
306
+ ],
307
+ )
308
+
309
+ value = values.pop()
310
+ return (Observed.present(value) if value else Observed.empty("")), []
311
+
312
+
313
+ def parse_account(html: str, observed_at: datetime) -> Account:
314
+ """Разбирает страницу и собирает сведения о собственном аккаунте.
315
+
316
+ Args:
317
+ html (str): Тело страницы. Предполагается уже классифицированным как
318
+ пригодное: состояние сессии здесь не проверяется.
319
+ observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
320
+ разбор оставался чистым и повторяемым на сохранённом снимке.
321
+
322
+ Returns:
323
+ Account: Собственный идентификатор, имя и метка языка.
324
+
325
+ Raises:
326
+ ProtocolChangedError: Если на странице нет ни одного признака собственной
327
+ личности. Пустой ответ вернуть нельзя: он неотличим от страницы,
328
+ отданной не нам.
329
+ """
330
+ tree = HTMLParser(html)
331
+
332
+ carrier = tree.css_first(_OWN_ID)
333
+ if carrier is None and not tree.css(_OWN_NAME):
334
+ raise ProtocolChangedError(
335
+ f"на странице нет ни носителя собственного идентификатора ({_OWN_ID}), "
336
+ f"ни собственного имени ({_OWN_NAME}). Пустой ответ вернуть нельзя: "
337
+ "он неотличим от страницы, отданной не нам"
338
+ )
339
+
340
+ username, defects = _username(tree)
341
+
342
+ if carrier is None:
343
+ # Атрибут есть не на всякой странице: там, где виджета переписки нет,
344
+ # нет и его. Это не поломка - это выбор страницы, и он записан
345
+ # повреждением, чтобы вызывающий не принял пробел за отсутствие
346
+ # идентификатора у аккаунта.
347
+ defects.append(
348
+ Defect(
349
+ severity=Severity.PAGE,
350
+ code="own_id_carrier_missing",
351
+ detail=(
352
+ f"на странице нет носителя собственного идентификатора ({_OWN_ID}). "
353
+ "Он есть только там, где есть виджет переписки"
354
+ ),
355
+ field_name="user_id",
356
+ )
357
+ )
358
+
359
+ root = tree.css_first("html")
360
+ return Account(
361
+ user_id=_attribute(carrier, "data-user", "user_id"),
362
+ username=username,
363
+ locale=_attribute(root, "lang", "locale"),
364
+ observed_at=observed_at,
365
+ defects=tuple(defects),
366
+ )
367
+
368
+
369
+ @dataclass(frozen=True, slots=True)
370
+ class AppData:
371
+ """Настройки страницы из атрибута data-app-data.
372
+
373
+ ЗДЕСЬ ЛЕЖИТ ЗАЩИТНЫЙ ТОКЕН, без которого не собрать ни одного запроса
374
+ записи. Отдаётся он обёрнутым в Secret: значение видно только по явному
375
+ вызову reveal, и ни в repr, ни в журнал, ни в сообщение об ошибке не
376
+ попадает. Строкой его не отдают нарочно - подстановка в f-строку случайна,
377
+ вызов reveal виден при чтении кода.
378
+
379
+ Идентификатор и метка языка лежат в том же объекте ВТОРЫМИ носителями: их
380
+ же несут атрибут data-user и html[lang]. Сверка носителей - дело
381
+ вызывающего разбора, здесь читается ровно то, что написано.
382
+
383
+ Attributes:
384
+ csrf_token (Secret | None): Защитный токен. None, если не прочитан.
385
+ user_id (Observed[str]): Собственный идентификатор из объекта.
386
+ locale (Observed[str]): Метка языка из объекта.
387
+ upload_size_max (Observed[int]): Наибольший размер выгружаемого файла в
388
+ байтах, КАК ЕГО ОБЪЯВИЛА ПЛОЩАДКА.
389
+
390
+ Читается, а не выдумывается, и в этом весь смысл поля. Свой предел
391
+ отверг бы то, что площадка приняла бы, - и отверг бы молча, на
392
+ стороне вызывающего, которому нечего было бы возразить.
393
+ defects (tuple[Defect, ...]): Замеченные повреждения.
394
+ """
395
+
396
+ csrf_token: Secret | None
397
+ user_id: Observed[str]
398
+ locale: Observed[str]
399
+ upload_size_max: Observed[int] = field(default_factory=lambda: Observed.missing("not_read"))
400
+ defects: tuple[Defect, ...] = ()
401
+
402
+
403
+ def _json_field(data: dict[str, object], key: str, field_name: str) -> Observed[str]:
404
+ """Читает строковое поле объекта настроек как наблюдение.
405
+
406
+ Args:
407
+ data (dict[str, object]): Разобранный объект настроек.
408
+ key (str): Имя ключа в объекте.
409
+ field_name (str): Имя поля для причины отсутствия.
410
+
411
+ Returns:
412
+ Observed[str]: Наблюдение.
413
+ """
414
+ if key not in data:
415
+ return Observed.missing(f"key_absent:{field_name}")
416
+ value = data[key]
417
+ if not isinstance(value, str):
418
+ # Число здесь читалось бы как строка молча, и разница вышла бы наружу
419
+ # только при сравнении с другим носителем.
420
+ return Observed.missing(f"key_not_a_string:{field_name}")
421
+ value = value.strip()
422
+ return Observed.present(value) if value else Observed.empty("")
423
+
424
+
425
+ def _upload_limit(parsed: dict[str, object]) -> Observed[int]:
426
+ """Читает объявленный площадкой предел размера файла.
427
+
428
+ ПОЛЕ НАШЛОСЬ 31.08.2026, и до того перечень ключей этого объекта числился
429
+ полным из пяти. Ключей шесть, и шестой - именно этот.
430
+
431
+ Аргументы:
432
+ parsed (dict[str, object]): Разобранный объект настроек.
433
+
434
+ Возвращает:
435
+ Observed[int]: Предел в байтах либо причина отсутствия.
436
+ """
437
+ options = parsed.get("uploadOptions")
438
+ if not isinstance(options, dict):
439
+ return Observed.missing("upload_options_absent")
440
+
441
+ raw = options.get("fileSizeMax")
442
+ # Строкой оно приходит на настоящей странице, числом - могло бы; принимаются
443
+ # оба, а логическое исключается отдельно: истина в Python это единица.
444
+ if isinstance(raw, bool):
445
+ return Observed.missing("upload_size_max_not_a_number")
446
+ if isinstance(raw, int):
447
+ value = raw
448
+ elif isinstance(raw, str) and raw.strip().isdigit():
449
+ value = int(raw.strip())
450
+ else:
451
+ return Observed.missing("upload_size_max_not_a_number")
452
+
453
+ # Ноль и отрицательное отвергаются: предел, запрещающий любой файл, скорее
454
+ # означает, что мы прочитали не то поле, чем что площадка запретила выгрузку.
455
+ if value <= 0:
456
+ return Observed.missing("upload_size_max_not_positive")
457
+ return Observed.present(value)
458
+
459
+
460
+ def parse_app_data(html: str) -> AppData:
461
+ """Разбирает объект настроек страницы и достаёт защитный токен.
462
+
463
+ НЕ ПАДАЕТ НИ НА ЧЁМ. Атрибута может не быть, он может не разобраться, в нём
464
+ может не быть нужного ключа - и всё это разные обстоятельства с разными
465
+ причинами. Отказ здесь превратил бы «токена нет» в «страница сломана», а это
466
+ решается по-разному: первое ведёт к перезаходу, второе к разбирательству с
467
+ разметкой.
468
+
469
+ Отдельно стоит случай СТАРОГО СНИМКА. До формата скелета v8 атрибут
470
+ маскировался целиком, одной подписью, и разобрать его как JSON нельзя. Это
471
+ не поломка площадки, а возраст снимка, и причина названа отдельно.
472
+
473
+ Args:
474
+ html (str): Тело страницы.
475
+
476
+ Returns:
477
+ AppData: Токен, идентификатор и метка языка из объекта настроек.
478
+ """
479
+ tree = HTMLParser(html)
480
+ carrier = tree.css_first(_APP_DATA)
481
+ if carrier is None:
482
+ return AppData(
483
+ csrf_token=None,
484
+ user_id=Observed.missing("app_data_carrier_missing"),
485
+ locale=Observed.missing("app_data_carrier_missing"),
486
+ defects=(
487
+ Defect(
488
+ severity=Severity.PAGE,
489
+ code="app_data_carrier_missing",
490
+ detail=f"на странице нет носителя настроек ({_APP_DATA})",
491
+ field_name="csrf_token",
492
+ ),
493
+ ),
494
+ )
495
+
496
+ raw = (carrier.attributes or {}).get("data-app-data") or ""
497
+ try:
498
+ parsed: object = json.loads(raw)
499
+ except ValueError:
500
+ parsed = None
501
+
502
+ if not isinstance(parsed, dict):
503
+ masked = raw.startswith("T") and ":" in raw
504
+ code = "app_data_masked_by_skeleton" if masked else "app_data_not_json"
505
+ detail = (
506
+ "атрибут настроек замаскирован скелетом целиком: снимок сделан "
507
+ "форматом старше v8, и ключей объекта в нём нет"
508
+ if masked
509
+ else "атрибут настроек не разбирается как объект JSON"
510
+ )
511
+ return AppData(
512
+ csrf_token=None,
513
+ user_id=Observed.missing(code),
514
+ locale=Observed.missing(code),
515
+ defects=(
516
+ Defect(severity=Severity.PAGE, code=code, detail=detail, field_name="csrf_token"),
517
+ ),
518
+ )
519
+
520
+ defects: list[Defect] = []
521
+ token: Secret | None = None
522
+ value = parsed.get(_CSRF_KEY)
523
+ if not isinstance(value, str) or not value.strip():
524
+ # Значения токена в сообщении нет и быть не может: оно часть сессии
525
+ # владельца. Названо только то, чего не хватило.
526
+ defects.append(
527
+ Defect(
528
+ severity=Severity.PAGE,
529
+ code="csrf_token_absent",
530
+ detail=(
531
+ f"в объекте настроек нет пригодного ключа {_CSRF_KEY}. "
532
+ f"Прочитанные ключи: {sorted(parsed)}"
533
+ ),
534
+ field_name="csrf_token",
535
+ )
536
+ )
537
+ else:
538
+ token = Secret(value.strip(), label=_CSRF_KEY)
539
+
540
+ return AppData(
541
+ csrf_token=token,
542
+ user_id=_json_field(parsed, "userId", "user_id"),
543
+ locale=_json_field(parsed, "locale", "locale"),
544
+ upload_size_max=_upload_limit(parsed),
545
+ defects=tuple(defects),
546
+ )
funora/bot/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """Слой бота: наблюдение, реакция и отправка из чужого потока.
2
+
3
+ Подпакет, а не отдельный дистрибутив: он держится на внутренностях ядра, а
4
+ внутренности между дистрибутивами не тянут.
5
+
6
+ ЧТО ЗДЕСЬ ЕСТЬ И ЧЕГО НЕТ. Есть одно: единственный порядок, при котором
7
+ посторонний поток - скажем, обработчик телеграм-бота - может попросить об
8
+ отправке, не портя счёт ограничителя исходящих. Задание кладётся в очередь,
9
+ разбирает её тот же поток, что ведёт наблюдение.
10
+
11
+ АВТОВЫДАЧА ЗДЕСЬ ЕСТЬ, и устроена она вокруг одной черты: выдача идёт сама
12
+ только там, где все условия выполнены ПОЛОЖИТЕЛЬНО. Любое незнание - не повод
13
+ действовать осторожнее, а повод не действовать: вопрос уходит человеку.
14
+
15
+ Идентификатора предложения в заказе нет, и опознаётся лот по описанию - тому
16
+ самому свободному тексту, часть которого дописывает покупатель. Поэтому
17
+ уверенность у опознания всегда выведенная, а двусмысленность - отказ, а не
18
+ выбор наугад.
19
+
20
+ Управления лотами и рассылки нет. Операций записи над лотами никто не
21
+ наблюдал, а пределы отправки объявлены и соблюдаются. Подробности - в
22
+ docs/guide/bot.md.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from .._delivered import Delivery, DeliveryLedger
28
+ from ._delivery import HOLD_FOR_OPERATOR, AutoDelivery, DeliveryDecision, DeliveryPlan
29
+ from ._outbox import MAX_PENDING, Outbox, SendCommand, SendTicket
30
+ from ._runtime import MAX_SENDS_PER_IDLE, Bot
31
+ from ._spool import MAX_SPOOLED, Spool, SpoolEntry, SpoolOutcome
32
+
33
+ __all__ = [
34
+ "Bot",
35
+ "Outbox",
36
+ "SendCommand",
37
+ "SendTicket",
38
+ "MAX_PENDING",
39
+ "MAX_SENDS_PER_IDLE",
40
+ # очередь между процессами
41
+ "Spool",
42
+ "SpoolEntry",
43
+ "SpoolOutcome",
44
+ "MAX_SPOOLED",
45
+ # автовыдача
46
+ "AutoDelivery",
47
+ "DeliveryPlan",
48
+ "DeliveryDecision",
49
+ "DeliveryLedger",
50
+ "Delivery",
51
+ "HOLD_FOR_OPERATOR",
52
+ ]