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/_market.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Чтение публичного списка предложений раздела.
|
|
2
|
+
|
|
3
|
+
То, что видит ПОКУПАТЕЛЬ, ищущий товар. Не витрина продавца и не страница
|
|
4
|
+
управления: здесь предложения многих продавцов в одном разделе, и потому здесь
|
|
5
|
+
есть колонка, которой нет больше нигде, - сам продавец.
|
|
6
|
+
|
|
7
|
+
ЛОВУШКА ЗДЕСЬ ОДНА И ДОРОГАЯ. У строки есть атрибут data-user, и по имени он
|
|
8
|
+
ровно то, что нужно. Он не то.
|
|
9
|
+
|
|
10
|
+
Атрибут есть у строки ТОГДА И ТОЛЬКО ТОГДА, когда предложение поднято.
|
|
11
|
+
Наблюдено точным совпадением в обе стороны: семьдесят строк с классом
|
|
12
|
+
offer-promo - и ровно у этих семидесяти есть data-user; две тысячи девятьсот
|
|
13
|
+
тридцать одна строка без класса - и ни у одной из них его нет.
|
|
14
|
+
|
|
15
|
+
Разбор, читающий продавца оттуда, отдал бы его у двух процентов предложений и
|
|
16
|
+
выглядел бы работающим: поля заполняются, ошибок нет, а поднятые предложения
|
|
17
|
+
показываются первыми и попадают в глаза первыми.
|
|
18
|
+
|
|
19
|
+
Продавец лежит в ссылке на профиль внутри строки. Она есть у всех трёх тысяч
|
|
20
|
+
одной строки, и различных значений четыреста семьдесят девять - против четырёх
|
|
21
|
+
различных data-user. Числа расходятся на два порядка, и это и есть
|
|
22
|
+
доказательство.
|
|
23
|
+
|
|
24
|
+
ЛЕНИВАЯ ЗАГРУЗКА - НЕ УСЕЧЕНИЕ. Две тысячи восемьсот одна строка несёт класс
|
|
25
|
+
lazyload-hidden, и все они присутствуют в теле ответа наравне с прочими. Класс
|
|
26
|
+
говорит о показе, а не о наличии: список прочитан весь одним ответом, догружать
|
|
27
|
+
нечего.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from typing import Final
|
|
35
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
36
|
+
|
|
37
|
+
from selectolax.parser import HTMLParser, Node
|
|
38
|
+
|
|
39
|
+
from ._money import Money, parse_display_price
|
|
40
|
+
from ._observed import Observed
|
|
41
|
+
from ._result import Completeness, Defect, Severity
|
|
42
|
+
from .errors import IncompleteResultError, ProtocolChangedError, ValidationError
|
|
43
|
+
from .extraction import ATTRIBUTES, QUERY_PARAMS, SELECTORS
|
|
44
|
+
|
|
45
|
+
__all__ = ["MarketOffer", "MarketPage", "parse_market"]
|
|
46
|
+
|
|
47
|
+
_ROW: Final[str] = SELECTORS["market.rows"]
|
|
48
|
+
_SERVER: Final[str] = SELECTORS["market.fields.server_text"]
|
|
49
|
+
_DESCRIPTION: Final[str] = SELECTORS["market.fields.description_text"]
|
|
50
|
+
_SELLER_LINK: Final[str] = SELECTORS["market.fields.seller_link"]
|
|
51
|
+
_SELLER_NAME: Final[str] = SELECTORS["market.fields.seller_name"]
|
|
52
|
+
_PRICE_CELL: Final[str] = SELECTORS["market.fields.price_cell"]
|
|
53
|
+
_PRICE_TEXT: Final[str] = SELECTORS["market.fields.price_text"]
|
|
54
|
+
_CURRENCY: Final[str] = SELECTORS["market.fields.currency_symbol_text"]
|
|
55
|
+
|
|
56
|
+
_OFFER_HREF: Final[str] = ATTRIBUTES["market.rows.attributes.offer_href"]
|
|
57
|
+
|
|
58
|
+
#: Имя параметра, в котором лежит идентификатор предложения.
|
|
59
|
+
#:
|
|
60
|
+
#: Берётся из порождённой таблицы, а не пишется литералом: имя выбирает
|
|
61
|
+
#: площадка, и живя в двух местах, оно разошлось бы с объявлением молча.
|
|
62
|
+
_OFFER_ID_PARAM: Final[str] = QUERY_PARAMS["market.rows.attributes.offer_id"]
|
|
63
|
+
_SERVER_ID: Final[str] = ATTRIBUTES["market.rows.attributes.server_id"]
|
|
64
|
+
_FILTER_TYPE: Final[str] = ATTRIBUTES["market.rows.attributes.filter_type"]
|
|
65
|
+
_SELLER_HREF: Final[str] = ATTRIBUTES["market.fields.seller_link.attributes.seller_href"]
|
|
66
|
+
_SORT_VALUE: Final[str] = ATTRIBUTES["market.fields.price_cell.attributes.sort_value"]
|
|
67
|
+
_ONLINE: Final[str] = ATTRIBUTES["market.markers.online.attribute"]
|
|
68
|
+
|
|
69
|
+
#: Классы, которыми площадка помечает поднятое предложение.
|
|
70
|
+
#:
|
|
71
|
+
#: Их два, и они не одно и то же: offer-promo наблюдён у семидесяти строк,
|
|
72
|
+
#: offer-promoted - у одной, и она же первая на странице. Что означает различие,
|
|
73
|
+
#: не установлено: наблюдалась одна страница.
|
|
74
|
+
_PROMO: Final[str] = "offer-promo"
|
|
75
|
+
_PROMOTED: Final[str] = "offer-promoted"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class MarketOffer:
|
|
80
|
+
"""Одно предложение в списке раздела.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
offer_id (Observed[str]): Идентификатор предложения.
|
|
84
|
+
|
|
85
|
+
ЛЕЖИТ ОН В СТРОКЕ ЗАПРОСА ССЫЛКИ И БОЛЬШЕ НИГДЕ, и до формата
|
|
86
|
+
скелета v9 наблюдать его было нельзя: снимок заменял строку запроса
|
|
87
|
+
одной подписью. Из-за этого market.offers не собиралась вовсе, а
|
|
88
|
+
причиной называли модель.
|
|
89
|
+
|
|
90
|
+
Наблюдено 31.08.2026: параметр называется id, значение - восемь
|
|
91
|
+
цифр, и есть он у всех 1417 строк.
|
|
92
|
+
offer_href (Observed[str]): Ссылка на предложение целиком.
|
|
93
|
+
seller_href (Observed[str]): Ссылка на профиль продавца. НАСТОЯЩИЙ
|
|
94
|
+
продавец строки - не атрибут data-user, которого у большинства строк
|
|
95
|
+
нет вовсе.
|
|
96
|
+
seller_name_text (Observed[str]): Отображаемое имя продавца.
|
|
97
|
+
seller_online (bool): Наблюдался ли признак «в сети».
|
|
98
|
+
server_text (Observed[str]): Название сервера, как показано.
|
|
99
|
+
server_id (Observed[str]): Идентификатор сервера из атрибута.
|
|
100
|
+
filter_type (Observed[str]): Значение фильтра строки.
|
|
101
|
+
description_text (Observed[str]): Описание, как показано.
|
|
102
|
+
price_text (Observed[str]): Цена без знака валюты.
|
|
103
|
+
currency_symbol_text (Observed[str]): Знак валюты.
|
|
104
|
+
sort_value (Observed[str]): Значение сортировки ячейки цены.
|
|
105
|
+
price (Observed[Money]): Показанная цена при scale 6, без округления.
|
|
106
|
+
promoted (bool): Помечено ли предложение поднятым.
|
|
107
|
+
row_index (int): Место строки, считая с нуля.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
offer_id: Observed[str]
|
|
111
|
+
offer_href: Observed[str]
|
|
112
|
+
seller_href: Observed[str]
|
|
113
|
+
seller_name_text: Observed[str]
|
|
114
|
+
seller_online: bool
|
|
115
|
+
server_text: Observed[str]
|
|
116
|
+
server_id: Observed[str]
|
|
117
|
+
filter_type: Observed[str]
|
|
118
|
+
description_text: Observed[str]
|
|
119
|
+
price_text: Observed[str]
|
|
120
|
+
currency_symbol_text: Observed[str]
|
|
121
|
+
sort_value: Observed[str]
|
|
122
|
+
promoted: bool
|
|
123
|
+
row_index: int
|
|
124
|
+
price: Observed[Money] = field(default_factory=lambda: Observed.missing("price_not_normalized"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class MarketPage:
|
|
129
|
+
"""Публичный список предложений одного раздела.
|
|
130
|
+
|
|
131
|
+
Attributes:
|
|
132
|
+
completeness (Completeness): Полнота чтения.
|
|
133
|
+
reason (str | None): Причина неполноты.
|
|
134
|
+
observed_at (datetime): Момент наблюдения.
|
|
135
|
+
rows_total (int): Сколько строк нашлось.
|
|
136
|
+
rows_accepted (int): Сколько собрано.
|
|
137
|
+
rows_lazy (int): Сколько строк помечено ленивой загрузкой. Усечением это
|
|
138
|
+
НЕ является: разметка отдана целиком, класс говорит о показе.
|
|
139
|
+
defects (tuple[Defect, ...]): Замеченные повреждения.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
completeness: Completeness
|
|
143
|
+
reason: str | None
|
|
144
|
+
observed_at: datetime
|
|
145
|
+
rows_total: int
|
|
146
|
+
rows_accepted: int
|
|
147
|
+
rows_lazy: int
|
|
148
|
+
defects: tuple[Defect, ...] = ()
|
|
149
|
+
_offers: tuple[MarketOffer, ...] = field(default=(), repr=False)
|
|
150
|
+
|
|
151
|
+
def offers(self, *, accept_incomplete: bool = False) -> tuple[MarketOffer, ...]:
|
|
152
|
+
"""Возвращает собранные предложения.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
accept_incomplete (bool): Признание готовности работать с неполным
|
|
156
|
+
результатом.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
tuple[MarketOffer, ...]: Предложения в порядке появления.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
|
|
163
|
+
не признана.
|
|
164
|
+
"""
|
|
165
|
+
if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
|
|
166
|
+
raise IncompleteResultError(
|
|
167
|
+
f"список прочитан не полностью ({self.completeness}, причина: "
|
|
168
|
+
f"{self.reason}), собрано {self.rows_accepted} из {self.rows_total}. "
|
|
169
|
+
"Передайте accept_incomplete=True, если готовы работать с неполными "
|
|
170
|
+
"данными"
|
|
171
|
+
)
|
|
172
|
+
return self._offers
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _text(node: Node | None, name: str) -> Observed[str]:
|
|
176
|
+
"""Читает текст узла как наблюдение.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
node (Node | None): Узел либо None.
|
|
180
|
+
name (str): Имя поля для причины отсутствия.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Observed[str]: Наблюдение.
|
|
184
|
+
"""
|
|
185
|
+
if node is None:
|
|
186
|
+
return Observed.missing(f"selector_no_match:{name}")
|
|
187
|
+
value = " ".join((node.text() or "").split())
|
|
188
|
+
return Observed.present(value) if value else Observed.empty("")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _own_text(node: Node | None, name: str) -> Observed[str]:
|
|
192
|
+
"""Читает собственный текст узла, без текста вложенных.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
node (Node | None): Узел либо None.
|
|
196
|
+
name (str): Имя поля для причины отсутствия.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Observed[str]: Наблюдение.
|
|
200
|
+
"""
|
|
201
|
+
if node is None:
|
|
202
|
+
return Observed.missing(f"selector_no_match:{name}")
|
|
203
|
+
own = " ".join((node.text(deep=False) or "").split())
|
|
204
|
+
return Observed.present(own) if own else Observed.empty("")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _attribute(node: Node | None, name: str, field_name: str) -> Observed[str]:
|
|
208
|
+
"""Читает атрибут узла как наблюдение.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
node (Node | None): Узел либо None.
|
|
212
|
+
name (str): Имя атрибута.
|
|
213
|
+
field_name (str): Имя поля для причины отсутствия.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Observed[str]: Наблюдение.
|
|
217
|
+
"""
|
|
218
|
+
if node is None:
|
|
219
|
+
return Observed.missing(f"carrier_missing:{field_name}")
|
|
220
|
+
attributes = node.attributes or {}
|
|
221
|
+
if name not in attributes:
|
|
222
|
+
return Observed.missing(f"attribute_absent:{field_name}")
|
|
223
|
+
value = (attributes.get(name) or "").strip()
|
|
224
|
+
return Observed.present(value) if value else Observed.empty("")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _classes(node: Node) -> set[str]:
|
|
228
|
+
"""Возвращает классы узла множеством.
|
|
229
|
+
|
|
230
|
+
Разбор идёт по ТОКЕНАМ, а не по подстроке: подстрока «tc-server» входит и в
|
|
231
|
+
«tc-server-inside», и счёт по ней разошёлся бы вдвое.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
node (Node): Узел.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
set[str]: Классы.
|
|
238
|
+
"""
|
|
239
|
+
return set(((node.attributes or {}).get("class") or "").split())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _query_param(href: Observed[str], name: str, field_name: str) -> Observed[str]:
|
|
243
|
+
"""Достаёт параметр строки запроса из наблюдённого адреса.
|
|
244
|
+
|
|
245
|
+
ЧИТАЕТСЯ ИЗ УЖЕ НАБЛЮДЁННОГО, а не из узла заново. Так наблюдённость
|
|
246
|
+
наследуется честно: нет ссылки - нет и идентификатора, и причина у обоих
|
|
247
|
+
одна.
|
|
248
|
+
|
|
249
|
+
Аргументы:
|
|
250
|
+
href (Observed[str]): Адрес, каким его прочитали.
|
|
251
|
+
name (str): Имя параметра.
|
|
252
|
+
field_name (str): Имя поля для причины ненаблюдения.
|
|
253
|
+
|
|
254
|
+
Возвращает:
|
|
255
|
+
Observed[str]: Значение параметра либо причина, по которой его нет.
|
|
256
|
+
"""
|
|
257
|
+
if not href.is_observed:
|
|
258
|
+
return Observed.missing(f"carrier_missing:{field_name}")
|
|
259
|
+
|
|
260
|
+
for key, value in parse_qsl(urlsplit(href.value).query, keep_blank_values=True):
|
|
261
|
+
if key != name:
|
|
262
|
+
continue
|
|
263
|
+
# Пустое значение параметра - не идентификатор. Отдать его значило бы
|
|
264
|
+
# выдать «id=» за прочитанный лот.
|
|
265
|
+
return Observed.present(value) if value else Observed.missing(f"empty:{field_name}")
|
|
266
|
+
return Observed.missing(f"no_query_param:{name}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _row(node: Node, index: int) -> tuple[MarketOffer, list[Defect]]:
|
|
270
|
+
"""Собирает одно предложение из строки.
|
|
271
|
+
|
|
272
|
+
Продавец читается из ССЫЛКИ НА ПРОФИЛЬ, а не из атрибута data-user: тот есть
|
|
273
|
+
только у поднятых строк, и разбор по нему отдал бы продавца у двух процентов
|
|
274
|
+
списка.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
node (Node): Узел строки.
|
|
278
|
+
index (int): Место строки.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
tuple[MarketOffer, list[Defect]]: Предложение и перечень повреждений.
|
|
282
|
+
"""
|
|
283
|
+
defects: list[Defect] = []
|
|
284
|
+
seller_link = node.css_first(_SELLER_LINK)
|
|
285
|
+
seller_href = _attribute(seller_link, _SELLER_HREF, "seller_href")
|
|
286
|
+
if not seller_href.is_observed:
|
|
287
|
+
defects.append(
|
|
288
|
+
Defect(
|
|
289
|
+
severity=Severity.ROW,
|
|
290
|
+
code="seller_link_missing",
|
|
291
|
+
detail=(
|
|
292
|
+
f"у строки нет ссылки на профиль продавца ({_SELLER_LINK}, атрибут "
|
|
293
|
+
f"{_SELLER_HREF}). Это единственный носитель продавца, годный для "
|
|
294
|
+
"всех строк: атрибут data-user есть только у поднятых"
|
|
295
|
+
),
|
|
296
|
+
row_index=index,
|
|
297
|
+
field_name="seller_href",
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
price_cell = node.css_first(_PRICE_CELL)
|
|
302
|
+
classes = _classes(node)
|
|
303
|
+
offer_href = _attribute(node, _OFFER_HREF, "offer_href")
|
|
304
|
+
offer_id = _query_param(offer_href, _OFFER_ID_PARAM, "offer_id")
|
|
305
|
+
if not offer_id.is_observed and offer_href.is_observed:
|
|
306
|
+
defects.append(
|
|
307
|
+
Defect(
|
|
308
|
+
severity=Severity.ROW,
|
|
309
|
+
code="offer_id_missing",
|
|
310
|
+
detail=(
|
|
311
|
+
f"в ссылке строки нет параметра {_OFFER_ID_PARAM!r}. Это "
|
|
312
|
+
"единственный носитель идентификатора чужого предложения: "
|
|
313
|
+
"атрибутом он не выносится нигде"
|
|
314
|
+
),
|
|
315
|
+
row_index=index,
|
|
316
|
+
field_name="offer_id",
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
price_text = _own_text(node.css_first(_PRICE_TEXT), "price_text")
|
|
320
|
+
currency_symbol = _text(node.css_first(_CURRENCY), "currency_symbol_text")
|
|
321
|
+
price: Observed[Money] = Observed.missing("price_source_unobserved")
|
|
322
|
+
if price_text.is_observed and currency_symbol.is_observed:
|
|
323
|
+
try:
|
|
324
|
+
price = Observed.present(parse_display_price(price_text.value, currency_symbol.value))
|
|
325
|
+
except ValidationError:
|
|
326
|
+
price = Observed.missing("price_not_normalizable")
|
|
327
|
+
defects.append(
|
|
328
|
+
Defect(
|
|
329
|
+
severity=Severity.FIELD,
|
|
330
|
+
code="price_not_normalizable",
|
|
331
|
+
detail="показанная цена не переводится в Money без потери точности",
|
|
332
|
+
row_index=index,
|
|
333
|
+
field_name="price",
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
return (
|
|
337
|
+
MarketOffer(
|
|
338
|
+
offer_id=offer_id,
|
|
339
|
+
offer_href=offer_href,
|
|
340
|
+
seller_href=seller_href,
|
|
341
|
+
seller_name_text=_text(node.css_first(_SELLER_NAME), "seller_name_text"),
|
|
342
|
+
# Признак читается НАЛИЧИЕМ атрибута, а не значением: значение
|
|
343
|
+
# наблюдалось одно на всех строках, где атрибут есть, а отсутствие
|
|
344
|
+
# наблюдалось у восьмисот девятнадцати строк той же страницы.
|
|
345
|
+
seller_online=_ONLINE in (node.attributes or {}),
|
|
346
|
+
server_text=_text(node.css_first(_SERVER), "server_text"),
|
|
347
|
+
server_id=_attribute(node, _SERVER_ID, "server_id"),
|
|
348
|
+
filter_type=_attribute(node, _FILTER_TYPE, "filter_type"),
|
|
349
|
+
description_text=_text(node.css_first(_DESCRIPTION), "description_text"),
|
|
350
|
+
price_text=price_text,
|
|
351
|
+
currency_symbol_text=currency_symbol,
|
|
352
|
+
price=price,
|
|
353
|
+
sort_value=_attribute(price_cell, _SORT_VALUE, "sort_value"),
|
|
354
|
+
promoted=bool(classes & {_PROMO, _PROMOTED}),
|
|
355
|
+
row_index=index,
|
|
356
|
+
),
|
|
357
|
+
defects,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def parse_market(html: str, *, observed_at: datetime) -> MarketPage:
|
|
362
|
+
"""Разбирает публичный список предложений раздела.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
html (str): Тело страницы.
|
|
366
|
+
observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
|
|
367
|
+
разбор оставался чистым и повторяемым на сохранённом снимке.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
MarketPage: Предложения раздела.
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
ProtocolChangedError: Если на странице нет ни одной строки. Пустой список
|
|
374
|
+
вернуть нельзя: он неотличим от смены разметки, а разница решает,
|
|
375
|
+
искать ли товар в другом разделе.
|
|
376
|
+
"""
|
|
377
|
+
tree = HTMLParser(html)
|
|
378
|
+
rows = tree.css(_ROW)
|
|
379
|
+
if not rows:
|
|
380
|
+
raise ProtocolChangedError(
|
|
381
|
+
f"на странице нет ни одной строки предложения ({_ROW}). Пустой список "
|
|
382
|
+
"вернуть нельзя: он неотличим от смены разметки, а разница решает, искать "
|
|
383
|
+
"ли товар в другом разделе"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
offers: list[MarketOffer] = []
|
|
387
|
+
defects: list[Defect] = []
|
|
388
|
+
lazy = 0
|
|
389
|
+
for index, node in enumerate(rows):
|
|
390
|
+
if "lazyload-hidden" in _classes(node):
|
|
391
|
+
lazy += 1
|
|
392
|
+
offer, row_defects = _row(node, index)
|
|
393
|
+
offers.append(offer)
|
|
394
|
+
defects += row_defects
|
|
395
|
+
|
|
396
|
+
damaged = any(one.severity is Severity.ROW for one in defects)
|
|
397
|
+
return MarketPage(
|
|
398
|
+
completeness=Completeness.PARTIAL if damaged else Completeness.COMPLETE,
|
|
399
|
+
reason="rows_damaged" if damaged else None,
|
|
400
|
+
observed_at=observed_at,
|
|
401
|
+
rows_total=len(rows),
|
|
402
|
+
rows_accepted=len(offers),
|
|
403
|
+
rows_lazy=lazy,
|
|
404
|
+
defects=tuple(defects),
|
|
405
|
+
_offers=tuple(offers),
|
|
406
|
+
)
|
funora/_matching.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
import unicodedata
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Final
|
|
25
|
+
|
|
26
|
+
from ._observed import Confidence, Observed
|
|
27
|
+
|
|
28
|
+
__all__ = ["MatchOutcome", "match_offer", "normalized_for_match"]
|
|
29
|
+
|
|
30
|
+
#: Причина, по которой лот не выбран: совпадений нет вовсе.
|
|
31
|
+
NO_MATCH: Final[str] = "no_offer_matched"
|
|
32
|
+
|
|
33
|
+
#: Причина: совпадений несколько, и они не вложены друг в друга.
|
|
34
|
+
AMBIGUOUS: Final[str] = "offer_match_ambiguous"
|
|
35
|
+
|
|
36
|
+
#: Причина: у заказа нет описания, сравнивать не с чем.
|
|
37
|
+
NO_ORDER_TEXT: Final[str] = "order_description_not_observed"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class MatchOutcome:
|
|
42
|
+
"""Чем кончилось сопоставление.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
offer_id (Observed[str]): Идентификатор выбранного предложения.
|
|
46
|
+
Ненаблюдённый, если выбрать не удалось.
|
|
47
|
+
candidates (tuple[str, ...]): Идентификаторы всех подошедших
|
|
48
|
+
предложений. При отказе по двусмысленности их больше одного, и
|
|
49
|
+
перечень нужен человеку: по нему видно, какие именно лоты
|
|
50
|
+
неразличимы, и какой из них переименовать.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
offer_id: Observed[str]
|
|
54
|
+
candidates: tuple[str, ...]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalized_for_match(text: str) -> str:
|
|
58
|
+
"""Приводит текст к виду, пригодному для сравнения.
|
|
59
|
+
|
|
60
|
+
Три действия, и каждое против своего вида расхождения. Совместимая
|
|
61
|
+
нормализация Unicode сводит разные записи одного знака к одной. Приведение
|
|
62
|
+
регистра снимает различие прописных и строчных. Схлопывание пробельных
|
|
63
|
+
последовательностей снимает разницу между одним пробелом, двумя и переводом
|
|
64
|
+
строки: площадка показывает текст в разметке, и переносы в нём случайны.
|
|
65
|
+
|
|
66
|
+
Аргументы:
|
|
67
|
+
text (str): Исходный текст.
|
|
68
|
+
|
|
69
|
+
Возвращает:
|
|
70
|
+
str: Текст, пригодный для сравнения.
|
|
71
|
+
"""
|
|
72
|
+
return " ".join(unicodedata.normalize("NFKC", text).casefold().split())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def match_offer(order_description: Observed[str], lots: dict[str, Observed[str]]) -> MatchOutcome:
|
|
76
|
+
"""Ищет собственный лот, о котором идёт речь в заказе.
|
|
77
|
+
|
|
78
|
+
ИЩЕТСЯ ВХОЖДЕНИЕ ОПИСАНИЯ ЛОТА В ОПИСАНИЕ ЗАКАЗА, и только оно. Не
|
|
79
|
+
равенство: площадка дописывает к описанию заказа то, что выбрал покупатель.
|
|
80
|
+
Не обратное вхождение: описание заказа, оказавшееся внутри описания лота,
|
|
81
|
+
означало бы, что лот описан подробнее заказа, - а это признак не того лота.
|
|
82
|
+
|
|
83
|
+
ПУСТОЕ ОПИСАНИЕ ЛОТА НИКОГДА НЕ КАНДИДАТ. Пустая строка входит в любую, и
|
|
84
|
+
один лот с пустым описанием совпал бы разом со всеми заказами.
|
|
85
|
+
|
|
86
|
+
НЕСКОЛЬКО КАНДИДАТОВ - ОТКАЗ. Исключение ровно одно и оно проверяемо: если
|
|
87
|
+
все кандидаты вложены друг в друга одной цепочкой, берётся самый длинный.
|
|
88
|
+
Цепочка означает, что лоты описаны уточнением друг друга, и длиннейший
|
|
89
|
+
описывает точнее. Развилка - два лота, ни один из которых не уточняет
|
|
90
|
+
другого, - означает, что различить их нечем, и выбирать наугад нельзя.
|
|
91
|
+
|
|
92
|
+
Аргументы:
|
|
93
|
+
order_description (Observed[str]): Описание из строки списка продаж.
|
|
94
|
+
lots (dict[str, Observed[str]]): Описания собственных лотов по
|
|
95
|
+
идентификатору предложения.
|
|
96
|
+
|
|
97
|
+
Возвращает:
|
|
98
|
+
MatchOutcome: Выбранное предложение либо честная причина отказа.
|
|
99
|
+
"""
|
|
100
|
+
if not order_description.is_observed or not order_description.value.strip():
|
|
101
|
+
return MatchOutcome(Observed.missing(NO_ORDER_TEXT), ())
|
|
102
|
+
|
|
103
|
+
haystack = normalized_for_match(order_description.value)
|
|
104
|
+
|
|
105
|
+
hits: list[tuple[str, str]] = []
|
|
106
|
+
for offer_id, description in lots.items():
|
|
107
|
+
if not description.is_observed:
|
|
108
|
+
continue
|
|
109
|
+
needle = normalized_for_match(description.value)
|
|
110
|
+
if not needle:
|
|
111
|
+
continue
|
|
112
|
+
if needle in haystack:
|
|
113
|
+
hits.append((offer_id, needle))
|
|
114
|
+
|
|
115
|
+
if not hits:
|
|
116
|
+
return MatchOutcome(Observed.missing(NO_MATCH), ())
|
|
117
|
+
|
|
118
|
+
if len(hits) == 1:
|
|
119
|
+
# Уверенность ВЫВЕДЕННАЯ, и другой у неё быть не может: правило
|
|
120
|
+
# опирается на текст, часть которого пишет не площадка.
|
|
121
|
+
return MatchOutcome(Observed.present(hits[0][0], Confidence.INFERRED), (hits[0][0],))
|
|
122
|
+
|
|
123
|
+
ordered = sorted(hits, key=lambda one: len(one[1]))
|
|
124
|
+
|
|
125
|
+
# Вложенность требуется СТРОГАЯ: каждое следующее описание длиннее
|
|
126
|
+
# предыдущего и содержит его.
|
|
127
|
+
#
|
|
128
|
+
# Строгость здесь не педантизм, и стоила она дорого. Правило без неё
|
|
129
|
+
# считало цепочкой два лота с ОДИНАКОВЫМ описанием: строка входит сама в
|
|
130
|
+
# себя, условие выполнялось, и выбирался тот, что оказался последним после
|
|
131
|
+
# сортировки, - то есть наугад.
|
|
132
|
+
#
|
|
133
|
+
# Два лота с одинаковым описанием - это ровно тот случай, ради которого
|
|
134
|
+
# отказ и придуман: различить их нечем. Покупателю ушёл бы чужой товар, и
|
|
135
|
+
# ни в отказе, ни в журнале следа бы не осталось.
|
|
136
|
+
chained = all(
|
|
137
|
+
len(ordered[index][1]) < len(ordered[index + 1][1])
|
|
138
|
+
and ordered[index][1] in ordered[index + 1][1]
|
|
139
|
+
for index in range(len(ordered) - 1)
|
|
140
|
+
)
|
|
141
|
+
if chained:
|
|
142
|
+
return MatchOutcome(
|
|
143
|
+
Observed.present(ordered[-1][0], Confidence.INFERRED),
|
|
144
|
+
tuple(one[0] for one in ordered),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return MatchOutcome(Observed.missing(AMBIGUOUS), tuple(sorted(one[0] for one in hits)))
|