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.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|