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/_snapshot.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Снимок выдачи раздела и сравнение двух снимков.
|
|
2
|
+
|
|
3
|
+
ЗАЧЕМ ОТДЕЛЬНО ОТ [_market.py]. Тот читает страницу: строки в порядке показа,
|
|
4
|
+
со всеми полями. Здесь - то, что СРАВНИВАЕТСЯ: предложения по идентификатору,
|
|
5
|
+
отпечаток запроса и полнота.
|
|
6
|
+
|
|
7
|
+
Разделены они не для порядка. Порядок строк меняется от поднятия чужого лота, и
|
|
8
|
+
сравнение по позициям давало бы поток ложных изменений каждую минуту.
|
|
9
|
+
Сравнивать надо по идентификатору - а идентификатор чужого предложения стал
|
|
10
|
+
наблюдаем только 31.08.2026, с формата скелета v9. До него этого модуля не могло
|
|
11
|
+
существовать.
|
|
12
|
+
|
|
13
|
+
Порождения событий рынка здесь нет. Показанная цена теперь нормализуется в
|
|
14
|
+
Money без округления; неизвестный формат остаётся явно ненаблюдённым. Событие
|
|
15
|
+
об исчезновении требует счётчика подряд идущих отсутствий - его ведёт тот,
|
|
16
|
+
кто хранит снимки, а не чистое сравнение.
|
|
17
|
+
|
|
18
|
+
Поэтому здесь выдаётся РАЗНИЦА как данные, а не события. Разница честна: по ней
|
|
19
|
+
видно, что появилось, что пропало и у чего сменилась цена. Наблюдения и события
|
|
20
|
+
порождает monitoring.watch, который хранит историю и подтверждения доставки.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from hashlib import sha256
|
|
28
|
+
from typing import Final
|
|
29
|
+
|
|
30
|
+
from ._market import MarketPage
|
|
31
|
+
from ._money import Money
|
|
32
|
+
from ._observed import Observed
|
|
33
|
+
from ._result import Completeness
|
|
34
|
+
from .errors import UsageError
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"MarketSnapshot",
|
|
38
|
+
"SnapshotEntry",
|
|
39
|
+
"MarketDiff",
|
|
40
|
+
"PriceChange",
|
|
41
|
+
"snapshot_of",
|
|
42
|
+
"compare",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
#: Сколько знаков отпечатка запроса хранится.
|
|
46
|
+
#:
|
|
47
|
+
#: Отпечаток служит равенству, а не тайне: шестнадцати шестнадцатеричных знаков
|
|
48
|
+
#: хватает, чтобы два разных запроса не совпали случайно.
|
|
49
|
+
_FINGERPRINT_LENGTH: Final[int] = 16
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class SnapshotEntry:
|
|
54
|
+
"""Одно предложение внутри снимка.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
offer_id (str): Идентификатор предложения. По нему идёт сравнение.
|
|
58
|
+
price_text (str): Цена, как показана, без знака валюты.
|
|
59
|
+
currency_symbol_text (str): Знак валюты. Входит в сравнение вместе с
|
|
60
|
+
ценой: смена знака при том же числе - это смена цены.
|
|
61
|
+
seller_href (str): Ссылка на профиль продавца.
|
|
62
|
+
position (int): Место в выдаче, считая с нуля.
|
|
63
|
+
price (Observed[Money]): Показанная цена при scale 6 либо причина отсутствия.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
offer_id: str
|
|
67
|
+
price_text: str
|
|
68
|
+
currency_symbol_text: str
|
|
69
|
+
seller_href: str
|
|
70
|
+
position: int
|
|
71
|
+
price: Observed[Money] = field(default_factory=lambda: Observed.missing("price_not_normalized"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class MarketSnapshot:
|
|
76
|
+
"""Состояние выдачи раздела в один момент.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
query_fingerprint (str): Отпечаток запроса. Сравнивать можно только
|
|
80
|
+
снимки с одинаковым отпечатком.
|
|
81
|
+
node_id (str): Раздел, чья выдача снята.
|
|
82
|
+
taken_at (datetime): Момент снятия.
|
|
83
|
+
completeness (Completeness): Полнота снимка.
|
|
84
|
+
reason (str | None): Причина неполноты. None означает полный снимок.
|
|
85
|
+
rows_total (int): Сколько строк нашлось.
|
|
86
|
+
rows_accepted (int): Сколько строк собрано целиком.
|
|
87
|
+
offers (dict[str, SnapshotEntry]): Предложения по идентификатору.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
query_fingerprint: str
|
|
91
|
+
node_id: str
|
|
92
|
+
taken_at: datetime
|
|
93
|
+
completeness: Completeness
|
|
94
|
+
reason: str | None
|
|
95
|
+
rows_total: int
|
|
96
|
+
rows_accepted: int
|
|
97
|
+
offers: dict[str, SnapshotEntry]
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_complete(self) -> bool:
|
|
101
|
+
"""Говорит, полон ли снимок.
|
|
102
|
+
|
|
103
|
+
Возвращает:
|
|
104
|
+
bool: True, если полнота complete.
|
|
105
|
+
"""
|
|
106
|
+
return self.completeness is Completeness.COMPLETE
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class PriceChange:
|
|
111
|
+
"""Смена цены у предложения, оставшегося на месте.
|
|
112
|
+
|
|
113
|
+
Attributes:
|
|
114
|
+
offer_id (str): Предложение.
|
|
115
|
+
before (SnapshotEntry): Каким было.
|
|
116
|
+
after (SnapshotEntry): Каким стало.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
offer_id: str
|
|
120
|
+
before: SnapshotEntry
|
|
121
|
+
after: SnapshotEntry
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class MarketDiff:
|
|
126
|
+
"""Разница между двумя снимками одной выдачи.
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
appeared (tuple[SnapshotEntry, ...]): Предложения, которых в прежнем
|
|
130
|
+
снимке не было.
|
|
131
|
+
absent (tuple[SnapshotEntry, ...]): Предложения, которых нет в новом.
|
|
132
|
+
|
|
133
|
+
ИМЕННО ОТСУТСТВУЮЩИЕ, А НЕ ИСЧЕЗНУВШИЕ. Одно отсутствие не значит
|
|
134
|
+
исчезновения: предложение могло не попасть в чтение. Контракт
|
|
135
|
+
требует у события об исчезновении счётчика подряд идущих
|
|
136
|
+
отсутствий, и вести его - дело того, кто хранит снимки.
|
|
137
|
+
price_changed (tuple[PriceChange, ...]): У кого сменилась цена либо знак
|
|
138
|
+
валюты.
|
|
139
|
+
seller_changed (tuple[PriceChange, ...]): У кого сменился продавец.
|
|
140
|
+
absences_trusted (bool): Можно ли делать выводы об отсутствии.
|
|
141
|
+
|
|
142
|
+
Ложь означает, что один из снимков неполон, и список absent
|
|
143
|
+
заполнен не будет: неполный снимок не отличает «пропало» от «не
|
|
144
|
+
прочитали».
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
appeared: tuple[SnapshotEntry, ...]
|
|
148
|
+
absent: tuple[SnapshotEntry, ...]
|
|
149
|
+
price_changed: tuple[PriceChange, ...]
|
|
150
|
+
seller_changed: tuple[PriceChange, ...]
|
|
151
|
+
absences_trusted: bool
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def fingerprint_of(node_id: str) -> str:
|
|
155
|
+
"""Считает отпечаток запроса.
|
|
156
|
+
|
|
157
|
+
В отпечаток входит один раздел. Фильтры площадки - сервер и наличие -
|
|
158
|
+
наблюдались только в исходном, пустом состоянии; класть в отпечаток
|
|
159
|
+
ненаблюдённое значило бы объявить о нём знание.
|
|
160
|
+
|
|
161
|
+
Аргументы:
|
|
162
|
+
node_id (str): Раздел.
|
|
163
|
+
|
|
164
|
+
Возвращает:
|
|
165
|
+
str: Отпечаток.
|
|
166
|
+
"""
|
|
167
|
+
return sha256(f"node={node_id}".encode()).hexdigest()[:_FINGERPRINT_LENGTH]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def snapshot_of(page: MarketPage, *, node_id: str) -> MarketSnapshot:
|
|
171
|
+
"""Собирает снимок из прочитанной страницы.
|
|
172
|
+
|
|
173
|
+
ПРЕДЛОЖЕНИЯ БЕЗ ИДЕНТИФИКАТОРА В СНИМОК НЕ ПОПАДАЮТ. Сравнивать их не по
|
|
174
|
+
чему, а положив их под выдуманным ключом, мы породили бы исчезновение на
|
|
175
|
+
ровном месте: в следующем снимке выдуманный ключ будет другим.
|
|
176
|
+
|
|
177
|
+
Их потеря при этом видна: rows_accepted берётся у страницы и остаётся
|
|
178
|
+
больше, чем число предложений в снимке.
|
|
179
|
+
|
|
180
|
+
Аргументы:
|
|
181
|
+
page (MarketPage): Прочитанная страница.
|
|
182
|
+
node_id (str): Раздел, у которого она прочитана.
|
|
183
|
+
|
|
184
|
+
Возвращает:
|
|
185
|
+
MarketSnapshot: Снимок, пригодный для сравнения.
|
|
186
|
+
"""
|
|
187
|
+
offers: dict[str, SnapshotEntry] = {}
|
|
188
|
+
for one in page.offers(accept_incomplete=True):
|
|
189
|
+
if not one.offer_id.is_observed:
|
|
190
|
+
continue
|
|
191
|
+
offers[one.offer_id.value] = SnapshotEntry(
|
|
192
|
+
offer_id=one.offer_id.value,
|
|
193
|
+
price_text=one.price_text.or_none() or "",
|
|
194
|
+
currency_symbol_text=one.currency_symbol_text.or_none() or "",
|
|
195
|
+
seller_href=one.seller_href.or_none() or "",
|
|
196
|
+
position=one.row_index,
|
|
197
|
+
price=one.price,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return MarketSnapshot(
|
|
201
|
+
query_fingerprint=fingerprint_of(node_id),
|
|
202
|
+
node_id=node_id,
|
|
203
|
+
taken_at=page.observed_at,
|
|
204
|
+
completeness=page.completeness,
|
|
205
|
+
reason=page.reason,
|
|
206
|
+
rows_total=page.rows_total,
|
|
207
|
+
rows_accepted=page.rows_accepted,
|
|
208
|
+
offers=offers,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def compare(before: MarketSnapshot, after: MarketSnapshot) -> MarketDiff:
|
|
213
|
+
"""Сравнивает два снимка одной выдачи.
|
|
214
|
+
|
|
215
|
+
ОТПЕЧАТКИ ОБЯЗАНЫ СОВПАДАТЬ. Снимок раздела с фильтром и снимок того же
|
|
216
|
+
раздела без фильтра описывают разные множества; сравнив их, получишь
|
|
217
|
+
исчезновение всего, что отфильтровано.
|
|
218
|
+
|
|
219
|
+
ОТСУТСТВИЯ СЧИТАЮТСЯ ТОЛЬКО ПО ДВУМ ПОЛНЫМ СНИМКАМ. Неполный не отличает
|
|
220
|
+
«пропало» от «не прочитали», и разница между ними - это разница между
|
|
221
|
+
«конкурент ушёл, можно поднять цену» и «мы плохо прочитали страницу».
|
|
222
|
+
|
|
223
|
+
Появления при этом считаются и по неполным: предложение, которого раньше не
|
|
224
|
+
было, а теперь есть, вправду есть - неполнота могла его скрыть прежде, но
|
|
225
|
+
не могла выдумать сейчас.
|
|
226
|
+
|
|
227
|
+
Аргументы:
|
|
228
|
+
before (MarketSnapshot): Прежний снимок.
|
|
229
|
+
after (MarketSnapshot): Новый снимок.
|
|
230
|
+
|
|
231
|
+
Возвращает:
|
|
232
|
+
MarketDiff: Разница.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
UsageError: Если отпечатки запросов не совпадают.
|
|
236
|
+
"""
|
|
237
|
+
if before.query_fingerprint != after.query_fingerprint:
|
|
238
|
+
raise UsageError(
|
|
239
|
+
f"снимки сняты разными запросами: {before.query_fingerprint} и "
|
|
240
|
+
f"{after.query_fingerprint}. Сравнивать их нельзя - разные запросы "
|
|
241
|
+
"описывают разные множества, и разница вышла бы исчезновением "
|
|
242
|
+
"всего, что не попало во второй"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
appeared = tuple(entry for key, entry in after.offers.items() if key not in before.offers)
|
|
246
|
+
|
|
247
|
+
trusted = before.is_complete and after.is_complete
|
|
248
|
+
absent = (
|
|
249
|
+
tuple(entry for key, entry in before.offers.items() if key not in after.offers)
|
|
250
|
+
if trusted
|
|
251
|
+
else ()
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
price_changed: list[PriceChange] = []
|
|
255
|
+
seller_changed: list[PriceChange] = []
|
|
256
|
+
for key, now in after.offers.items():
|
|
257
|
+
was = before.offers.get(key)
|
|
258
|
+
if was is None:
|
|
259
|
+
continue
|
|
260
|
+
if (
|
|
261
|
+
was.price.value != now.price.value
|
|
262
|
+
if was.price.is_observed and now.price.is_observed
|
|
263
|
+
else (was.price_text, was.currency_symbol_text)
|
|
264
|
+
!= (now.price_text, now.currency_symbol_text)
|
|
265
|
+
):
|
|
266
|
+
price_changed.append(PriceChange(offer_id=key, before=was, after=now))
|
|
267
|
+
if was.seller_href != now.seller_href:
|
|
268
|
+
seller_changed.append(PriceChange(offer_id=key, before=was, after=now))
|
|
269
|
+
|
|
270
|
+
return MarketDiff(
|
|
271
|
+
appeared=appeared,
|
|
272
|
+
absent=absent,
|
|
273
|
+
price_changed=tuple(price_changed),
|
|
274
|
+
seller_changed=tuple(seller_changed),
|
|
275
|
+
absences_trusted=trusted,
|
|
276
|
+
)
|
funora/_state.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Сохранение состояния клиента между запусками.
|
|
2
|
+
|
|
3
|
+
Единственная задача файла - пережить перезапуск. Спецификация требует этого
|
|
4
|
+
прямо: кэш гашения повторов только в памяти означает, что после любого
|
|
5
|
+
перезапуска повторно приходит всё, что успело прийти до него. Для обработчика,
|
|
6
|
+
выдающего товар, это выданный дважды товар при каждом перезапуске процесса.
|
|
7
|
+
|
|
8
|
+
Три решения, каждое против своего вида беды.
|
|
9
|
+
|
|
10
|
+
Запись атомарна. Файл собирается рядом и переименовывается поверх, а не
|
|
11
|
+
дописывается на месте. Процесс, убитый посреди записи, оставил бы обрезанный
|
|
12
|
+
файл, и следующий запуск не смог бы его прочитать - то есть перезапуск в самый
|
|
13
|
+
неудачный момент отменял бы всю защиту, ради которой файл заведён.
|
|
14
|
+
|
|
15
|
+
Чужой формат не читается молча. Файл, записанный другой версией формата или
|
|
16
|
+
другим семейством адаптера, даёт ошибку, а не пустое состояние. Молчаливый старт
|
|
17
|
+
с нуля здесь неотличим от штатной работы и приводит к повторной обработке всего,
|
|
18
|
+
что уже обработано.
|
|
19
|
+
|
|
20
|
+
Отсутствие файла ошибкой не является. Первый запуск - штатное событие, и
|
|
21
|
+
требовать файл значило бы требовать его создать вручную.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from stat import S_ISDIR, S_ISREG
|
|
29
|
+
from typing import Any, Final
|
|
30
|
+
|
|
31
|
+
from ._canonical import canonical_dumps
|
|
32
|
+
from ._fileio import atomic_write, file_lock
|
|
33
|
+
from ._json import load_json
|
|
34
|
+
from .contract import ADAPTER_FAMILY as _ADAPTER_FAMILY
|
|
35
|
+
from .contract import CANONICAL_FORM_VERSION
|
|
36
|
+
from .errors import CursorIncompatibleError, StateSchemaIncompatibleError
|
|
37
|
+
|
|
38
|
+
__all__ = ["StateFile", "STATE_FORMAT"]
|
|
39
|
+
|
|
40
|
+
#: Версия формата файла состояния.
|
|
41
|
+
#:
|
|
42
|
+
#: Меняется при любом изменении состава сохраняемого. Прочитать файл чужой
|
|
43
|
+
#: версии нельзя: неизвестно, что означают его поля.
|
|
44
|
+
#:
|
|
45
|
+
#: v2 добавила курсор. Файл v1 хранил только гашение повторов, и старт по нему
|
|
46
|
+
#: ушёл бы в холодный старт, молча съев всё, что изменилось за простой. Отказ
|
|
47
|
+
#: здесь честнее: пользователь удалит файл сам и будет знать, чем это грозит.
|
|
48
|
+
#:
|
|
49
|
+
#: v3 сменила не состав, а СМЫСЛ хранимого. События о самом наблюдении -
|
|
50
|
+
#: watch.primed и snapshot.incomplete - строились вручную: идентификатор
|
|
51
|
+
#: человекочитаемой строкой, ключ упорядочивания «account:...» мимо нормативной
|
|
52
|
+
#: таблицы. Теперь оба строятся общим путём, и прежние отпечатки в гашении
|
|
53
|
+
#: повторов не совпадут ни с чем.
|
|
54
|
+
#:
|
|
55
|
+
#: Молча принять такой файл значило бы выдать приветствие и жалобу на неполноту
|
|
56
|
+
#: повторно - по разу за срок гашения. Не смертельно, но необъяснимо со стороны
|
|
57
|
+
#: пользователя: он увидит «наблюдение началось» у работающего месяц клиента.
|
|
58
|
+
#:
|
|
59
|
+
#: v4: метки гашения повторов перестали быть монотонными секундами и стали
|
|
60
|
+
#: моментами от эпохи. Прежние файлы принять нельзя: дробное показание
|
|
61
|
+
#: секундомера, прочитанное как момент от эпохи, попадает в тысяча девятьсот
|
|
62
|
+
#: семидесятый - и весь кэш гашения молча выбрасывается по сроку. Это ровно
|
|
63
|
+
#: то, что спецификация запрещает прямо: молчаливое чтение с начала порождает
|
|
64
|
+
#: повторную обработку всего, что уже обработано.
|
|
65
|
+
#: v5 хранит непринятую партию до обработчиков. v4 без незавершённых попыток
|
|
66
|
+
#: читается без сброса; v4 с попытками не содержит самих событий для повтора.
|
|
67
|
+
#: v6 различает личный курсор и набор снимков рынка. v5 читается без сброса.
|
|
68
|
+
STATE_FORMAT: Final[str] = "funora-state-v6"
|
|
69
|
+
|
|
70
|
+
#: Семейство адаптера, к которому относится состояние.
|
|
71
|
+
#:
|
|
72
|
+
#: Состояние, снятое с другой площадки, бессмысленно здесь целиком: совпадение
|
|
73
|
+
#: идентификаторов было бы случайным, а последствия - молчаливым гашением чужих
|
|
74
|
+
#: событий.
|
|
75
|
+
ADAPTER_FAMILY: Final[str] = _ADAPTER_FAMILY
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class StateFile:
|
|
80
|
+
"""Файл состояния клиента.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
path (str | Path): Путь файла. Строка принимается наравне с Path и
|
|
84
|
+
приводится к нему здесь.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
path: Path
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
"""Закрепляет общий путь файла и блокировок, сохраняя рабочие ссылки."""
|
|
91
|
+
path = Path(self.path)
|
|
92
|
+
try:
|
|
93
|
+
try:
|
|
94
|
+
resolved = path.resolve(strict=True)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
# Новый файл допустим, но битая ссылка в любом компоненте
|
|
97
|
+
# пути не должна создавать пустой журнал в другом месте.
|
|
98
|
+
for component in (path, *path.parents):
|
|
99
|
+
if component.is_symlink():
|
|
100
|
+
component.resolve(strict=True)
|
|
101
|
+
resolved = path.resolve()
|
|
102
|
+
object.__setattr__(self, "path", resolved)
|
|
103
|
+
self._exists()
|
|
104
|
+
except (OSError, RuntimeError) as exc:
|
|
105
|
+
raise StateSchemaIncompatibleError(
|
|
106
|
+
f"путь файла состояния {path} недоступен: {type(exc).__name__}"
|
|
107
|
+
) from exc
|
|
108
|
+
|
|
109
|
+
def _exists(self) -> bool:
|
|
110
|
+
"""Отличает первый запуск от недоступного или специального файла."""
|
|
111
|
+
for component in (self.path, *self.path.parents):
|
|
112
|
+
try:
|
|
113
|
+
mode = component.stat().st_mode
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
if isinstance(exc, FileNotFoundError) and not component.is_symlink():
|
|
116
|
+
continue
|
|
117
|
+
raise StateSchemaIncompatibleError(
|
|
118
|
+
f"путь состояния {self.path} недоступен: {type(exc).__name__}"
|
|
119
|
+
) from exc
|
|
120
|
+
if component != self.path and S_ISDIR(mode):
|
|
121
|
+
return False
|
|
122
|
+
if component == self.path and S_ISREG(mode):
|
|
123
|
+
return True
|
|
124
|
+
raise StateSchemaIncompatibleError(
|
|
125
|
+
f"неверный тип файла или родительского каталога состояния {self.path}"
|
|
126
|
+
)
|
|
127
|
+
raise StateSchemaIncompatibleError(f"путь состояния {self.path} недоступен")
|
|
128
|
+
|
|
129
|
+
def load(self) -> dict[str, Any]:
|
|
130
|
+
"""Читает состояние.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
dict[str, Any]: Сохранённое состояние. Пустой словарь, если файла
|
|
134
|
+
нет: первый запуск - штатное событие.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
StateSchemaIncompatibleError: Если файл не читается вовсе либо
|
|
138
|
+
записан другой версией схемы файла.
|
|
139
|
+
CursorIncompatibleError: Если сохранённая позиция снята с другого
|
|
140
|
+
семейства адаптера либо собрана другой канонической формой.
|
|
141
|
+
Молчаливый старт с нуля здесь неотличим от штатной работы и
|
|
142
|
+
приводит к повторной обработке всего, что уже обработано.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
if not self._exists():
|
|
146
|
+
return {}
|
|
147
|
+
raw = load_json(self.path.read_text(encoding="utf-8"))
|
|
148
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
149
|
+
raise StateSchemaIncompatibleError(
|
|
150
|
+
f"файл состояния {self.path} не читается: {type(exc).__name__}. "
|
|
151
|
+
"Удалите его вручную, если готовы к повторной обработке всего, "
|
|
152
|
+
"что уже обработано"
|
|
153
|
+
) from exc
|
|
154
|
+
|
|
155
|
+
if not isinstance(raw, dict):
|
|
156
|
+
raise StateSchemaIncompatibleError(
|
|
157
|
+
f"файл состояния {self.path} имеет неожиданное устройство"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
stored_format = raw.get("format")
|
|
161
|
+
if stored_format not in (STATE_FORMAT, "funora-state-v5", "funora-state-v4"):
|
|
162
|
+
raise StateSchemaIncompatibleError(
|
|
163
|
+
f"файл состояния {self.path} записан форматом {stored_format!r}, "
|
|
164
|
+
f"ожидался {STATE_FORMAT!r}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Семейство адаптера и каноническая форма - про КУРСОР, а версия схемы
|
|
168
|
+
# файла выше - про файл. Спецификация делит их прямо: 1801 говорит
|
|
169
|
+
# «курсор принадлежит другой версии формата или другому семейству
|
|
170
|
+
# адаптера», 1802 - «версия схемы сохранённого состояния не
|
|
171
|
+
# поддерживается». Обе ветки ниже подпадают под первое и возбуждали
|
|
172
|
+
# второе.
|
|
173
|
+
#
|
|
174
|
+
# Разница не в номере. Она в том, что делать: чужая схема файла лечится
|
|
175
|
+
# выходом новой версии SDK, чужое семейство - никогда. Курсор, снятый с
|
|
176
|
+
# другой площадки, не станет совместимым от обновления.
|
|
177
|
+
stored_family = raw.get("adapter_family")
|
|
178
|
+
if stored_family != ADAPTER_FAMILY:
|
|
179
|
+
raise CursorIncompatibleError(
|
|
180
|
+
f"файл состояния {self.path} снят с семейства {stored_family!r}, "
|
|
181
|
+
f"ожидалось {ADAPTER_FAMILY!r}. Сохранённая позиция принадлежит "
|
|
182
|
+
"другой площадке и совместимой не станет"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
stored_canonical = raw.get("canonical_form_version")
|
|
186
|
+
if stored_canonical is not None and stored_canonical != CANONICAL_FORM_VERSION:
|
|
187
|
+
raise CursorIncompatibleError(
|
|
188
|
+
f"файл состояния {self.path} записан канонической формой "
|
|
189
|
+
f"{stored_canonical!r}, ожидалась {CANONICAL_FORM_VERSION!r}. "
|
|
190
|
+
"Сохранённые отпечатки собраны по другим правилам и не совпадут "
|
|
191
|
+
"ни с чем"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
payload = raw.get("payload")
|
|
195
|
+
if not isinstance(payload, dict):
|
|
196
|
+
raise StateSchemaIncompatibleError(
|
|
197
|
+
f"файл состояния {self.path}: payload обязан быть объектом; "
|
|
198
|
+
"начать с пустым журналом значило бы забыть уже выполненные действия"
|
|
199
|
+
)
|
|
200
|
+
if stored_format == "funora-state-v4" and payload.get("attempts"):
|
|
201
|
+
raise CursorIncompatibleError(
|
|
202
|
+
"файл v4 содержит незавершённые попытки без сохранённых событий; "
|
|
203
|
+
"завершите их прежней версией SDK перед обновлением. "
|
|
204
|
+
"Восстановить исходную партию по новому снимку невозможно"
|
|
205
|
+
)
|
|
206
|
+
if (
|
|
207
|
+
"watch_owner" in payload
|
|
208
|
+
and not {
|
|
209
|
+
"watch_pending",
|
|
210
|
+
"watch_greeted",
|
|
211
|
+
"cursor",
|
|
212
|
+
}
|
|
213
|
+
<= payload.keys()
|
|
214
|
+
):
|
|
215
|
+
raise StateSchemaIncompatibleError("в состоянии watch отсутствует журнал или курсор")
|
|
216
|
+
if payload.get("attempts") and payload.get("watch_pending") is None:
|
|
217
|
+
raise StateSchemaIncompatibleError("попытки watch сохранены без непринятой партии")
|
|
218
|
+
return payload
|
|
219
|
+
|
|
220
|
+
def update(self, patch: dict[str, Any]) -> None:
|
|
221
|
+
"""Правит часть состояния, не трогая остального.
|
|
222
|
+
|
|
223
|
+
Нужен затем, что состояние пишут ДВОЕ и в разное время. Цикл наблюдения
|
|
224
|
+
сохраняет курсоры и гашение раз в шаг; ограничитель исходящих обязан
|
|
225
|
+
сохраниться сразу после отправки, иначе перезапуск между отправкой и
|
|
226
|
+
концом шага теряет её из реестра - а реестр для того и заведён, чтобы
|
|
227
|
+
часовая квота не обнулялась перезапуском.
|
|
228
|
+
|
|
229
|
+
Прямая запись целиком тут не годится: сохранив один только реестр, мы
|
|
230
|
+
затёрли бы курсоры, и перезапуск ушёл бы в холодный старт.
|
|
231
|
+
|
|
232
|
+
Слияние поверхностное, по ключам верхнего уровня. Глубокого не нужно:
|
|
233
|
+
разделы состояния независимы, и владелец у каждого один.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
patch (dict[str, Any]): Ключи верхнего уровня, которые надо заменить.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
None
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
StateSchemaIncompatibleError: Если существующий файл не читается.
|
|
243
|
+
CursorIncompatibleError: Если он снят с другого семейства адаптера.
|
|
244
|
+
"""
|
|
245
|
+
self._exists()
|
|
246
|
+
with file_lock(self.path.with_suffix(self.path.suffix + ".lock")):
|
|
247
|
+
current = self.load()
|
|
248
|
+
current.update(patch)
|
|
249
|
+
self._save(current)
|
|
250
|
+
|
|
251
|
+
def save(self, payload: dict[str, Any]) -> None:
|
|
252
|
+
"""Записывает состояние.
|
|
253
|
+
|
|
254
|
+
Запись атомарна: файл собирается рядом и переименовывается поверх.
|
|
255
|
+
Дописывание на месте оставило бы обрезанный файл при убийстве процесса
|
|
256
|
+
посреди записи, и следующий запуск не смог бы его прочитать - то есть
|
|
257
|
+
перезапуск в самый неудачный момент отменил бы всю защиту.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
payload (dict[str, Any]): Сохраняемое состояние.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
None
|
|
264
|
+
"""
|
|
265
|
+
self._exists()
|
|
266
|
+
with file_lock(self.path.with_suffix(self.path.suffix + ".lock")):
|
|
267
|
+
self._save(payload)
|
|
268
|
+
|
|
269
|
+
def _save(self, payload: dict[str, Any]) -> None:
|
|
270
|
+
self._exists()
|
|
271
|
+
body = canonical_dumps(
|
|
272
|
+
{
|
|
273
|
+
"format": STATE_FORMAT,
|
|
274
|
+
"adapter_family": ADAPTER_FAMILY,
|
|
275
|
+
"canonical_form_version": CANONICAL_FORM_VERSION,
|
|
276
|
+
"payload": payload,
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
atomic_write(self.path, body)
|
funora/_stock.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Общий разбор показанного наличия в колонке и поле формы."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
from selectolax.parser import Node
|
|
6
|
+
|
|
7
|
+
from ._observed import Observed
|
|
8
|
+
|
|
9
|
+
# Граница разбора SDK, а не объявленный площадкой предел запасов.
|
|
10
|
+
_MAX_DIGITS: Final[int] = 18
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_stock_text(raw: str | None) -> Observed[int]:
|
|
14
|
+
"""Не подменяет отсутствие, пустоту и неизвестный формат нулём."""
|
|
15
|
+
if raw is None:
|
|
16
|
+
return Observed.missing("stock_not_shown")
|
|
17
|
+
value = raw.strip()
|
|
18
|
+
if not value:
|
|
19
|
+
return Observed.missing("stock_empty")
|
|
20
|
+
if not value.isascii() or not value.isdecimal():
|
|
21
|
+
return Observed.missing("stock_format_unknown")
|
|
22
|
+
if len(value) > _MAX_DIGITS:
|
|
23
|
+
return Observed.missing("stock_out_of_range")
|
|
24
|
+
return Observed.present(int(value))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_stock_column(row: Node, selector: str) -> Observed[int]:
|
|
28
|
+
"""Читает единственную ячейку внутри строки, исключая заголовки."""
|
|
29
|
+
cells = row.css(selector)
|
|
30
|
+
if len(cells) > 1:
|
|
31
|
+
return Observed.missing("stock_ambiguous")
|
|
32
|
+
return parse_stock_text(cells[0].text() if cells else None)
|