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/_showcase.py ADDED
@@ -0,0 +1,362 @@
1
+ """Разбор витрины продавца с его профиля.
2
+
3
+ Витрина - ПУБЛИЧНЫЙ вид предложений: то, что видит покупатель. Она не то же, что
4
+ страница управления лотами: ни признака включённости, ни правки цены, ни кнопки
5
+ поднятия на ней нет, и потому запись отличается по типу от Lot.
6
+
7
+ Четыре решения объясняют устройство.
8
+
9
+ Раздел витрины ищется по .offer-tc-container, а не по .offer. Класс offer на
10
+ профиле носят ЧЕТЫРНАДЦАТЬ узлов, а разделов тринадцать: четырнадцатый - блок
11
+ отзывов, переиспользующий тот же класс. Разбор, взявший .offer за раздел, нашёл
12
+ бы раздел без единого предложения и без заголовка.
13
+
14
+ Адрес предложения отдаётся ЦЕЛИКОМ, непрозрачной строкой. Идентификатор лежит в
15
+ строке запроса, а формат скелета заменяет её одной подписью: разобрать параметр
16
+ по снимку нельзя, и проверить такой разбор тоже нечем. Правило, которое ни одна
17
+ проверка не покрывает, уже однажды обошлось дорого - на разборе суммы со
18
+ страницы заказа.
19
+
20
+ Поля ищутся ВНУТРИ строки. Заголовок каждой таблицы несёт ячейку цены с тем же
21
+ классом: по документу их сто семьдесят одна на сто пятьдесят восемь строк.
22
+
23
+ Чтение объявляется неполным всегда. Признака усечения на странице нет ни одного
24
+ - но у семи разделов из тринадцати ровно по двадцать строк, и это положительный
25
+ довод в пользу обрезания, а не отсутствие довода против.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime
32
+ from typing import Final
33
+
34
+ from selectolax.parser import HTMLParser, Node
35
+
36
+ from ._observed import Observed
37
+ from ._result import Completeness, Defect, Severity
38
+ from ._stock import parse_stock_column
39
+ from .errors import IncompleteResultError, ProtocolChangedError
40
+ from .extraction import SELECTORS
41
+
42
+ __all__ = ["ShowcaseOffer", "ShowcasePage", "ShowcaseSection", "parse_showcase"]
43
+
44
+ _SECTION: Final[str] = SELECTORS["showcase.sections.container"]
45
+ _TITLE: Final[str] = SELECTORS["showcase.sections.title"]
46
+ _MANAGE: Final[str] = SELECTORS["showcase.sections.manage_link"]
47
+
48
+ _ROW: Final[str] = SELECTORS["showcase.offers.row"]
49
+ _DESCRIPTION: Final[str] = SELECTORS["showcase.offers.fields.description_text"]
50
+ _PRICE_CELL: Final[str] = SELECTORS["showcase.offers.fields.price_cell"]
51
+ _PRICE_TEXT: Final[str] = SELECTORS["showcase.offers.fields.price_text"]
52
+ _SYMBOL: Final[str] = SELECTORS["showcase.offers.fields.currency_symbol_text"]
53
+
54
+ _AMOUNT: Final[str] = SELECTORS["showcase.offers.optional_columns.amount"]
55
+ _SERVER: Final[str] = SELECTORS["showcase.offers.optional_columns.server"]
56
+ _AUTO_DELIVERY: Final[str] = SELECTORS["showcase.offers.optional_columns.auto_delivery"]
57
+
58
+ #: Сколько строк в разделе наводит на мысль об обрезании.
59
+ #:
60
+ #: Наблюдено: у семи разделов из тринадцати ровно столько. Семь совпадений на
61
+ #: одну случайность не похожи - но предел выведен из одного снимка одного
62
+ #: продавца, и доказательством не является.
63
+ _SUSPICIOUS_ROWS: Final[int] = 20
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class ShowcaseOffer:
68
+ """Одно предложение на витрине.
69
+
70
+ Не Lot и намеренно отличается от него типом. Lot требует признака
71
+ включённости и цены с кодом валюты; витрина не даёт ни того ни другого -
72
+ она показывает то, что видит покупатель.
73
+
74
+ Attributes:
75
+ offer_href (Observed[str]): Адрес предложения целиком, непрозрачной
76
+ строкой. Идентификатор лежит в строке запроса и отдельно не
77
+ разбирается.
78
+ description_text (Observed[str]): Описание предложения.
79
+ price_text (Observed[str]): Цена без знака валюты, как показана.
80
+ currency_symbol_text (Observed[str]): Знак валюты.
81
+ sort_value (Observed[str]): Значение сортировки из атрибута ячейки цены.
82
+ amount_text (Observed[str]): Остаток, если раздел его показывает.
83
+ stock (Observed[int]): Показанный целый остаток. Отсутствие, пустая
84
+ ячейка и непонятный формат остаются ненаблюдёнными с разными
85
+ причинами; ноль появляется только из явно показанного числа.
86
+ server_text (Observed[str]): Сервер, если раздел его показывает.
87
+ auto_delivery (Observed[bool]): Признак автоматической выдачи.
88
+ row_index (int): Место строки в разделе, считая с нуля.
89
+ """
90
+
91
+ offer_href: Observed[str]
92
+ description_text: Observed[str]
93
+ price_text: Observed[str]
94
+ currency_symbol_text: Observed[str]
95
+ sort_value: Observed[str]
96
+ amount_text: Observed[str]
97
+ server_text: Observed[str]
98
+ auto_delivery: Observed[bool]
99
+ row_index: int
100
+ stock: Observed[int] = field(default_factory=lambda: Observed.missing("stock_not_normalized"))
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class ShowcaseSection:
105
+ """Один раздел витрины.
106
+
107
+ Attributes:
108
+ title_text (Observed[str]): Название раздела.
109
+ category_href (Observed[str]): Адрес раздела.
110
+ manage_href (Observed[str]): Адрес страницы управления лотами раздела.
111
+ offers (tuple[ShowcaseOffer, ...]): Предложения раздела.
112
+ position (int): Место раздела на странице, считая с нуля.
113
+ """
114
+
115
+ title_text: Observed[str]
116
+ category_href: Observed[str]
117
+ manage_href: Observed[str]
118
+ offers: tuple[ShowcaseOffer, ...]
119
+ position: int
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class ShowcasePage:
124
+ """Результат чтения витрины.
125
+
126
+ Attributes:
127
+ completeness (Completeness): Полнота прочитанного.
128
+ reason (str): Машиночитаемая причина, по которой полнота такова.
129
+ observed_at (datetime): Момент наблюдения.
130
+ sections_total (int): Сколько разделов нашлось, штук.
131
+ offers_total (int): Сколько предложений нашлось, штук.
132
+ capped_sections (int): Сколько разделов показали ровно двадцать строк.
133
+ defects (tuple[Defect, ...]): Замеченные повреждения.
134
+ """
135
+
136
+ completeness: Completeness
137
+ reason: str
138
+ observed_at: datetime
139
+ sections_total: int
140
+ offers_total: int
141
+ capped_sections: int
142
+ defects: tuple[Defect, ...] = ()
143
+ _sections: tuple[ShowcaseSection, ...] = field(repr=False, default=())
144
+
145
+ def sections(self, *, accept_incomplete: bool = False) -> tuple[ShowcaseSection, ...]:
146
+ """Возвращает разделы витрины с их предложениями.
147
+
148
+ Args:
149
+ accept_incomplete (bool): Признание того, что результат может быть
150
+ неполным. Без него неполный результат не выдаётся.
151
+
152
+ Returns:
153
+ tuple[ShowcaseSection, ...]: Разделы в порядке появления.
154
+
155
+ Raises:
156
+ IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
157
+ не признана. Витрина не объявляется полной ни разу, и признание
158
+ требуется всегда - это неудобно нарочно.
159
+ """
160
+ if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
161
+ raise IncompleteResultError(
162
+ f"результат неполон ({self.completeness}, причина: {self.reason}), "
163
+ f"разделов {self.sections_total}, предложений {self.offers_total}, "
164
+ f"разделов ровно с {_SUSPICIOUS_ROWS} строками {self.capped_sections}. "
165
+ "Передайте accept_incomplete=True, если готовы работать с неполными данными"
166
+ )
167
+ return self._sections
168
+
169
+
170
+ def _text(node: Node | None, name: str) -> Observed[str]:
171
+ """Извлекает текст узла как наблюдение.
172
+
173
+ Args:
174
+ node (Node | None): Узел либо None.
175
+ name (str): Имя поля для причины отсутствия.
176
+
177
+ Returns:
178
+ Observed[str]: Наблюдение.
179
+ """
180
+ if node is None:
181
+ return Observed.missing(f"selector_no_match:{name}")
182
+ value = " ".join((node.text() or "").split())
183
+ return Observed.present(value) if value else Observed.empty("")
184
+
185
+
186
+ def _own_text(node: Node | None, name: str) -> Observed[str]:
187
+ """Извлекает СОБСТВЕННЫЙ текст узла, без вложенных.
188
+
189
+ Знак валюты лежит отдельным узлом внутри ячейки цены, и текст целиком
190
+ склеил бы их.
191
+
192
+ Args:
193
+ node (Node | None): Узел либо None.
194
+ name (str): Имя поля для причины отсутствия.
195
+
196
+ Returns:
197
+ Observed[str]: Наблюдение.
198
+ """
199
+ if node is None:
200
+ return Observed.missing(f"selector_no_match:{name}")
201
+ value = " ".join((node.text(deep=False) or "").split())
202
+ return Observed.present(value) if value else Observed.empty("")
203
+
204
+
205
+ def _attribute(node: Node | None, name: str, field_name: str) -> Observed[str]:
206
+ """Читает атрибут, различая три исхода.
207
+
208
+ Args:
209
+ node (Node | None): Узел либо None.
210
+ name (str): Имя атрибута.
211
+ field_name (str): Имя поля для причины отсутствия.
212
+
213
+ Returns:
214
+ Observed[str]: Наблюдение.
215
+ """
216
+ if node is None:
217
+ return Observed.missing(f"selector_no_match:{field_name}")
218
+ attributes = node.attributes or {}
219
+ if name not in attributes:
220
+ return Observed.missing(f"attribute_absent:{field_name}")
221
+ value = (attributes.get(name) or "").strip()
222
+ return Observed.present(value) if value else Observed.empty("")
223
+
224
+
225
+ def _offer(row: Node, index: int) -> ShowcaseOffer:
226
+ """Собирает одно предложение.
227
+
228
+ Args:
229
+ row (Node): Узел строки.
230
+ index (int): Место строки в разделе, считая с нуля.
231
+
232
+ Returns:
233
+ ShowcaseOffer: Предложение.
234
+ """
235
+ price_cell = row.css_first(_PRICE_CELL)
236
+ return ShowcaseOffer(
237
+ offer_href=_attribute(row, "href", "offer_href"),
238
+ description_text=_text(row.css_first(_DESCRIPTION), "description_text"),
239
+ price_text=_own_text(row.css_first(_PRICE_TEXT), "price_text"),
240
+ currency_symbol_text=_text(row.css_first(_SYMBOL), "currency_symbol_text"),
241
+ sort_value=_attribute(price_cell, "data-s", "sort_value"),
242
+ # Колонки остатка и сервера есть НЕ ВО ВСЕХ разделах, и это наблюдено.
243
+ # Отсутствие узла здесь - факт о разделе, а не о нашем незнании, но
244
+ # различить эти два случая по снимку нечем: узла нет в обоих.
245
+ amount_text=_text(row.css_first(_AMOUNT), "amount_text"),
246
+ server_text=_text(row.css_first(_SERVER), "server_text"),
247
+ auto_delivery=Observed.present(row.css_first(_AUTO_DELIVERY) is not None),
248
+ row_index=index,
249
+ stock=parse_stock_column(row, _AMOUNT),
250
+ )
251
+
252
+
253
+ def _section(container: Node, index: int) -> tuple[ShowcaseSection, list[Defect]]:
254
+ """Собирает один раздел витрины.
255
+
256
+ Заголовок раздела лежит НЕ внутри контейнера строк, а рядом с ним - в общей
257
+ обёртке. Ищется он поэтому у родителя, и промах здесь не заметить: разбор
258
+ вернул бы раздел без названия и без ссылки.
259
+
260
+ Args:
261
+ container (Node): Контейнер строк раздела.
262
+ index (int): Место раздела на странице, считая с нуля.
263
+
264
+ Returns:
265
+ tuple[ShowcaseSection, list[Defect]]: Раздел и перечень повреждений.
266
+ """
267
+ outer = container.parent
268
+ title = outer.css_first(_TITLE) if outer is not None else None
269
+ manage = outer.css_first(_MANAGE) if outer is not None else None
270
+
271
+ defects: list[Defect] = []
272
+ if title is None:
273
+ defects.append(
274
+ Defect(
275
+ severity=Severity.ROW,
276
+ code="section_title_missing",
277
+ detail=f"раздел {index}: заголовка ({_TITLE}) рядом с контейнером строк нет",
278
+ )
279
+ )
280
+
281
+ rows = container.css(_ROW)
282
+ return (
283
+ ShowcaseSection(
284
+ title_text=_text(title, "title_text"),
285
+ category_href=_attribute(title, "href", "category_href"),
286
+ manage_href=_attribute(manage, "href", "manage_href"),
287
+ offers=tuple(_offer(row, at) for at, row in enumerate(rows)),
288
+ position=index,
289
+ ),
290
+ defects,
291
+ )
292
+
293
+
294
+ def parse_showcase(html: str, observed_at: datetime) -> ShowcasePage:
295
+ """Разбирает витрину продавца со страницы его профиля.
296
+
297
+ Args:
298
+ html (str): Тело страницы. Предполагается уже классифицированным как
299
+ пригодное: состояние сессии здесь не проверяется.
300
+ observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
301
+ разбор оставался чистым и повторяемым на сохранённом снимке.
302
+
303
+ Returns:
304
+ ShowcasePage: Разделы витрины с их предложениями.
305
+
306
+ Raises:
307
+ ProtocolChangedError: Если контейнеров разделов нет ни одного: пустую
308
+ витрину вернуть нельзя, она неотличима от смены разметки.
309
+ """
310
+ tree = HTMLParser(html)
311
+ containers = tree.css(_SECTION)
312
+
313
+ if not containers:
314
+ raise ProtocolChangedError(
315
+ f"на профиле нет ни одного контейнера раздела витрины ({_SECTION}). "
316
+ "Пустую витрину вернуть нельзя: она неотличима от смены разметки"
317
+ )
318
+
319
+ defects: list[Defect] = []
320
+ sections: list[ShowcaseSection] = []
321
+ for index, container in enumerate(containers):
322
+ section, section_defects = _section(container, index)
323
+ defects.extend(section_defects)
324
+ sections.append(section)
325
+
326
+ offers_total = sum(len(one.offers) for one in sections)
327
+ rows_by_document = len(tree.css(_ROW))
328
+ if offers_total != rows_by_document:
329
+ defects.append(
330
+ Defect(
331
+ severity=Severity.PAGE,
332
+ code="offers_outside_sections",
333
+ detail=(
334
+ f"предложений внутри разделов {offers_total}, а по документу "
335
+ f"{rows_by_document}: часть строк оказалась вне контейнеров"
336
+ ),
337
+ )
338
+ )
339
+
340
+ capped = sum(1 for one in sections if len(one.offers) == _SUSPICIOUS_ROWS)
341
+
342
+ if any(one.severity is Severity.PAGE for one in defects):
343
+ completeness, reason = Completeness.PARTIAL, "page_defects"
344
+ elif capped:
345
+ # Положительный довод в пользу обрезания: столько разделов показали
346
+ # ровно предельное число строк.
347
+ completeness, reason = Completeness.PARTIAL, "sections_look_capped"
348
+ else:
349
+ # Признака усечения на странице нет ни одного - и это НЕ основание
350
+ # объявить витрину полной. Отсутствие признака говорит о том, кто искал.
351
+ completeness, reason = Completeness.PARTIAL, "showcase_cap_unobserved"
352
+
353
+ return ShowcasePage(
354
+ completeness=completeness,
355
+ reason=reason,
356
+ observed_at=observed_at,
357
+ sections_total=len(sections),
358
+ offers_total=offers_total,
359
+ capped_sections=capped,
360
+ defects=tuple(defects),
361
+ _sections=tuple(sections),
362
+ )