shop-notify 1.0.1__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.
- shop_notify/__init__.py +5 -0
- shop_notify/__main__.py +371 -0
- shop_notify/config.py +81 -0
- shop_notify/db.py +72 -0
- shop_notify/etsy.py +211 -0
- shop_notify/models.py +43 -0
- shop_notify/notifications.py +353 -0
- shop_notify/systemd.py +54 -0
- shop_notify/templates/de/body.html.j2 +46 -0
- shop_notify/templates/de/body.txt.j2 +8 -0
- shop_notify/templates/de/item_body.txt.j2 +3 -0
- shop_notify/templates/de/item_subject.txt.j2 +1 -0
- shop_notify/templates/de/subject.txt.j2 +7 -0
- shop_notify/templates/en/body.html.j2 +46 -0
- shop_notify/templates/en/body.txt.j2 +8 -0
- shop_notify/templates/en/item_body.txt.j2 +3 -0
- shop_notify/templates/en/item_subject.txt.j2 +1 -0
- shop_notify/templates/en/subject.txt.j2 +7 -0
- shop_notify-1.0.1.dist-info/METADATA +254 -0
- shop_notify-1.0.1.dist-info/RECORD +22 -0
- shop_notify-1.0.1.dist-info/WHEEL +4 -0
- shop_notify-1.0.1.dist-info/entry_points.txt +3 -0
shop_notify/__init__.py
ADDED
shop_notify/__main__.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import signal
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from types import FrameType
|
|
10
|
+
from typing import TypedDict
|
|
11
|
+
|
|
12
|
+
from shop_notify import __version__
|
|
13
|
+
from shop_notify.config import NotifySettings, Settings
|
|
14
|
+
from shop_notify.db import ListingStore
|
|
15
|
+
from shop_notify.etsy import EtsyClient, EtsyError
|
|
16
|
+
from shop_notify.models import Listing, Money
|
|
17
|
+
from shop_notify.notifications import (
|
|
18
|
+
AppriseNotifier,
|
|
19
|
+
ConsoleNotifier,
|
|
20
|
+
Message,
|
|
21
|
+
MessageRenderer,
|
|
22
|
+
MultiNotifier,
|
|
23
|
+
NotificationError,
|
|
24
|
+
Notifier,
|
|
25
|
+
Sender,
|
|
26
|
+
)
|
|
27
|
+
from shop_notify.systemd import SystemdNotifier
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("shop_notify")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ImageOptions(TypedDict):
|
|
33
|
+
images: bool
|
|
34
|
+
images_max: int
|
|
35
|
+
images_in_summary: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def setup_logging(settings: Settings) -> None:
|
|
39
|
+
handlers: list[logging.Handler] = [logging.StreamHandler()]
|
|
40
|
+
if settings.log_file:
|
|
41
|
+
handlers.append(logging.FileHandler(settings.log_file, encoding="utf-8"))
|
|
42
|
+
logging.basicConfig(
|
|
43
|
+
level=settings.log_level.upper(),
|
|
44
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
45
|
+
handlers=handlers,
|
|
46
|
+
force=True,
|
|
47
|
+
)
|
|
48
|
+
# Apprise logs full target URLs (including credentials) at debug level
|
|
49
|
+
logging.getLogger("apprise").setLevel(logging.WARNING)
|
|
50
|
+
# httpx logs every request at INFO; only show that when debugging
|
|
51
|
+
if logging.getLogger().getEffectiveLevel() > logging.DEBUG:
|
|
52
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def image_options(settings: NotifySettings) -> ImageOptions:
|
|
56
|
+
return {
|
|
57
|
+
"images": settings.notify_images,
|
|
58
|
+
"images_max": settings.notify_images_max,
|
|
59
|
+
"images_in_summary": settings.notify_images_in_summary,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_notifier(settings: Settings) -> Notifier:
|
|
64
|
+
notifiers: list[Notifier] = []
|
|
65
|
+
try:
|
|
66
|
+
renderer = MessageRenderer(
|
|
67
|
+
settings.notify_template_dir,
|
|
68
|
+
shop_name=settings.etsy_shop_name,
|
|
69
|
+
language=settings.notify_language,
|
|
70
|
+
)
|
|
71
|
+
if settings.notify_config is not None or settings.notify_urls is not None:
|
|
72
|
+
urls = settings.notify_urls.get_secret_value() if settings.notify_urls else None
|
|
73
|
+
notifiers.append(
|
|
74
|
+
AppriseNotifier(
|
|
75
|
+
urls=urls,
|
|
76
|
+
config=settings.notify_config,
|
|
77
|
+
renderer=renderer,
|
|
78
|
+
**image_options(settings),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
except ValueError as e:
|
|
82
|
+
raise SystemExit(f"Invalid notification configuration: {e}") from e
|
|
83
|
+
|
|
84
|
+
console = settings.notify_console
|
|
85
|
+
if console is True or (console == "auto" and not notifiers):
|
|
86
|
+
notifiers.insert(0, ConsoleNotifier(renderer))
|
|
87
|
+
elif console == "auto" and not settings.notify_existing_on_first_run:
|
|
88
|
+
notifiers.insert(0, ConsoleNotifier(renderer, first_run_only=True))
|
|
89
|
+
|
|
90
|
+
notifier = notifiers[0] if len(notifiers) == 1 else MultiNotifier(notifiers)
|
|
91
|
+
log.info("Notification targets: %s", ", ".join(notifier.targets) or "none (log only)")
|
|
92
|
+
return notifier
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_shop_id(client: EtsyClient, settings: Settings) -> int:
|
|
96
|
+
if settings.etsy_shop_id is not None:
|
|
97
|
+
return settings.etsy_shop_id
|
|
98
|
+
assert settings.etsy_shop_name
|
|
99
|
+
try:
|
|
100
|
+
shop_id = client.find_shop_id(settings.etsy_shop_name)
|
|
101
|
+
except EtsyError as e:
|
|
102
|
+
raise SystemExit(f"Could not resolve shop {settings.etsy_shop_name!r}: {e}") from e
|
|
103
|
+
log.info("Watching shop %s (ID %d)", settings.etsy_shop_name, shop_id)
|
|
104
|
+
return shop_id
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def add_images(client: EtsyClient, listings: Sequence[Listing]) -> list[Listing]:
|
|
108
|
+
"""Attach main image URLs; notifications are still sent if this fails."""
|
|
109
|
+
try:
|
|
110
|
+
images = client.listing_images([item.listing_id for item in listings])
|
|
111
|
+
except EtsyError as e:
|
|
112
|
+
log.warning("Could not load listing images: %s", e)
|
|
113
|
+
return list(listings)
|
|
114
|
+
return [item.model_copy(update={"image_url": images.get(item.listing_id)}) for item in listings]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
QUOTA_WARNING_THRESHOLD = 0.2
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_quota(client: EtsyClient) -> None:
|
|
121
|
+
"""Warn early when the daily Etsy request quota is running low."""
|
|
122
|
+
quota = client.quota
|
|
123
|
+
if quota is None:
|
|
124
|
+
return
|
|
125
|
+
if quota.fraction_left < QUOTA_WARNING_THRESHOLD:
|
|
126
|
+
log.warning(
|
|
127
|
+
"Only %d of %d Etsy API requests left today - consider a larger POLL_INTERVAL",
|
|
128
|
+
quota.remaining,
|
|
129
|
+
quota.limit,
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
log.debug("Etsy API requests left today: %d of %d", quota.remaining, quota.limit)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run_once(
|
|
136
|
+
client: EtsyClient,
|
|
137
|
+
store: ListingStore,
|
|
138
|
+
notifier: Notifier,
|
|
139
|
+
shop_id: int,
|
|
140
|
+
*,
|
|
141
|
+
notify_existing: bool = False,
|
|
142
|
+
) -> int:
|
|
143
|
+
"""Poll once and notify new items; returns the number of notified items.
|
|
144
|
+
|
|
145
|
+
On the first run (empty database) the existing items are only recorded unless
|
|
146
|
+
``notify_existing`` is set; notifiers still receive them flagged as ``initial``.
|
|
147
|
+
"""
|
|
148
|
+
listings = list(client.active_listings(shop_id))
|
|
149
|
+
log.debug("Fetched %d active listings from shop %s", len(listings), shop_id)
|
|
150
|
+
check_quota(client)
|
|
151
|
+
|
|
152
|
+
first_run = store.is_empty()
|
|
153
|
+
new_items = store.sync(listings)
|
|
154
|
+
if new_items and first_run and not notify_existing:
|
|
155
|
+
notifier.notify(new_items, initial=True)
|
|
156
|
+
store.mark_as_notified(new_items)
|
|
157
|
+
log.info(
|
|
158
|
+
"First run: %d existing item(s) recorded, notifications start with the next new item",
|
|
159
|
+
len(new_items),
|
|
160
|
+
)
|
|
161
|
+
return 0
|
|
162
|
+
if new_items:
|
|
163
|
+
notifier.notify(add_images(client, new_items))
|
|
164
|
+
store.mark_as_notified(new_items)
|
|
165
|
+
for item in new_items:
|
|
166
|
+
log.info("New item: %s (%s)", item.title, item.url)
|
|
167
|
+
log.info("Notified %d new item(s)", len(new_items))
|
|
168
|
+
else:
|
|
169
|
+
log.info("No new items this time")
|
|
170
|
+
return len(new_items)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def healthcheck(settings: Settings, now: float | None = None) -> int:
|
|
174
|
+
"""Return 0 if the last successful poll is recent enough, 1 otherwise."""
|
|
175
|
+
path = settings.heartbeat_path
|
|
176
|
+
try:
|
|
177
|
+
age = (time.time() if now is None else now) - path.stat().st_mtime
|
|
178
|
+
except FileNotFoundError:
|
|
179
|
+
print(f"unhealthy: no heartbeat yet ({path})")
|
|
180
|
+
return 1
|
|
181
|
+
if age > settings.heartbeat_max_age:
|
|
182
|
+
print(f"unhealthy: last successful poll {age / 60:.0f} min ago")
|
|
183
|
+
return 1
|
|
184
|
+
print(f"healthy: last successful poll {age:.0f} s ago")
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _now() -> str:
|
|
189
|
+
return f"{datetime.now().astimezone():%H:%M}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _quota_status(client: EtsyClient) -> str:
|
|
193
|
+
quota = client.quota
|
|
194
|
+
return f", {quota.remaining}/{quota.limit} API requests left today" if quota else ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def wait_for_next_poll(stop: threading.Event, seconds: float, systemd: SystemdNotifier) -> None:
|
|
198
|
+
"""Sleep until the next poll or a stop request, pinging the systemd watchdog meanwhile."""
|
|
199
|
+
remaining = float(seconds)
|
|
200
|
+
while remaining > 0 and not stop.is_set():
|
|
201
|
+
chunk = min(remaining, systemd.watchdog_interval or remaining)
|
|
202
|
+
stop.wait(chunk)
|
|
203
|
+
remaining -= chunk
|
|
204
|
+
systemd.watchdog()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run(settings: Settings) -> None:
|
|
208
|
+
setup_logging(settings)
|
|
209
|
+
log.info("Shop Notify %s starting", __version__)
|
|
210
|
+
notifier = build_notifier(settings)
|
|
211
|
+
systemd = SystemdNotifier()
|
|
212
|
+
|
|
213
|
+
stop = threading.Event()
|
|
214
|
+
|
|
215
|
+
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
|
|
216
|
+
log.info("Received %s, shutting down", signal.Signals(signum).name)
|
|
217
|
+
stop.set()
|
|
218
|
+
|
|
219
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
220
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
221
|
+
|
|
222
|
+
store = ListingStore(settings.db_path)
|
|
223
|
+
with EtsyClient(
|
|
224
|
+
api_key=settings.etsy_api_key.get_secret_value(),
|
|
225
|
+
shared_secret=settings.etsy_shared_secret.get_secret_value(),
|
|
226
|
+
base_url=settings.etsy_api_endpoint,
|
|
227
|
+
) as client:
|
|
228
|
+
try:
|
|
229
|
+
shop_id = resolve_shop_id(client, settings)
|
|
230
|
+
systemd.ready(f"Watching shop {settings.etsy_shop_name or shop_id}")
|
|
231
|
+
while not stop.is_set():
|
|
232
|
+
try:
|
|
233
|
+
new = run_once(
|
|
234
|
+
client,
|
|
235
|
+
store,
|
|
236
|
+
notifier,
|
|
237
|
+
shop_id,
|
|
238
|
+
notify_existing=settings.notify_existing_on_first_run,
|
|
239
|
+
)
|
|
240
|
+
settings.heartbeat_path.touch()
|
|
241
|
+
systemd.status(f"Last poll {_now()}: {new} new item(s){_quota_status(client)}")
|
|
242
|
+
except (EtsyError, NotificationError) as e:
|
|
243
|
+
log.error("%s", e)
|
|
244
|
+
systemd.status(f"Last poll {_now()} failed: {str(e)[:200]}")
|
|
245
|
+
except Exception:
|
|
246
|
+
log.exception("Unexpected error during poll")
|
|
247
|
+
systemd.status(f"Last poll {_now()} failed with an unexpected error")
|
|
248
|
+
wait_for_next_poll(stop, settings.poll_interval, systemd)
|
|
249
|
+
finally:
|
|
250
|
+
systemd.stopping()
|
|
251
|
+
store.close()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
SAMPLE_IMAGE_URL = (
|
|
255
|
+
"https://raw.githubusercontent.com/spielkind/shop-notify/main/docs/sample-item.png"
|
|
256
|
+
)
|
|
257
|
+
PROJECT_URL = "https://github.com/spielkind/shop-notify"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
SAMPLE_TITLES = {
|
|
261
|
+
"en": ('Sample: hand-thrown mug "Morning dew"', "Sample: small vase in sage green (no image)"),
|
|
262
|
+
"de": (
|
|
263
|
+
"Muster: handgedrehte Tasse „Morgentau“",
|
|
264
|
+
"Muster: kleine Vase in Salbeigrün (ohne Bild)",
|
|
265
|
+
),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def sample_listings(language: str = "en") -> list[Listing]:
|
|
270
|
+
now = int(time.time())
|
|
271
|
+
first, second = SAMPLE_TITLES.get(language, SAMPLE_TITLES["en"])
|
|
272
|
+
return [
|
|
273
|
+
Listing(
|
|
274
|
+
listing_id=1,
|
|
275
|
+
title=first,
|
|
276
|
+
url=PROJECT_URL,
|
|
277
|
+
last_modified_timestamp=now,
|
|
278
|
+
price=Money(amount=2450, divisor=100, currency_code="EUR"),
|
|
279
|
+
image_url=SAMPLE_IMAGE_URL,
|
|
280
|
+
),
|
|
281
|
+
Listing(
|
|
282
|
+
listing_id=2,
|
|
283
|
+
title=second,
|
|
284
|
+
url=PROJECT_URL,
|
|
285
|
+
last_modified_timestamp=now,
|
|
286
|
+
price=Money(amount=1800, divisor=100, currency_code="EUR"),
|
|
287
|
+
),
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def build_test_senders(settings: NotifySettings, url: str | None) -> tuple[list[Sender], Message]:
|
|
292
|
+
"""Targets of a test notification: ``url`` only, or all configured ones."""
|
|
293
|
+
renderer = MessageRenderer(
|
|
294
|
+
settings.notify_template_dir,
|
|
295
|
+
shop_name=settings.etsy_shop_name,
|
|
296
|
+
language=settings.notify_language,
|
|
297
|
+
)
|
|
298
|
+
senders: list[Sender] = []
|
|
299
|
+
if url:
|
|
300
|
+
senders.append(AppriseNotifier(urls=url, renderer=renderer, **image_options(settings)))
|
|
301
|
+
else:
|
|
302
|
+
if settings.has_notify_targets:
|
|
303
|
+
urls = settings.notify_urls.get_secret_value() if settings.notify_urls else None
|
|
304
|
+
senders.append(
|
|
305
|
+
AppriseNotifier(
|
|
306
|
+
urls=urls,
|
|
307
|
+
config=settings.notify_config,
|
|
308
|
+
renderer=renderer,
|
|
309
|
+
**image_options(settings),
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
console = settings.notify_console
|
|
313
|
+
if console is True or (console == "auto" and not senders):
|
|
314
|
+
senders.insert(0, ConsoleNotifier(renderer))
|
|
315
|
+
if not senders:
|
|
316
|
+
raise ValueError("no notification targets configured (NOTIFY_CONSOLE=false)")
|
|
317
|
+
return senders, renderer.render(sample_listings(renderer.language), test=True)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_notify(settings: NotifySettings, url: str | None = None) -> int:
|
|
321
|
+
"""Send a sample notification; 0 = all targets ok, 1 = some failed, 2 = config error."""
|
|
322
|
+
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
|
|
323
|
+
logging.getLogger("apprise").setLevel(logging.WARNING)
|
|
324
|
+
try:
|
|
325
|
+
senders, message = build_test_senders(settings, url)
|
|
326
|
+
except ValueError as e:
|
|
327
|
+
print(f"Invalid notification configuration: {e}", file=sys.stderr)
|
|
328
|
+
return 2
|
|
329
|
+
|
|
330
|
+
print(f"Sending a test notification with {len(sample_listings())} sample items …\n")
|
|
331
|
+
deliveries = [delivery for sender in senders for delivery in sender.send(message)]
|
|
332
|
+
print()
|
|
333
|
+
for delivery in deliveries:
|
|
334
|
+
print(f" {'✓' if delivery.ok else '✗'} {delivery.target}")
|
|
335
|
+
succeeded = sum(delivery.ok for delivery in deliveries)
|
|
336
|
+
print(f"\n{succeeded} of {len(deliveries)} target(s) succeeded.")
|
|
337
|
+
return 0 if succeeded == len(deliveries) else 1
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
341
|
+
parser = argparse.ArgumentParser(
|
|
342
|
+
prog="shop-notify", description="Watch a shop for new listings and send notifications."
|
|
343
|
+
)
|
|
344
|
+
parser.add_argument(
|
|
345
|
+
"command",
|
|
346
|
+
nargs="?",
|
|
347
|
+
choices=["run", "healthcheck", "test-notify"],
|
|
348
|
+
default="run",
|
|
349
|
+
help=(
|
|
350
|
+
"run: poll the shop (default); healthcheck: exit 0 if the last poll was recent; "
|
|
351
|
+
"test-notify: send a sample notification to all targets"
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
parser.add_argument(
|
|
355
|
+
"--url", help="test-notify only: send to this Apprise URL instead of the configured targets"
|
|
356
|
+
)
|
|
357
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
358
|
+
args = parser.parse_args(argv)
|
|
359
|
+
if args.url and args.command != "test-notify":
|
|
360
|
+
parser.error("--url can only be used with test-notify")
|
|
361
|
+
|
|
362
|
+
if args.command == "test-notify":
|
|
363
|
+
raise SystemExit(test_notify(NotifySettings(), args.url))
|
|
364
|
+
settings = Settings()
|
|
365
|
+
if args.command == "healthcheck":
|
|
366
|
+
raise SystemExit(healthcheck(settings))
|
|
367
|
+
run(settings)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
main()
|
shop_notify/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal, Self
|
|
3
|
+
|
|
4
|
+
from pydantic import Field, SecretStr, field_validator, model_validator
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotifySettings(BaseSettings):
|
|
9
|
+
"""Settings needed to send notifications; no Etsy credentials required."""
|
|
10
|
+
|
|
11
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
|
12
|
+
|
|
13
|
+
etsy_shop_name: str | None = None
|
|
14
|
+
|
|
15
|
+
log_file: Path | None = Path("shop-notify.log")
|
|
16
|
+
log_level: str = "INFO"
|
|
17
|
+
|
|
18
|
+
# Apprise notification targets; without any, notifications go to the console only
|
|
19
|
+
notify_config: Path | None = None
|
|
20
|
+
notify_urls: SecretStr | None = None
|
|
21
|
+
# Print notifications to stdout: "auto" = only if no other targets are configured
|
|
22
|
+
notify_console: Literal["auto"] | bool = "auto"
|
|
23
|
+
# On the first run (empty database) existing items are only recorded, not notified
|
|
24
|
+
notify_existing_on_first_run: bool = False
|
|
25
|
+
# Directory with custom subject.txt.j2 / body.txt.j2 / body.html.j2 templates
|
|
26
|
+
notify_template_dir: Path | None = None
|
|
27
|
+
# Language of the built-in templates
|
|
28
|
+
notify_language: Literal["en", "de"] = "en"
|
|
29
|
+
# Push/chat services: one message per item with its image, up to this many items
|
|
30
|
+
notify_images: bool = True
|
|
31
|
+
notify_images_max: int = Field(default=5, ge=1)
|
|
32
|
+
# Above notify_images_max: attach the images to the summary message (Telegram album)
|
|
33
|
+
notify_images_in_summary: bool = False
|
|
34
|
+
|
|
35
|
+
@field_validator("log_file", "notify_config", "notify_template_dir", mode="before")
|
|
36
|
+
@classmethod
|
|
37
|
+
def _empty_path_is_none(cls, value: object) -> object:
|
|
38
|
+
return None if value == "" else value
|
|
39
|
+
|
|
40
|
+
@field_validator("notify_urls", mode="before")
|
|
41
|
+
@classmethod
|
|
42
|
+
def _empty_urls_is_none(cls, value: object) -> object:
|
|
43
|
+
return None if isinstance(value, str) and not value.strip() else value
|
|
44
|
+
|
|
45
|
+
@field_validator("notify_console", mode="before")
|
|
46
|
+
@classmethod
|
|
47
|
+
def _normalize_console(cls, value: object) -> object:
|
|
48
|
+
return value.strip().lower() if isinstance(value, str) else value
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def has_notify_targets(self) -> bool:
|
|
52
|
+
return self.notify_config is not None or self.notify_urls is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Settings(NotifySettings):
|
|
56
|
+
"""All settings for watching a shop."""
|
|
57
|
+
|
|
58
|
+
etsy_api_key: SecretStr
|
|
59
|
+
etsy_shared_secret: SecretStr
|
|
60
|
+
etsy_api_endpoint: str = "https://openapi.etsy.com/v3/application/"
|
|
61
|
+
etsy_shop_id: int | None = None
|
|
62
|
+
|
|
63
|
+
poll_interval: int = Field(default=300, ge=10)
|
|
64
|
+
db_path: Path = Path("shop-notify.db")
|
|
65
|
+
heartbeat_file: Path | None = None
|
|
66
|
+
|
|
67
|
+
@model_validator(mode="after")
|
|
68
|
+
def _require_shop(self) -> Self:
|
|
69
|
+
if self.etsy_shop_id is None and not self.etsy_shop_name:
|
|
70
|
+
raise ValueError("either ETSY_SHOP_ID or ETSY_SHOP_NAME must be set")
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def heartbeat_path(self) -> Path:
|
|
75
|
+
"""Heartbeat file, by default next to the database."""
|
|
76
|
+
return self.heartbeat_file or self.db_path.with_suffix(".heartbeat")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def heartbeat_max_age(self) -> float:
|
|
80
|
+
"""Seconds after which a missing heartbeat update counts as unhealthy."""
|
|
81
|
+
return 2 * self.poll_interval + 60
|
shop_notify/db.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from shop_notify.models import Listing
|
|
7
|
+
|
|
8
|
+
_SCHEMA = """
|
|
9
|
+
CREATE TABLE IF NOT EXISTS listings (
|
|
10
|
+
listing_id INTEGER PRIMARY KEY,
|
|
11
|
+
request_date INTEGER NOT NULL,
|
|
12
|
+
notified_date INTEGER,
|
|
13
|
+
result TEXT
|
|
14
|
+
);
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ListingStore:
|
|
19
|
+
"""Persists seen listings and tracks which ones have already been notified."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, path: Path | str) -> None:
|
|
22
|
+
self._db = sqlite3.connect(path)
|
|
23
|
+
self._db.executescript(_SCHEMA)
|
|
24
|
+
|
|
25
|
+
def close(self) -> None:
|
|
26
|
+
self._db.close()
|
|
27
|
+
|
|
28
|
+
def is_empty(self) -> bool:
|
|
29
|
+
return self._db.execute("SELECT 1 FROM listings LIMIT 1").fetchone() is None
|
|
30
|
+
|
|
31
|
+
def sync(self, listings: Iterable[Listing]) -> list[Listing]:
|
|
32
|
+
"""Store the current listings, drop vanished notified ones, return un-notified ones.
|
|
33
|
+
|
|
34
|
+
Listings that disappear and later re-appear are therefore notified again.
|
|
35
|
+
Un-notified listings are returned in the order given (Etsy: newest first),
|
|
36
|
+
followed by pending ones that are no longer listed.
|
|
37
|
+
"""
|
|
38
|
+
now = int(time.time())
|
|
39
|
+
listings = list(listings)
|
|
40
|
+
position = {item.listing_id: index for index, item in enumerate(listings)}
|
|
41
|
+
rows = [(item.listing_id, now, item.model_dump_json()) for item in listings]
|
|
42
|
+
|
|
43
|
+
with self._db:
|
|
44
|
+
self._db.executemany(
|
|
45
|
+
"""
|
|
46
|
+
INSERT INTO listings (listing_id, request_date, result) VALUES (?, ?, ?)
|
|
47
|
+
ON CONFLICT (listing_id) DO UPDATE
|
|
48
|
+
SET request_date = excluded.request_date, result = excluded.result
|
|
49
|
+
""",
|
|
50
|
+
rows,
|
|
51
|
+
)
|
|
52
|
+
self._db.execute(
|
|
53
|
+
"DELETE FROM listings WHERE request_date < ? AND notified_date IS NOT NULL",
|
|
54
|
+
(now,),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
cursor = self._db.execute(
|
|
58
|
+
"SELECT result FROM listings WHERE notified_date IS NULL ORDER BY listing_id"
|
|
59
|
+
)
|
|
60
|
+
pending = [Listing.model_validate_json(row[0]) for row in cursor]
|
|
61
|
+
return sorted(pending, key=lambda item: position.get(item.listing_id, len(position)))
|
|
62
|
+
|
|
63
|
+
def mark_as_notified(self, listings: Iterable[Listing]) -> None:
|
|
64
|
+
now = int(time.time())
|
|
65
|
+
with self._db:
|
|
66
|
+
self._db.executemany(
|
|
67
|
+
"""
|
|
68
|
+
UPDATE listings SET notified_date = ?
|
|
69
|
+
WHERE listing_id = ? AND notified_date IS NULL
|
|
70
|
+
""",
|
|
71
|
+
[(now, item.listing_id) for item in listings],
|
|
72
|
+
)
|