vinted-bot 2.1.0__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.
- vinted_bot/__init__.py +3 -0
- vinted_bot/__main__.py +3 -0
- vinted_bot/app.py +400 -0
- vinted_bot/botctl/__init__.py +1 -0
- vinted_bot/botctl/telegram_bot.py +582 -0
- vinted_bot/cli.py +490 -0
- vinted_bot/config.py +288 -0
- vinted_bot/db/__init__.py +6 -0
- vinted_bot/db/connection.py +126 -0
- vinted_bot/db/export.py +98 -0
- vinted_bot/db/migrations/0001_init.sql +124 -0
- vinted_bot/db/migrations/0002_seller_details.sql +6 -0
- vinted_bot/db/migrations/0003_listing_gallery.sql +7 -0
- vinted_bot/db/migrations/0004_richer_searches.sql +277 -0
- vinted_bot/db/migrations/0005_resale_and_mute.sql +17 -0
- vinted_bot/db/migrations/0006_dedup_that_survives.sql +66 -0
- vinted_bot/db/migrations.py +100 -0
- vinted_bot/db/repo.py +2145 -0
- vinted_bot/deals.py +85 -0
- vinted_bot/deliver/__init__.py +15 -0
- vinted_bot/deliver/apprise.py +136 -0
- vinted_bot/deliver/base.py +88 -0
- vinted_bot/deliver/discord.py +819 -0
- vinted_bot/deliver/dispatcher.py +359 -0
- vinted_bot/deliver/labels.py +452 -0
- vinted_bot/deliver/ntfy.py +208 -0
- vinted_bot/deliver/preview.py +218 -0
- vinted_bot/deliver/ratelimit.py +80 -0
- vinted_bot/deliver/render.py +390 -0
- vinted_bot/deliver/telegram.py +551 -0
- vinted_bot/deliver/webhook.py +186 -0
- vinted_bot/engine/__init__.py +6 -0
- vinted_bot/engine/dedup.py +135 -0
- vinted_bot/engine/filters.py +249 -0
- vinted_bot/engine/health.py +204 -0
- vinted_bot/engine/poller.py +484 -0
- vinted_bot/engine/quiet.py +37 -0
- vinted_bot/engine/self_update.py +621 -0
- vinted_bot/engine/updates.py +81 -0
- vinted_bot/engine/validation.py +293 -0
- vinted_bot/engine/watchdog.py +142 -0
- vinted_bot/engine/watcher.py +215 -0
- vinted_bot/log.py +52 -0
- vinted_bot/vinted/__init__.py +34 -0
- vinted_bot/vinted/agents.json +78 -0
- vinted_bot/vinted/canary.py +100 -0
- vinted_bot/vinted/client.py +420 -0
- vinted_bot/vinted/errors.py +73 -0
- vinted_bot/vinted/headers.py +239 -0
- vinted_bot/vinted/item_page.py +228 -0
- vinted_bot/vinted/models.py +373 -0
- vinted_bot/vinted/proxies.py +94 -0
- vinted_bot/vinted/session.py +289 -0
- vinted_bot/vinted/taxonomy.py +343 -0
- vinted_bot/vinted/transport.py +382 -0
- vinted_bot/vinted/urls.py +329 -0
- vinted_bot/web/__init__.py +1 -0
- vinted_bot/web/core.py +245 -0
- vinted_bot/web/data.py +156 -0
- vinted_bot/web/destinations.py +207 -0
- vinted_bot/web/forms.py +366 -0
- vinted_bot/web/preview.py +97 -0
- vinted_bot/web/routes/__init__.py +1 -0
- vinted_bot/web/routes/alerts.py +208 -0
- vinted_bot/web/routes/api.py +293 -0
- vinted_bot/web/routes/auth.py +85 -0
- vinted_bot/web/routes/finds.py +308 -0
- vinted_bot/web/routes/searches.py +471 -0
- vinted_bot/web/routes/settings_page.py +185 -0
- vinted_bot/web/routes/stats.py +157 -0
- vinted_bot/web/routes/updating.py +37 -0
- vinted_bot/web/routes/watchlist.py +147 -0
- vinted_bot/web/routes/welcome.py +394 -0
- vinted_bot/web/security.py +236 -0
- vinted_bot/web/server.py +308 -0
- vinted_bot/web/static/LICENSE-lucide.txt +15 -0
- vinted_bot/web/static/css/app.css +1211 -0
- vinted_bot/web/static/css/tokens.css +276 -0
- vinted_bot/web/static/fonts/Figtree-latin-ext.woff2 +0 -0
- vinted_bot/web/static/fonts/Figtree-latin.woff2 +0 -0
- vinted_bot/web/static/fonts/Gabarito-latin-ext.woff2 +0 -0
- vinted_bot/web/static/fonts/Gabarito-latin.woff2 +0 -0
- vinted_bot/web/static/fonts/OFL-Figtree.txt +93 -0
- vinted_bot/web/static/fonts/OFL-Gabarito.txt +93 -0
- vinted_bot/web/static/img/demo/corduroy-trousers.webp +0 -0
- vinted_bot/web/static/img/demo/denim-jacket.webp +0 -0
- vinted_bot/web/static/img/demo/straight-leg-jeans.webp +0 -0
- vinted_bot/web/static/img/demo/wool-coat.webp +0 -0
- vinted_bot/web/static/img/icons/apple-touch-icon.png +0 -0
- vinted_bot/web/static/img/icons/favicon.ico +0 -0
- vinted_bot/web/static/img/icons/favicon.svg +13 -0
- vinted_bot/web/static/img/icons/icon-192.png +0 -0
- vinted_bot/web/static/img/icons/icon-512.png +0 -0
- vinted_bot/web/static/img/icons/icon-maskable-512.png +0 -0
- vinted_bot/web/static/img/mags/mags-alert.webp +0 -0
- vinted_bot/web/static/img/mags/mags-cheer.webp +0 -0
- vinted_bot/web/static/img/mags/mags-find.webp +0 -0
- vinted_bot/web/static/img/mags/mags-head.webp +0 -0
- vinted_bot/web/static/img/mags/mags-parcel.webp +0 -0
- vinted_bot/web/static/img/mags/mags-point.webp +0 -0
- vinted_bot/web/static/img/mags/mags-sleep.webp +0 -0
- vinted_bot/web/static/img/mags/mags-think.webp +0 -0
- vinted_bot/web/static/img/mags/mags-watch.webp +0 -0
- vinted_bot/web/static/img/mags/mags-wave.webp +0 -0
- vinted_bot/web/static/img/mags/mags-worried.webp +0 -0
- vinted_bot/web/static/js/app.js +976 -0
- vinted_bot/web/static/js/qrcode.js +2297 -0
- vinted_bot/web/support.py +73 -0
- vinted_bot/web/templates/alerts.html +131 -0
- vinted_bot/web/templates/bare.html +14 -0
- vinted_bot/web/templates/base.html +110 -0
- vinted_bot/web/templates/error.html +12 -0
- vinted_bot/web/templates/index.html +184 -0
- vinted_bot/web/templates/login.html +32 -0
- vinted_bot/web/templates/ob_base.html +54 -0
- vinted_bot/web/templates/partials/banners.html +57 -0
- vinted_bot/web/templates/partials/card.html +1 -0
- vinted_bot/web/templates/partials/dest_forms.html +109 -0
- vinted_bot/web/templates/partials/head.html +14 -0
- vinted_bot/web/templates/partials/icons.svg +109 -0
- vinted_bot/web/templates/partials/macros.html +87 -0
- vinted_bot/web/templates/partials/ob_back.html +1 -0
- vinted_bot/web/templates/partials/support.html +94 -0
- vinted_bot/web/templates/search-detail.html +103 -0
- vinted_bot/web/templates/search-edit.html +319 -0
- vinted_bot/web/templates/searches.html +158 -0
- vinted_bot/web/templates/settings.html +183 -0
- vinted_bot/web/templates/stats.html +118 -0
- vinted_bot/web/templates/watchlist.html +80 -0
- vinted_bot/web/templates/welcome-1.html +36 -0
- vinted_bot/web/templates/welcome-2.html +48 -0
- vinted_bot/web/templates/welcome-3.html +85 -0
- vinted_bot/web/templates/welcome-4.html +51 -0
- vinted_bot/web/templates/welcome-5.html +48 -0
- vinted_bot/web/templates/welcome-6.html +37 -0
- vinted_bot/web/views.py +615 -0
- vinted_bot-2.1.0.dist-info/METADATA +282 -0
- vinted_bot-2.1.0.dist-info/RECORD +141 -0
- vinted_bot-2.1.0.dist-info/WHEEL +4 -0
- vinted_bot-2.1.0.dist-info/entry_points.txt +2 -0
- vinted_bot-2.1.0.dist-info/licenses/LICENSE +21 -0
vinted_bot/__init__.py
ADDED
vinted_bot/__main__.py
ADDED
vinted_bot/app.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Starting everything up and shutting it down cleanly.
|
|
2
|
+
|
|
3
|
+
One process, one event loop, one SQLite file. A task per search, a dispatcher, a watchdog,
|
|
4
|
+
a heartbeat, and optionally the web UI and the Telegram bot. A supervisor keeps the running
|
|
5
|
+
searches in step with what is in the database, so adding one in the web UI takes effect
|
|
6
|
+
without a restart.
|
|
7
|
+
|
|
8
|
+
Shutdown matters more than it sounds. Docker sends SIGTERM and waits about ten seconds
|
|
9
|
+
before killing the process; a loop sitting in a sixty-second sleep will be killed mid-send
|
|
10
|
+
every single time. So nothing here sleeps — everything waits on a stop event with a
|
|
11
|
+
timeout, which comes back the instant a signal arrives.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import contextlib
|
|
18
|
+
import random
|
|
19
|
+
import signal
|
|
20
|
+
import sqlite3
|
|
21
|
+
import webbrowser
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from vinted_bot.config import MAX_BACKOFF_S, Settings
|
|
26
|
+
from vinted_bot.db import Database, apply_pending
|
|
27
|
+
from vinted_bot.db.repo import Query, Repo
|
|
28
|
+
from vinted_bot.deliver.dispatcher import Dispatcher
|
|
29
|
+
from vinted_bot.engine import self_update, updates
|
|
30
|
+
from vinted_bot.engine.health import Heartbeat
|
|
31
|
+
from vinted_bot.engine.poller import Poller
|
|
32
|
+
from vinted_bot.engine.watchdog import Watchdog
|
|
33
|
+
from vinted_bot.engine.watcher import Watcher
|
|
34
|
+
from vinted_bot.log import get_logger
|
|
35
|
+
from vinted_bot.vinted.client import VintedClient
|
|
36
|
+
from vinted_bot.vinted.proxies import ProxyRotation
|
|
37
|
+
from vinted_bot.vinted.session import SessionManager
|
|
38
|
+
from vinted_bot.vinted.taxonomy import Taxonomy
|
|
39
|
+
from vinted_bot.vinted.transport import Transport, TransportPool, build_transport
|
|
40
|
+
|
|
41
|
+
log = get_logger(__name__)
|
|
42
|
+
|
|
43
|
+
# How often to notice searches added, paused or edited elsewhere.
|
|
44
|
+
SUPERVISE_INTERVAL_S = 15.0
|
|
45
|
+
|
|
46
|
+
# Housekeeping: prune old listings and the rejections log, check for a newer release.
|
|
47
|
+
HOUSEKEEPING_INTERVAL_S = 3600.0
|
|
48
|
+
|
|
49
|
+
# Searches started in the same pass — all of them, after a restart — begin this far apart
|
|
50
|
+
# (plus jitter), so they do not hit the site in the same second.
|
|
51
|
+
STARTUP_SPACING_S = 2.0
|
|
52
|
+
|
|
53
|
+
# How long `run --open` waits for the dashboard to start listening.
|
|
54
|
+
_OPEN_BROWSER_WAIT_S = 20.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Application:
|
|
58
|
+
"""The whole running system."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
settings: Settings,
|
|
63
|
+
*,
|
|
64
|
+
supervise_interval_s: float = SUPERVISE_INTERVAL_S,
|
|
65
|
+
open_browser: bool = False,
|
|
66
|
+
install: self_update.Install | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._settings = settings
|
|
69
|
+
# How this copy was installed; worked out at startup unless a test says.
|
|
70
|
+
self._install = install
|
|
71
|
+
self._supervise_interval_s = supervise_interval_s
|
|
72
|
+
self._open_browser = open_browser
|
|
73
|
+
self._stop = asyncio.Event()
|
|
74
|
+
self._work_available = asyncio.Event()
|
|
75
|
+
self._rng = random.Random()
|
|
76
|
+
self._poller_tasks: dict[int, asyncio.Task[None]] = {}
|
|
77
|
+
# The search as its poller was started with it. Any edit makes it unequal, and
|
|
78
|
+
# the poller is restarted with the new rules on the next pass.
|
|
79
|
+
self._poller_signatures: dict[int, Query] = {}
|
|
80
|
+
# Crashes in a row per search, for spacing out restarts. A crash after at least
|
|
81
|
+
# one good check starts the count again.
|
|
82
|
+
self._crashes: dict[int, int] = {}
|
|
83
|
+
|
|
84
|
+
def request_stop(self) -> None:
|
|
85
|
+
self._stop.set()
|
|
86
|
+
|
|
87
|
+
async def run(self) -> int:
|
|
88
|
+
"""Run until stopped. The exit code: RESTART_EXIT_CODE after an update."""
|
|
89
|
+
settings = self._settings
|
|
90
|
+
|
|
91
|
+
async with Database(settings.db_path) as db:
|
|
92
|
+
await apply_pending(db)
|
|
93
|
+
repo = Repo(db)
|
|
94
|
+
|
|
95
|
+
proxies = ProxyRotation.from_file(settings.proxy_file)
|
|
96
|
+
mock_dir = settings.mock_scenario_dir if settings.fetch_mode == "mock" else None
|
|
97
|
+
|
|
98
|
+
def build(proxy: str | None) -> Transport:
|
|
99
|
+
return build_transport(
|
|
100
|
+
impersonate=settings.http_impersonate,
|
|
101
|
+
timeout=settings.request_timeout_s,
|
|
102
|
+
proxy=proxy,
|
|
103
|
+
mock_dir=mock_dir,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
pool = TransportPool(build)
|
|
107
|
+
# For the health check ping and the daily update check, never for Vinted.
|
|
108
|
+
http = httpx.AsyncClient(timeout=10.0)
|
|
109
|
+
try:
|
|
110
|
+
sessions = SessionManager(
|
|
111
|
+
db,
|
|
112
|
+
pool,
|
|
113
|
+
rotate_after_minutes=settings.session_rotate_minutes,
|
|
114
|
+
proxies=proxies,
|
|
115
|
+
)
|
|
116
|
+
client = VintedClient(None, sessions, keep_raw=settings.keep_raw_json)
|
|
117
|
+
dispatcher = Dispatcher(
|
|
118
|
+
repo=repo,
|
|
119
|
+
settings=settings,
|
|
120
|
+
stop=self._stop,
|
|
121
|
+
work_available=self._work_available,
|
|
122
|
+
)
|
|
123
|
+
watchdog = Watchdog(
|
|
124
|
+
repo=repo,
|
|
125
|
+
sessions=sessions,
|
|
126
|
+
settings=settings,
|
|
127
|
+
stop=self._stop,
|
|
128
|
+
announce=dispatcher.notify_status,
|
|
129
|
+
)
|
|
130
|
+
heartbeat = Heartbeat(repo, self._stop, http=http)
|
|
131
|
+
updater = self_update.Updater(settings, repo, self._stop, self._install)
|
|
132
|
+
watcher = Watcher(
|
|
133
|
+
repo=repo,
|
|
134
|
+
stop=self._stop,
|
|
135
|
+
work_available=self._work_available,
|
|
136
|
+
item_snapshot=client.item_snapshot,
|
|
137
|
+
seller_items=client.seller_items,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
self._install_signal_handlers()
|
|
141
|
+
log.info(
|
|
142
|
+
"app.starting",
|
|
143
|
+
db=str(settings.db_path),
|
|
144
|
+
fetch_mode=settings.fetch_mode,
|
|
145
|
+
web=settings.web_enabled,
|
|
146
|
+
install=updater.install.kind.value,
|
|
147
|
+
supervisor=settings.supervised,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
async with asyncio.TaskGroup() as tg:
|
|
152
|
+
tg.create_task(dispatcher.run(), name="dispatcher")
|
|
153
|
+
tg.create_task(watchdog.run(), name="watchdog")
|
|
154
|
+
tg.create_task(heartbeat.run(), name="heartbeat")
|
|
155
|
+
tg.create_task(watcher.run(), name="watcher")
|
|
156
|
+
tg.create_task(self._housekeeping(repo, http), name="housekeeping")
|
|
157
|
+
tg.create_task(updater.run_automatically(), name="auto-update")
|
|
158
|
+
tg.create_task(self._report_update(repo, dispatcher), name="update-report")
|
|
159
|
+
tg.create_task(
|
|
160
|
+
self._supervise(tg, repo, client, sessions), name="supervisor"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if settings.telegram_bot_token is not None:
|
|
164
|
+
tg.create_task(self._run_telegram_bot(repo), name="telegram-bot")
|
|
165
|
+
if settings.web_enabled:
|
|
166
|
+
taxonomy = Taxonomy(sessions, repo)
|
|
167
|
+
tg.create_task(
|
|
168
|
+
self._run_web(
|
|
169
|
+
repo, taxonomy, client, watcher.available_kinds, updater
|
|
170
|
+
),
|
|
171
|
+
name="web",
|
|
172
|
+
)
|
|
173
|
+
if self._open_browser:
|
|
174
|
+
tg.create_task(self._open_dashboard(), name="open-browser")
|
|
175
|
+
except* Exception as group:
|
|
176
|
+
for error in group.exceptions:
|
|
177
|
+
log.exception("app.task_failed", error=str(error))
|
|
178
|
+
raise
|
|
179
|
+
# A shutdown halfway through an upgrade would leave it half installed.
|
|
180
|
+
await updater.settle()
|
|
181
|
+
finally:
|
|
182
|
+
await http.aclose()
|
|
183
|
+
await pool.aclose()
|
|
184
|
+
|
|
185
|
+
if updater.restart_requested:
|
|
186
|
+
log.info("app.stopped", restarting_for="update")
|
|
187
|
+
return self_update.RESTART_EXIT_CODE
|
|
188
|
+
log.info("app.stopped")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
# --- Supervision ---------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
async def _supervise(
|
|
194
|
+
self,
|
|
195
|
+
tg: asyncio.TaskGroup,
|
|
196
|
+
repo: Repo,
|
|
197
|
+
client: VintedClient,
|
|
198
|
+
sessions: SessionManager,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Keep one running task per active search."""
|
|
201
|
+
while not self._stop.is_set():
|
|
202
|
+
try:
|
|
203
|
+
await self._reconcile(tg, repo, client, sessions)
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
log.exception("supervisor.failed", error=str(exc))
|
|
206
|
+
|
|
207
|
+
with contextlib.suppress(TimeoutError):
|
|
208
|
+
await asyncio.wait_for(self._stop.wait(), timeout=self._supervise_interval_s)
|
|
209
|
+
|
|
210
|
+
for task in self._poller_tasks.values():
|
|
211
|
+
task.cancel()
|
|
212
|
+
|
|
213
|
+
async def _reconcile(
|
|
214
|
+
self,
|
|
215
|
+
tg: asyncio.TaskGroup,
|
|
216
|
+
repo: Repo,
|
|
217
|
+
client: VintedClient,
|
|
218
|
+
sessions: SessionManager,
|
|
219
|
+
) -> None:
|
|
220
|
+
queries = {query.id: query for query in await repo.list_queries(include_paused=False)}
|
|
221
|
+
|
|
222
|
+
for query_id in list(self._poller_tasks):
|
|
223
|
+
query = queries.get(query_id)
|
|
224
|
+
task = self._poller_tasks[query_id]
|
|
225
|
+
changed = query is not None and query != self._poller_signatures.get(query_id)
|
|
226
|
+
if query is None or changed or task.done():
|
|
227
|
+
task.cancel()
|
|
228
|
+
del self._poller_tasks[query_id]
|
|
229
|
+
self._poller_signatures.pop(query_id, None)
|
|
230
|
+
if query is None or changed:
|
|
231
|
+
# An edit is a reason to try again straight away.
|
|
232
|
+
self._crashes.pop(query_id, None)
|
|
233
|
+
if query is None:
|
|
234
|
+
log.info("poller.stopped", query_id=query_id)
|
|
235
|
+
|
|
236
|
+
starting = [
|
|
237
|
+
query for query_id, query in queries.items() if query_id not in self._poller_tasks
|
|
238
|
+
]
|
|
239
|
+
for position, query in enumerate(starting):
|
|
240
|
+
query_id = query.id
|
|
241
|
+
# The first starts at once, so a newly added search checks straight away.
|
|
242
|
+
delay = position * STARTUP_SPACING_S
|
|
243
|
+
if position:
|
|
244
|
+
delay += self._rng.uniform(0, STARTUP_SPACING_S)
|
|
245
|
+
delay = min(delay, float(query.poll_interval_s))
|
|
246
|
+
if crashes := self._crashes.get(query_id, 0):
|
|
247
|
+
delay = self._restart_delay(query, crashes)
|
|
248
|
+
poller = Poller(
|
|
249
|
+
query,
|
|
250
|
+
repo=repo,
|
|
251
|
+
client=client,
|
|
252
|
+
sessions=sessions,
|
|
253
|
+
settings=self._settings,
|
|
254
|
+
stop=self._stop,
|
|
255
|
+
work_available=self._work_available,
|
|
256
|
+
seller_lookup=client.seller,
|
|
257
|
+
start_delay_s=delay,
|
|
258
|
+
)
|
|
259
|
+
self._poller_tasks[query_id] = tg.create_task(
|
|
260
|
+
self._guard(poller), name=f"poll:{query_id}"
|
|
261
|
+
)
|
|
262
|
+
self._poller_signatures[query_id] = query
|
|
263
|
+
log.info(
|
|
264
|
+
"poller.started",
|
|
265
|
+
query_id=query_id,
|
|
266
|
+
query=query.name,
|
|
267
|
+
tld=query.tld,
|
|
268
|
+
sites=query.sites,
|
|
269
|
+
every_s=query.poll_interval_s,
|
|
270
|
+
start_in_s=round(delay, 1),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _restart_delay(self, query: Query, crashes: int) -> float:
|
|
274
|
+
"""How long a crashed search waits before its next start: the backoff ladder.
|
|
275
|
+
|
|
276
|
+
Restarting at once would turn a search that crashes on every check into one that
|
|
277
|
+
asks Vinted for something every supervisor pass, forever.
|
|
278
|
+
"""
|
|
279
|
+
step = min(2.0 ** (crashes - 1) * query.poll_interval_s, MAX_BACKOFF_S)
|
|
280
|
+
return step + self._rng.uniform(0, step * 0.15)
|
|
281
|
+
|
|
282
|
+
async def _guard(self, poller: Poller) -> None:
|
|
283
|
+
"""Run a search's loop so that its failure cannot take the others down with it."""
|
|
284
|
+
query_id = poller.query.id
|
|
285
|
+
try:
|
|
286
|
+
await poller.run()
|
|
287
|
+
except asyncio.CancelledError:
|
|
288
|
+
raise
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
crashes = 1 if poller.successes else self._crashes.get(query_id, 0) + 1
|
|
291
|
+
self._crashes[query_id] = crashes
|
|
292
|
+
log.exception(
|
|
293
|
+
"poller.crashed",
|
|
294
|
+
query_id=query_id,
|
|
295
|
+
error=str(exc),
|
|
296
|
+
crashes_in_a_row=crashes,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# --- Background chores ---------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
async def _housekeeping(self, repo: Repo, http: httpx.AsyncClient) -> None:
|
|
302
|
+
while not self._stop.is_set():
|
|
303
|
+
try:
|
|
304
|
+
pruned = await repo.prune_items(self._settings.item_retention_days)
|
|
305
|
+
rejections = await repo.prune_rejections()
|
|
306
|
+
if pruned or rejections:
|
|
307
|
+
log.debug("housekeeping.pruned", items=pruned, rejections=rejections)
|
|
308
|
+
# Offline mode is offline: no call to GitHub either.
|
|
309
|
+
if self._settings.fetch_mode == "live":
|
|
310
|
+
await updates.check_for_update(repo, http)
|
|
311
|
+
except sqlite3.OperationalError as exc:
|
|
312
|
+
# A locked database; the next pass an hour from now will catch up.
|
|
313
|
+
log.warning("housekeeping.db_unavailable", error=str(exc))
|
|
314
|
+
with contextlib.suppress(TimeoutError):
|
|
315
|
+
await asyncio.wait_for(self._stop.wait(), timeout=HOUSEKEEPING_INTERVAL_S)
|
|
316
|
+
|
|
317
|
+
async def _report_update(self, repo: Repo, dispatcher: Dispatcher) -> None:
|
|
318
|
+
"""Say how the update that restarted the app went (self_update.finish_pending)."""
|
|
319
|
+
try:
|
|
320
|
+
await self_update.finish_pending(repo, dispatcher.notify_status)
|
|
321
|
+
except sqlite3.OperationalError as exc:
|
|
322
|
+
# A locked database: the note stays, and the next start reports it instead.
|
|
323
|
+
log.warning("update.report_failed", error=str(exc))
|
|
324
|
+
|
|
325
|
+
async def _run_telegram_bot(self, repo: Repo) -> None:
|
|
326
|
+
from vinted_bot.botctl.telegram_bot import run_bot # noqa: PLC0415
|
|
327
|
+
|
|
328
|
+
token = self._settings.telegram_bot_token
|
|
329
|
+
if token is None: # pragma: no cover - guarded by the caller
|
|
330
|
+
return
|
|
331
|
+
try:
|
|
332
|
+
await run_bot(token.get_secret_value(), repo=repo, stop=self._stop)
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
log.exception("telegram_bot.failed", error=str(exc))
|
|
335
|
+
|
|
336
|
+
async def _run_web(
|
|
337
|
+
self,
|
|
338
|
+
repo: Repo,
|
|
339
|
+
taxonomy: Taxonomy,
|
|
340
|
+
client: VintedClient,
|
|
341
|
+
watch_kinds: frozenset[str],
|
|
342
|
+
updater: self_update.Updater,
|
|
343
|
+
) -> None:
|
|
344
|
+
# Imported here so CLI commands that never serve the dashboard skip loading FastAPI.
|
|
345
|
+
from vinted_bot.web.server import serve # noqa: PLC0415
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
await serve(
|
|
349
|
+
self._settings,
|
|
350
|
+
repo,
|
|
351
|
+
self._stop,
|
|
352
|
+
taxonomy,
|
|
353
|
+
client=client,
|
|
354
|
+
watch_kinds=watch_kinds,
|
|
355
|
+
updater=updater,
|
|
356
|
+
)
|
|
357
|
+
except Exception as exc:
|
|
358
|
+
log.exception("web.failed", error=str(exc))
|
|
359
|
+
|
|
360
|
+
async def _open_dashboard(self) -> None:
|
|
361
|
+
"""Open the dashboard in a browser once it is listening (`run --open`)."""
|
|
362
|
+
url = self._settings.dashboard_url
|
|
363
|
+
if url is None: # pragma: no cover - only scheduled while the web UI is on
|
|
364
|
+
return
|
|
365
|
+
host = (
|
|
366
|
+
"127.0.0.1" if self._settings.web_host in {"0.0.0.0", "::"} else self._settings.web_host
|
|
367
|
+
)
|
|
368
|
+
deadline = asyncio.get_running_loop().time() + _OPEN_BROWSER_WAIT_S
|
|
369
|
+
while not self._stop.is_set() and asyncio.get_running_loop().time() < deadline:
|
|
370
|
+
try:
|
|
371
|
+
_, writer = await asyncio.wait_for(
|
|
372
|
+
asyncio.open_connection(host, self._settings.web_port), timeout=1.0
|
|
373
|
+
)
|
|
374
|
+
except (OSError, TimeoutError):
|
|
375
|
+
with contextlib.suppress(TimeoutError):
|
|
376
|
+
await asyncio.wait_for(self._stop.wait(), timeout=0.25)
|
|
377
|
+
continue
|
|
378
|
+
writer.close()
|
|
379
|
+
await writer.wait_closed()
|
|
380
|
+
# webbrowser can block while it launches a process, so it gets a thread.
|
|
381
|
+
await asyncio.to_thread(webbrowser.open, url)
|
|
382
|
+
log.info("web.opened", url=url)
|
|
383
|
+
return
|
|
384
|
+
log.warning("web.open_timed_out", url=url)
|
|
385
|
+
|
|
386
|
+
# --- Signals -------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
def _install_signal_handlers(self) -> None:
|
|
389
|
+
loop = asyncio.get_running_loop()
|
|
390
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
391
|
+
with contextlib.suppress(NotImplementedError):
|
|
392
|
+
loop.add_signal_handler(sig, self._on_signal, sig)
|
|
393
|
+
|
|
394
|
+
def _on_signal(self, sig: signal.Signals) -> None:
|
|
395
|
+
log.info("app.signal", signal=sig.name)
|
|
396
|
+
self._stop.set()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
async def run(settings: Settings, *, open_browser: bool = False) -> int:
|
|
400
|
+
return await Application(settings, open_browser=open_browser).run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The Telegram bot: chat pairing and status replies."""
|