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/_updates.py ADDED
@@ -0,0 +1,292 @@
1
+ """Разбор ответа канала обновлений.
2
+
3
+ ЧТО ЭТО ЗА КАНАЛ. Площадка держит его для собственной страницы: один POST с
4
+ подпиской, в ответ - изменения только тех объектов, на которые подписан. Ничего
5
+ не изменилось - объекты пусты.
6
+
7
+ ПОЧЕМУ ЭТО ВАЖНЕЕ ВСЕГО ОСТАЛЬНОГО. Наблюдение сегодня читает две полные
8
+ страницы на КАЖДОМ шаге, изменилось что-нибудь или нет. Канал отвечает малым
9
+ телом и молчит, когда молчать нечего.
10
+
11
+ Три факта, наблюдённые 30.08.2026 и записанные в spec/extraction/updates.yaml:
12
+
13
+ Метка - это КВИТАНЦИЯ «я видел до сюда», а не пропуск. Площадка принимает любую,
14
+ в том числе выдуманную, и отвечает всем, что изменилось с той поры. Свою метку
15
+ она возвращает в ответе; тот же опрос с ней даёт пустоту.
16
+
17
+ Подписка длиннее ДЕСЯТИ объектов обрезается МОЛЧА. Послано одиннадцать - пришло
18
+ десять, без единого признака, что один отброшен.
19
+
20
+ Счётчики приходят ЧИСЛАМИ, а не выводятся из разметки.
21
+
22
+ ЧТО ЗДЕСЬ НЕ РАЗБИРАЕТСЯ. Поле html внутри объектов - разметка переписки, и
23
+ разбирать её тем же кодом, что разбирает страницу, никто не проверял. Она
24
+ проходит насквозь как непрозрачная строка: разбор по догадке разошёлся бы с
25
+ площадкой молча.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Final
32
+
33
+ from ._json import load_json
34
+ from .errors import ProtocolChangedError
35
+
36
+ __all__ = [
37
+ "MAX_SUBSCRIPTION",
38
+ "UpdatesAnswer",
39
+ "ChannelObject",
40
+ "build_subscription",
41
+ "parse_updates_answer",
42
+ ]
43
+
44
+ #: Сколько объектов площадка принимает в одной подписке.
45
+ #:
46
+ #: Наблюдено, а не взято у чужой реализации: послано одиннадцать узлов диалога,
47
+ #: вернулось десять. Чужая константа была поводом проверить, а не свидетельством.
48
+ #:
49
+ #: МОЛЧАЛИВОСТЬ ОБРЕЗКИ - главное в этом числе. Признака, что объект отброшен, в
50
+ #: ответе нет никакого. Подписавшийся на пятнадцать диалогов получит десять и не
51
+ #: узнает об этом ниоткуда: сообщения пяти покупателей не придут, и объяснить это
52
+ #: будет нечем.
53
+ MAX_SUBSCRIPTION: Final[int] = 10
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class ChannelObject:
58
+ """Один изменившийся объект из ответа канала.
59
+
60
+ Attributes:
61
+ type (str): Вид объекта, как его назвала площадка.
62
+ id (str): Идентификатор объекта.
63
+ tag (str): Непустая метка для следующего опроса.
64
+ data (dict[str, Any]): Данные объекта как есть. Разметка внутри НЕ
65
+ разбирается: она проходит насквозь непрозрачной строкой.
66
+ """
67
+
68
+ type: str
69
+ id: str
70
+ tag: str
71
+ data: dict[str, Any] = field(default_factory=dict)
72
+
73
+ def number(self, name: str) -> int | None:
74
+ """Читает целое поле данных.
75
+
76
+ Числа - единственное, что этот разбор берёт из данных объекта, и берёт
77
+ он их без толкования: что означает счётчик, решает вызывающий.
78
+
79
+ Возвращается None, а не ноль, когда поля нет. Ноль означал бы «счётчик
80
+ равен нулю», а это другое утверждение.
81
+
82
+ Аргументы:
83
+ name (str): Имя поля.
84
+
85
+ Возвращает:
86
+ int | None: Значение либо None, если поля нет или оно не целое.
87
+ """
88
+ value = self.data.get(name)
89
+ # Логическое исключается отдельно: в Python True - это единица, и
90
+ # счётчик, оказавшийся булевым, прочитался бы числом молча.
91
+ if isinstance(value, bool) or not isinstance(value, int):
92
+ return None
93
+ return value
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class UpdatesAnswer:
98
+ """Ответ канала обновлений.
99
+
100
+ Attributes:
101
+ objects (tuple[ChannelObject, ...]): Изменившиеся объекты. Пусто -
102
+ штатное состояние, а не признак поломки: канал молчит, когда молчать
103
+ нечего.
104
+ error (object | None): Поле ошибки без преобразования. Только None означает
105
+ отсутствие отказа; форма непустого поля не интерпретируется.
106
+ answered_action (bool): Был ли в запросе действие. Площадка отвечает
107
+ объектом при опросе С ДЕЙСТВИЕМ и логическим - при опросе без него.
108
+ """
109
+
110
+ objects: tuple[ChannelObject, ...]
111
+ error: object | None
112
+ answered_action: bool
113
+
114
+ @property
115
+ def is_quiet(self) -> bool:
116
+ """Говорит, что изменений не пришло.
117
+
118
+ Returns:
119
+ bool: True, если объектов нет.
120
+ """
121
+ return not self.objects
122
+
123
+ def tags(self, known: dict[tuple[str, str], str] | None = None) -> dict[tuple[str, str], str]:
124
+ """Собирает метки для следующего опроса, НАКАПЛИВАЯ прежние.
125
+
126
+ Ключ - пара из вида и идентификатора, а не один вид: подписка держит по
127
+ объекту на каждый диалог, и вид у них общий.
128
+
129
+ НАКОПЛЕНИЕ - не удобство, а условие работоспособности. Ответ несёт
130
+ только ИЗМЕНИВШИЕСЯ объекты: у молчавшего диалога метки в ответе нет
131
+ вовсе. Собирая метки лишь из последнего ответа, опрос подставлял бы
132
+ всем молчавшим метку «я ничего не видел» - и площадка отдавала бы их
133
+ целиком заново на каждом шаге.
134
+
135
+ То есть без накопления канал перестаёт быть дешевле страниц ровно в
136
+ тот момент, когда становится нужен: при десяти диалогах и одном
137
+ говорящем девять приезжали бы полностью каждый раз.
138
+
139
+ Аргументы:
140
+ known (dict[tuple[str, str], str] | None): Метки прошлых опросов.
141
+ Пришедшие в этом ответе их вытесняют.
142
+
143
+ Returns:
144
+ dict[tuple[str, str], str]: Метки по объектам.
145
+ """
146
+ collected = dict(known or {})
147
+ collected.update({(one.type, one.id): one.tag for one in self.objects if one.tag})
148
+ return collected
149
+
150
+
151
+ def build_subscription(
152
+ wanted: list[tuple[str, str]], tags: dict[tuple[str, str], str]
153
+ ) -> list[list[dict[str, Any]]]:
154
+ """Собирает подписку ПОРЦИЯМИ не длиннее предела.
155
+
156
+ Порции считает вызывающий, а не площадка: она обрезает лишнее молча, и
157
+ заметить обрезку в ответе нечем.
158
+
159
+ Объект без известной метки получает выдуманную. Это не обход защиты, а
160
+ значение «я ничего не видел»: площадка отвечает на него всем, что изменилось,
161
+ - ровно так же, как отвечает странице при первом обращении.
162
+
163
+ Аргументы:
164
+ wanted (list[tuple[str, str]]): На что подписываться: вид и
165
+ идентификатор.
166
+ tags (dict[tuple[str, str], str]): Метки прошлого ответа.
167
+
168
+ Возвращает:
169
+ list[list[dict[str, Any]]]: Подписки порциями, каждая не длиннее
170
+ предела.
171
+ """
172
+ objects = [
173
+ {
174
+ "type": kind,
175
+ "id": entity,
176
+ "tag": tags.get((kind, entity), UNSEEN_TAG),
177
+ "data": False,
178
+ }
179
+ for kind, entity in wanted
180
+ ]
181
+ return [
182
+ objects[at : at + MAX_SUBSCRIPTION] for at in range(0, len(objects), MAX_SUBSCRIPTION)
183
+ ] or [[]]
184
+
185
+
186
+ #: Метка, означающая «я ничего не видел».
187
+ #:
188
+ #: Годится любая несуществующая: площадка отвечает на неё всем, что изменилось.
189
+ #: Наблюдено 30.08.2026 - подписка с этой самой строкой вернула оба объекта
190
+ #: целиком, а повторная с вернувшимися метками вернула пустоту.
191
+ UNSEEN_TAG: Final[str] = "0000000000"
192
+
193
+
194
+ def load_runner_json(body: str) -> object:
195
+ """Читает JSON канала без потери полей и неконечных чисел.
196
+
197
+ Опрос и отправка делят один декодер. Ошибки ValueError/RecursionError
198
+ вызывающий переводит в свой исход: откат к страницам либо unconfirmed.
199
+ """
200
+ return load_json(body)
201
+
202
+
203
+ def parse_updates_answer(body: str) -> UpdatesAnswer:
204
+ """Разбирает тело ответа канала.
205
+
206
+ Декодер JSON общий с отправкой. Опрос дополнительно проверяет все объекты
207
+ подписки, прежде чем вызывающий сможет подтвердить их метки.
208
+
209
+ Аргументы:
210
+ body (str): Тело ответа.
211
+
212
+ Возвращает:
213
+ UpdatesAnswer: Разобранный ответ.
214
+
215
+ Raises:
216
+ ProtocolChangedError: Если тело не разбирается либо устроено не так,
217
+ как наблюдалось.
218
+ """
219
+ try:
220
+ parsed = load_runner_json(body)
221
+ except (ValueError, RecursionError) as exc:
222
+ raise ProtocolChangedError(
223
+ "ответ канала обновлений не разобрался как JSON. Канал отвечал JSON "
224
+ "во всех наблюдениях; разбор чего-то иного означал бы, что мы "
225
+ "приняли за канал не канал"
226
+ ) from exc
227
+
228
+ if not isinstance(parsed, dict):
229
+ raise ProtocolChangedError(
230
+ f"ответ канала обновлений - не объект, а {type(parsed).__name__}"
231
+ )
232
+
233
+ raw = parsed.get("objects")
234
+ if not isinstance(raw, list):
235
+ raise ProtocolChangedError(
236
+ "в ответе канала обновлений нет перечня объектов. Пустой перечень - "
237
+ "штатное состояние, а отсутствие поля означает другой ответ"
238
+ )
239
+
240
+ answer = parsed.get("response")
241
+ # Объект при опросе С ДЕЙСТВИЕМ, логическое - без действия. Различие
242
+ # наблюдено, и по нему же читается ошибка: у логического ошибке взяться
243
+ # неоткуда.
244
+ error = None
245
+ if isinstance(answer, dict):
246
+ if "error" not in answer:
247
+ raise ProtocolChangedError("в ответе действия канала нет поля error")
248
+ action = True
249
+ error = answer["error"]
250
+ elif isinstance(answer, bool):
251
+ action = False
252
+ else:
253
+ raise ProtocolChangedError("поле response канала - не логическое и не объект действия")
254
+
255
+ objects: list[ChannelObject] = []
256
+ seen: set[tuple[str, str]] = set()
257
+ for one in raw:
258
+ if not isinstance(one, dict):
259
+ raise ProtocolChangedError("элемент objects канала - не объект")
260
+ kind = one.get("type")
261
+ if not isinstance(kind, str) or not kind:
262
+ raise ProtocolChangedError("у объекта канала нет непустого строкового type")
263
+ entity = one.get("id")
264
+ if not (type(entity) is int or isinstance(entity, str) and entity):
265
+ raise ProtocolChangedError("id объекта канала - не целое число и не непустая строка")
266
+ tag = one.get("tag")
267
+ if not isinstance(tag, str) or not tag:
268
+ raise ProtocolChangedError("у объекта канала нет непустой строковой tag")
269
+ data = one.get("data")
270
+ if not isinstance(data, dict):
271
+ raise ProtocolChangedError("data объекта канала - не объект")
272
+ key = (kind, str(entity))
273
+ try:
274
+ for value in (*key, tag):
275
+ value.encode("utf-8")
276
+ except UnicodeEncodeError:
277
+ raise ProtocolChangedError(
278
+ "ключ или метка канала содержит некорректный Unicode"
279
+ ) from None
280
+ if key in seen:
281
+ raise ProtocolChangedError("в ответе канала повторяется объект подписки")
282
+ seen.add(key)
283
+ objects.append(
284
+ ChannelObject(
285
+ type=kind,
286
+ id=key[1],
287
+ tag=tag,
288
+ data=data,
289
+ )
290
+ )
291
+
292
+ return UpdatesAnswer(objects=tuple(objects), error=error, answered_action=action)
funora/_verdicts.py ADDED
@@ -0,0 +1,91 @@
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
+ from __future__ import annotations
26
+
27
+ from ._classify import Verdict
28
+ from .errors import (
29
+ FunoraError,
30
+ InvalidCredentialsError,
31
+ ProtocolChangedError,
32
+ SessionExpiredError,
33
+ )
34
+ from .response_classes import VERDICT_ERRORS
35
+
36
+ __all__ = ["error_for", "PROVISIONAL_ATTR"]
37
+
38
+ #: Имя признака непроверенности на экземпляре ошибки.
39
+ #:
40
+ #: Признак живёт на экземпляре, а не на классе: непроверенность - свойство
41
+ #: конкретного решения, а не вида ошибки. Один и тот же ChallengeRequiredError
42
+ #: может быть поднят и по структурному признаку, и по догадке о тексте.
43
+ PROVISIONAL_ATTR = "provisional"
44
+
45
+
46
+ def error_for(
47
+ verdict: Verdict,
48
+ *,
49
+ session_ever_valid: bool = False,
50
+ ) -> FunoraError | None:
51
+ """Возвращает ошибку, соответствующую вердикту.
52
+
53
+ Args:
54
+ verdict (Verdict): Вердикт классификатора.
55
+ session_ever_valid (bool): Подтверждалась ли сессия хоть раз за время
56
+ жизни клиента. Отличает истёкшую сессию от неверного секрета.
57
+
58
+ Returns:
59
+ FunoraError | None: Экземпляр ошибки либо None, если ответ пригоден для
60
+ разбора. У экземпляра выставлен признак provisional, показывающий, было
61
+ ли решение принято непроверенной сигнатурой.
62
+
63
+ Raises:
64
+ ProtocolChangedError: Если пара «класс, причина» отсутствует в таблице.
65
+ Придумать ошибку на месте нельзя: придуманная разойдётся между
66
+ реализациями.
67
+ """
68
+ key = (str(verdict.cls), verdict.reason)
69
+ if key not in VERDICT_ERRORS:
70
+ raise ProtocolChangedError(
71
+ f"классификатор вернул вердикт {key}, которого нет в таблице "
72
+ "соответствия. Либо таблица отстала от спецификации, либо появился "
73
+ "новый класс ответа"
74
+ )
75
+
76
+ cls = VERDICT_ERRORS[key]
77
+ if cls is None:
78
+ return None
79
+
80
+ # Уточнение, не выразимое таблицей: истёкшая сессия и неверный секрет
81
+ # выглядят одинаково, но лечатся по-разному.
82
+ if cls is SessionExpiredError and not session_ever_valid:
83
+ cls = InvalidCredentialsError
84
+
85
+ detail = f"{verdict.cls}: {verdict.reason}"
86
+ if verdict.matched:
87
+ detail += f" (сигнатура {verdict.matched})"
88
+
89
+ error = cls(detail)
90
+ setattr(error, PROVISIONAL_ATTR, verdict.provisional)
91
+ return error # type: ignore[return-value]
funora/_viewing.py ADDED
@@ -0,0 +1,143 @@
1
+ """Что покупатель смотрит прямо сейчас.
2
+
3
+ РАСКОЛ НАБЛЮДЕНИЯ, И ОН ЗДЕСЬ ГЛАВНОЕ. Подписка на этот объект наблюдена НАМИ -
4
+ она лежит в семи наших записях канала, и состав её известен: признак,
5
+ идентификатор из восьми цифр, метка из восьми знаков.
6
+
7
+ Ответ на неё мы не видели НИ РАЗУ. Что приходит внутри, известно от независимой
8
+ реализации того же протокола: при пустом признаке покупатель не смотрит ничего,
9
+ иначе внутри лежит разметка со ссылкой на лот.
10
+
11
+ Отсюда устройство записи: разметка сохраняется КАК ЕСТЬ, а ссылка и подпись
12
+ читаются из неё отдельными полями. Не разобралась - поля остаются
13
+ ненаблюдёнными, а разметка при вызывающем: по ней человек увидит то, чего не
14
+ увидел разбор.
15
+
16
+ ИМЯ ОБЪЕКТА ЗАПИСАНО ДОСЛОВНО и странно выглядит. Что оно означает - неизвестно,
17
+ и догадываться незачем: подписка собирается по имени, а не по смыслу.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from datetime import datetime
24
+ from typing import Any, Final
25
+
26
+ from selectolax.parser import HTMLParser
27
+
28
+ from ._observed import Observed
29
+
30
+ __all__ = ["BuyerViewing", "parse_buyer_viewing", "VIEWING_OBJECT"]
31
+
32
+ #: Имя объекта подписки. Наблюдено НАМИ в семи записях канала.
33
+ #:
34
+ #: Записано дословно. Как оно добралось до записи, стоит помнить: прежний
35
+ #: образец протокольного знака его не пропускал, и сборщик записал вместо имени
36
+ #: мерку. Образец расширили дефисом ПО ЭТОЙ МЕРКЕ, а не наугад.
37
+ VIEWING_OBJECT: Final[str] = "c-p-u"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class BuyerViewing:
42
+ """Что покупатель смотрит прямо сейчас.
43
+
44
+ Attributes:
45
+ buyer_id (str): Идентификатор покупателя, о котором спрашивали.
46
+ viewing (bool): Смотрит ли он что-нибудь. ЛОЖЬ ЗДЕСЬ - НАБЛЮДЕНИЕ:
47
+ площадка прислала пустой признак, то есть сказала «не смотрит».
48
+ Неудачу чтения выражает отказ.
49
+ lot_href (Observed[str]): Ссылка на лот, прочитанная из разметки.
50
+ lot_text (Observed[str]): Подпись лота на локали интерфейса. Не
51
+ разбирается.
52
+ raw_html (Observed[str]): Разметка блока, КАК ЕСТЬ. Сохраняется затем,
53
+ что ответа этой точки мы не наблюдали: не разберись наши поля - у
54
+ вызывающего останется то, из чего он поймёт сам.
55
+ observed_at (datetime): Момент получения ответа.
56
+ """
57
+
58
+ buyer_id: str
59
+ viewing: bool
60
+ lot_href: Observed[str]
61
+ lot_text: Observed[str]
62
+ raw_html: Observed[str]
63
+ observed_at: datetime
64
+
65
+
66
+ def _from_markup(markup: str) -> tuple[Observed[str], Observed[str]]:
67
+ """Читает ссылку и подпись из разметки блока просмотра.
68
+
69
+ РАЗБОР ЗДЕСЬ САМЫЙ ПРОСТОЙ ИЗ ВОЗМОЖНЫХ - первая ссылка, - и это не лень.
70
+ Разметки этой мы не видели; строить по чужому описанию точный селектор
71
+ значило бы выдать чужое описание за наблюдение.
72
+
73
+ Аргументы:
74
+ markup (str): Разметка блока.
75
+
76
+ Возвращает:
77
+ tuple[Observed[str], Observed[str]]: Ссылка и подпись.
78
+ """
79
+ node = HTMLParser(markup).css_first("a[href]")
80
+ if node is None:
81
+ return (
82
+ Observed.missing("viewing_link_absent"),
83
+ Observed.missing("viewing_text_absent"),
84
+ )
85
+
86
+ raw_href = (node.attributes or {}).get("href")
87
+ href = (raw_href or "").strip()
88
+ text = (node.text() or "").strip()
89
+ return (
90
+ Observed.present(href) if href else Observed.empty(""),
91
+ Observed.present(text) if text else Observed.empty(""),
92
+ )
93
+
94
+
95
+ def parse_buyer_viewing(obj: Any, *, buyer_id: str, observed_at: datetime) -> BuyerViewing:
96
+ """Разбирает объект «покупатель смотрит» из ответа канала.
97
+
98
+ ПУСТОЙ ПРИЗНАК - НАБЛЮДЕНИЕ, А НЕ НЕУДАЧА. Площадка сказала «не смотрит», и
99
+ показать это продавцу можно. Неудачу чтения выражает отказ, а не ложь в
100
+ поле.
101
+
102
+ Аргументы:
103
+ obj (Any): Объект из ответа канала.
104
+ buyer_id (str): Идентификатор покупателя, о котором спрашивали.
105
+ observed_at (datetime): Момент получения.
106
+
107
+ Возвращает:
108
+ BuyerViewing: Что смотрит покупатель.
109
+ """
110
+ empty = BuyerViewing(
111
+ buyer_id=buyer_id,
112
+ viewing=False,
113
+ lot_href=Observed.missing("not_viewing"),
114
+ lot_text=Observed.missing("not_viewing"),
115
+ raw_html=Observed.missing("not_viewing"),
116
+ observed_at=observed_at,
117
+ )
118
+
119
+ if not isinstance(obj, dict):
120
+ return empty
121
+
122
+ data = obj.get("data")
123
+ # Пустой признак означает «не смотрит ничего». Наблюдено это не нами -
124
+ # известно от сторонней реализации, - и потому обращение осторожное: всё,
125
+ # что не похоже на разметку, читается как «не смотрит», а не как поломка.
126
+ if not isinstance(data, dict):
127
+ return empty
128
+
129
+ raw_html = data.get("html")
130
+ if isinstance(raw_html, dict):
131
+ raw_html = raw_html.get("desktop")
132
+ if not isinstance(raw_html, str) or not raw_html.strip():
133
+ return empty
134
+
135
+ href, text = _from_markup(raw_html)
136
+ return BuyerViewing(
137
+ buyer_id=buyer_id,
138
+ viewing=True,
139
+ lot_href=href,
140
+ lot_text=text,
141
+ raw_html=Observed.present(raw_html),
142
+ observed_at=observed_at,
143
+ )