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/_lot_form.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Разбор формы правки предложения и сборка запроса сохранения.
|
|
2
|
+
|
|
3
|
+
ЗАЧЕМ ОТДЕЛЬНЫЙ МОДУЛЬ, а не пара строк в движке. Здесь единственное место в
|
|
4
|
+
проекте, где цена ошибки - ЧУЖОЙ ТЕКСТ. Форма несёт описание лота, сообщение
|
|
5
|
+
покупателю после оплаты и цену; отправить её, потеряв поле, значит стереть у
|
|
6
|
+
продавца то, что он писал руками, и узнать об этом он сможет только глазами.
|
|
7
|
+
|
|
8
|
+
Отсюда три правила, каждое записано в коде отдельно:
|
|
9
|
+
|
|
10
|
+
поля собираются ВСЕ ПОДРЯД, без перечня допустимых - пропущенное поле уходит
|
|
11
|
+
пустым, а перечень отстаёт от площадки молча;
|
|
12
|
+
|
|
13
|
+
сохранение отправляет прочитанное КАК ЕСТЬ, меняя ровно то, что просили;
|
|
14
|
+
|
|
15
|
+
флажок, снятый на странице, останавливает сохранение: что уходит при снятом
|
|
16
|
+
флажке, никто не наблюдал, а разница между «не трогать лот» и «выключить лот»
|
|
17
|
+
как раз в этом.
|
|
18
|
+
|
|
19
|
+
Наблюдено 30-31.08.2026: форма lot-edit.logged.ru и запрос network.lot-save-form.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from hashlib import sha256
|
|
28
|
+
from typing import Final
|
|
29
|
+
|
|
30
|
+
from selectolax.parser import HTMLParser, Node
|
|
31
|
+
|
|
32
|
+
from ._observed import Observed
|
|
33
|
+
from ._result import Completeness, Defect, Severity
|
|
34
|
+
from ._stock import parse_stock_text
|
|
35
|
+
from .errors import ProtocolChangedError
|
|
36
|
+
from .extraction import SELECTORS
|
|
37
|
+
|
|
38
|
+
__all__ = ["LotForm", "parse_lot_form", "SAVE_PATH", "ACTIVE_FIELD"]
|
|
39
|
+
|
|
40
|
+
#: Адрес сохранения. Наблюдён дословно в атрибуте action формы.
|
|
41
|
+
SAVE_PATH: Final[str] = "/lots/offerSave"
|
|
42
|
+
|
|
43
|
+
#: Имя флажка показа лота в выдаче. Наблюдено и в форме, и в записи запроса.
|
|
44
|
+
#:
|
|
45
|
+
#: Стоит константой, а не литералом, потому что читается и пишется в двух разных
|
|
46
|
+
#: местах модуля: разъехавшись, они дали бы разбор, который видит одно, и запрос,
|
|
47
|
+
#: который меняет другое.
|
|
48
|
+
ACTIVE_FIELD: Final[str] = "active"
|
|
49
|
+
|
|
50
|
+
#: Селектор формы правки. Берётся из порождённой таблицы, а не пишется
|
|
51
|
+
#: литералом: иначе спецификация и код разойдутся молча.
|
|
52
|
+
_FORM: Final[str] = SELECTORS["lot-edit.form"]
|
|
53
|
+
|
|
54
|
+
#: Знак валюты рядом с полем цены.
|
|
55
|
+
_CURRENCY: Final[str] = SELECTORS["lot-edit.fields.currency_symbol"]
|
|
56
|
+
_AMOUNT: Final[str] = SELECTORS["lot-edit.fields.amount"]
|
|
57
|
+
|
|
58
|
+
#: Поля, которые в отпечаток НЕ входят.
|
|
59
|
+
#:
|
|
60
|
+
#: Защитный токен и метка сборки формы меняются при каждом чтении страницы, и
|
|
61
|
+
#: отпечаток, считанный вместе с ними, не совпал бы сам с собой уже через
|
|
62
|
+
#: секунду. Отпечаток обязан меняться от правки ЛОТА, а не от перезагрузки.
|
|
63
|
+
_VOLATILE: Final[frozenset[str]] = frozenset({"csrf_token", "form_created_at"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class LotForm:
|
|
68
|
+
"""Прочитанная форма правки предложения.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
offer_id (str): Идентификатор предложения.
|
|
72
|
+
node_id (str): Идентификатор раздела.
|
|
73
|
+
price_text (str): Цена, как она стоит в поле.
|
|
74
|
+
currency_symbol (Observed[str]): Знак валюты рядом с полем цены.
|
|
75
|
+
is_active (bool): Показывается ли лот в выдаче. Читается НАЛИЧИЕМ
|
|
76
|
+
пометки checked у флажка active.
|
|
77
|
+
revision (str): Отпечаток состояния лота. Наш собственный, а не
|
|
78
|
+
площадкин: площадка версии не даёт вовсе.
|
|
79
|
+
fields (dict[str, str]): Все поля формы, кроме флажков, как есть.
|
|
80
|
+
checked (frozenset[str]): Имена отмеченных флажков.
|
|
81
|
+
observed_at (datetime): Момент чтения.
|
|
82
|
+
completeness (Completeness): Полнота чтения.
|
|
83
|
+
reason (str): Почему полнота такая.
|
|
84
|
+
defects (tuple[Defect, ...]): Что не собралось.
|
|
85
|
+
stock (Observed[int]): Наличие из единственного текстового поля amount.
|
|
86
|
+
Без поля, при disabled или неизвестном формате значение не выводится.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
offer_id: str
|
|
90
|
+
node_id: str
|
|
91
|
+
price_text: str
|
|
92
|
+
currency_symbol: Observed[str]
|
|
93
|
+
is_active: bool
|
|
94
|
+
revision: str
|
|
95
|
+
fields: dict[str, str]
|
|
96
|
+
checked: frozenset[str]
|
|
97
|
+
observed_at: datetime
|
|
98
|
+
completeness: Completeness
|
|
99
|
+
reason: str
|
|
100
|
+
defects: tuple[Defect, ...] = field(default_factory=tuple)
|
|
101
|
+
_checkbox_values: dict[str, str] = field(default_factory=dict, repr=False)
|
|
102
|
+
stock: Observed[int] = field(default_factory=lambda: Observed.missing("stock_not_normalized"))
|
|
103
|
+
|
|
104
|
+
def to_request(self, *, price: str | None = None, active: bool | None = None) -> dict[str, str]:
|
|
105
|
+
"""Собирает поля запроса сохранения.
|
|
106
|
+
|
|
107
|
+
ОТПРАВЛЯЕТСЯ ПРОЧИТАННОЕ, а меняется ровно то, что просили. Собрать
|
|
108
|
+
запрос из перечня нужных полей было бы короче и стёрло бы всё
|
|
109
|
+
остальное: описание лота, сообщение покупателю, картинки.
|
|
110
|
+
|
|
111
|
+
Отмеченный флажок уходит значением «on» - это наблюдено в записи
|
|
112
|
+
запроса.
|
|
113
|
+
|
|
114
|
+
СНЯТЫЙ ФЛАЖОК УХОДИТ ПУСТОЙ СТРОКОЙ, и вот это НАМИ НЕ НАБЛЮДАЛОСЬ.
|
|
115
|
+
Оба наших снимка сохранения сняты с отмеченным флажком; вид запроса при
|
|
116
|
+
снятом известен от независимой реализации того же протокола, которая
|
|
117
|
+
шлёт поле всегда. Наше собственное рассуждение говорило обратное - что
|
|
118
|
+
снятый флажок по устройству форм не уходит вовсе.
|
|
119
|
+
|
|
120
|
+
Различие не косметическое: «поля нет» и «поле есть, но пустое» - разные
|
|
121
|
+
запросы, и который из двух выключает лот, решается на стороне площадки.
|
|
122
|
+
Поэтому обе операции видимости объявлены стоящими на вторичном источнике
|
|
123
|
+
и спрашивают согласия.
|
|
124
|
+
|
|
125
|
+
Аргументы:
|
|
126
|
+
price (str | None): Новая цена либо None, чтобы оставить прежнюю.
|
|
127
|
+
active (bool | None): Требуемое состояние показа либо None, чтобы
|
|
128
|
+
оставить как прочитано.
|
|
129
|
+
|
|
130
|
+
Возвращает:
|
|
131
|
+
dict[str, str]: Поля запроса.
|
|
132
|
+
"""
|
|
133
|
+
out = dict(self.fields)
|
|
134
|
+
if price is not None:
|
|
135
|
+
out["price"] = price
|
|
136
|
+
|
|
137
|
+
checked = set(self.checked)
|
|
138
|
+
if active is True:
|
|
139
|
+
checked.add(ACTIVE_FIELD)
|
|
140
|
+
elif active is False:
|
|
141
|
+
checked.discard(ACTIVE_FIELD)
|
|
142
|
+
# Пустая строка, а не отсутствие ключа. См. пояснение выше: это
|
|
143
|
+
# единственное место всего пакета, где уходит непроверенное нами.
|
|
144
|
+
out[ACTIVE_FIELD] = ""
|
|
145
|
+
|
|
146
|
+
for name in sorted(checked):
|
|
147
|
+
out[name] = self._checkbox_values.get(name, "on")
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _revision_of(
|
|
152
|
+
fields: dict[str, str], checked: frozenset[str], checkbox_values: dict[str, str]
|
|
153
|
+
) -> str:
|
|
154
|
+
"""Считает отпечаток состояния лота.
|
|
155
|
+
|
|
156
|
+
ОТПЕЧАТОК НАШ, а не площадкин, и это надо сказать вслух. Контракт требует
|
|
157
|
+
предусловия expected_revision, чтобы параллельная правка не была перетёрта
|
|
158
|
+
молча; площадка же версии лота не даёт нигде.
|
|
159
|
+
|
|
160
|
+
Поэтому версией служит отпечаток самих значений: изменилось что угодно -
|
|
161
|
+
изменился отпечаток. Это слабее настоящей версии в одном: правка, вернувшая
|
|
162
|
+
прежние значения, отпечатка не меняет. И сильнее в другом: он замечает
|
|
163
|
+
правку любого поля, а не только объявленных.
|
|
164
|
+
|
|
165
|
+
Аргументы:
|
|
166
|
+
fields (dict[str, str]): Поля формы.
|
|
167
|
+
checked (frozenset[str]): Отмеченные флажки.
|
|
168
|
+
checkbox_values (dict[str, str]): Значения флажков, включая снятые.
|
|
169
|
+
|
|
170
|
+
Возвращает:
|
|
171
|
+
str: Шестнадцать шестнадцатеричных знаков.
|
|
172
|
+
"""
|
|
173
|
+
# Границы значений нельзя обозначать переводом строки или '=': оба знака
|
|
174
|
+
# встречаются в полях. Скрытое значение и флажок одного имени раздельны:
|
|
175
|
+
# при снятом флажке в запрос уходит именно скрытое значение.
|
|
176
|
+
parts = [
|
|
177
|
+
"lot-form-v2",
|
|
178
|
+
[[name, value] for name, value in sorted(fields.items()) if name not in _VOLATILE],
|
|
179
|
+
[[name, value] for name, value in sorted(checkbox_values.items())],
|
|
180
|
+
sorted(checked),
|
|
181
|
+
]
|
|
182
|
+
material = json.dumps(parts, ensure_ascii=True, separators=(",", ":"), allow_nan=False)
|
|
183
|
+
return sha256(material.encode("ascii")).hexdigest()[:16]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _text(node: Node | None) -> str:
|
|
187
|
+
"""Читает текст узла.
|
|
188
|
+
|
|
189
|
+
Аргументы:
|
|
190
|
+
node (Node | None): Узел либо None.
|
|
191
|
+
|
|
192
|
+
Возвращает:
|
|
193
|
+
str: Текст без краевых пробелов.
|
|
194
|
+
"""
|
|
195
|
+
return (node.text() or "").strip() if node is not None else ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _stock(form: Node) -> Observed[int]:
|
|
199
|
+
controls = form.css(_AMOUNT)
|
|
200
|
+
if not controls:
|
|
201
|
+
return parse_stock_text(None)
|
|
202
|
+
if len(controls) != 1:
|
|
203
|
+
return Observed.missing("stock_ambiguous")
|
|
204
|
+
node = controls[0]
|
|
205
|
+
attributes = node.attributes
|
|
206
|
+
if "disabled" in attributes:
|
|
207
|
+
return Observed.missing("stock_disabled")
|
|
208
|
+
if node.tag != "input" or (attributes.get("type") or "text").lower() != "text":
|
|
209
|
+
return Observed.missing("stock_control_unknown")
|
|
210
|
+
return parse_stock_text(attributes.get("value") or "")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_lot_form(html: str, *, observed_at: datetime) -> LotForm:
|
|
214
|
+
"""Разбирает страницу правки предложения.
|
|
215
|
+
|
|
216
|
+
Аргументы:
|
|
217
|
+
html (str): Разметка страницы.
|
|
218
|
+
observed_at (datetime): Момент чтения.
|
|
219
|
+
|
|
220
|
+
Возвращает:
|
|
221
|
+
LotForm: Прочитанная форма.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ProtocolChangedError: Если формы нет либо в ней нет обязательных полей.
|
|
225
|
+
"""
|
|
226
|
+
form = HTMLParser(html).css_first(_FORM)
|
|
227
|
+
if form is None:
|
|
228
|
+
raise ProtocolChangedError(
|
|
229
|
+
f"на странице правки нет формы {_FORM!r}. Собирать запрос сохранения "
|
|
230
|
+
"не из чего, и догадываться о полях здесь нельзя: лишнее поле "
|
|
231
|
+
"стирает описание лота"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
fields: dict[str, str] = {}
|
|
235
|
+
checked: set[str] = set()
|
|
236
|
+
checkbox_values: dict[str, str] = {}
|
|
237
|
+
defects: list[Defect] = []
|
|
238
|
+
|
|
239
|
+
# Поля собираются ВСЕ ПОДРЯД. Перечень допустимых отстал бы от площадки
|
|
240
|
+
# молча, а отставание здесь означает пустое поле в запросе - то есть
|
|
241
|
+
# стёртый текст продавца.
|
|
242
|
+
for node in form.css("input, textarea, select"):
|
|
243
|
+
attributes = node.attributes or {}
|
|
244
|
+
name = attributes.get("name")
|
|
245
|
+
if not name:
|
|
246
|
+
# Кнопки имени не имеют и в запрос не уходят - так наблюдено.
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
if "disabled" in attributes:
|
|
250
|
+
continue
|
|
251
|
+
kind = (attributes.get("type") or "text").lower()
|
|
252
|
+
if kind in {"submit", "button", "reset", "file"}:
|
|
253
|
+
continue
|
|
254
|
+
if kind == "checkbox":
|
|
255
|
+
if name in checkbox_values:
|
|
256
|
+
raise ProtocolChangedError(
|
|
257
|
+
f"несколько флажков {name!r} требуют нескольких значений"
|
|
258
|
+
)
|
|
259
|
+
checkbox_values[name] = (
|
|
260
|
+
(attributes.get("value") or "") if "value" in attributes else "on"
|
|
261
|
+
)
|
|
262
|
+
if "checked" in attributes:
|
|
263
|
+
checked.add(name)
|
|
264
|
+
continue
|
|
265
|
+
if kind == "radio":
|
|
266
|
+
if "checked" not in attributes:
|
|
267
|
+
continue
|
|
268
|
+
value = attributes.get("value") if "value" in attributes else "on"
|
|
269
|
+
elif node.tag == "textarea":
|
|
270
|
+
value = node.text() or ""
|
|
271
|
+
elif node.tag == "select":
|
|
272
|
+
options = node.css("option")
|
|
273
|
+
selected = [one for one in options if "selected" in one.attributes]
|
|
274
|
+
if "multiple" in attributes or len(selected) > 1:
|
|
275
|
+
raise ProtocolChangedError(
|
|
276
|
+
f"поле {name!r} требует нескольких значений: плоская форма их потеряет"
|
|
277
|
+
)
|
|
278
|
+
option = selected[0] if selected else (options[0] if options else None)
|
|
279
|
+
if option is None:
|
|
280
|
+
raise ProtocolChangedError(f"у поля {name!r} нет вариантов")
|
|
281
|
+
value = (
|
|
282
|
+
option.attributes.get("value") if "value" in option.attributes else option.text()
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
value = attributes.get("value") or ""
|
|
286
|
+
if name in fields:
|
|
287
|
+
raise ProtocolChangedError(
|
|
288
|
+
f"повтор поля {name!r}: сохранение потеряет одно из значений"
|
|
289
|
+
)
|
|
290
|
+
fields[name] = value or ""
|
|
291
|
+
|
|
292
|
+
for required in ("csrf_token", "offer_id", "node_id", "price", "form_created_at"):
|
|
293
|
+
if required not in fields:
|
|
294
|
+
defects.append(
|
|
295
|
+
Defect(
|
|
296
|
+
severity=Severity.PAGE,
|
|
297
|
+
code="lot_form_field_missing",
|
|
298
|
+
detail=f"в форме правки нет поля {required!r}",
|
|
299
|
+
row_index=None,
|
|
300
|
+
field_name=required,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if defects:
|
|
305
|
+
raise ProtocolChangedError(
|
|
306
|
+
f"форма правки неполна: {[one.field_name for one in defects]}. "
|
|
307
|
+
"Отправлять её нельзя: недостающее поле уходит пустым, а пустое "
|
|
308
|
+
"поле здесь стирает то, что продавец писал руками"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
symbol = _text(form.css_first(_CURRENCY))
|
|
312
|
+
return LotForm(
|
|
313
|
+
offer_id=fields["offer_id"],
|
|
314
|
+
node_id=fields["node_id"],
|
|
315
|
+
price_text=fields["price"],
|
|
316
|
+
currency_symbol=(
|
|
317
|
+
Observed.present(symbol) if symbol else Observed.missing("selector_no_match:currency")
|
|
318
|
+
),
|
|
319
|
+
# Единственный носитель признака во всём проекте, и читается он
|
|
320
|
+
# НАЛИЧИЕМ пометки, а не значением.
|
|
321
|
+
is_active=ACTIVE_FIELD in checked,
|
|
322
|
+
revision=_revision_of(fields, frozenset(checked), checkbox_values),
|
|
323
|
+
fields=fields,
|
|
324
|
+
checked=frozenset(checked),
|
|
325
|
+
observed_at=observed_at,
|
|
326
|
+
completeness=Completeness.COMPLETE,
|
|
327
|
+
reason="all_fields_parsed",
|
|
328
|
+
defects=(),
|
|
329
|
+
_checkbox_values=checkbox_values,
|
|
330
|
+
stock=_stock(form),
|
|
331
|
+
)
|