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