crawlr 0.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.
crawlr/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Crawlr: an AI-powered, self-healing web scraper.
2
+
3
+ Core idea: use an LLM to *generate and repair* deterministic CSS selectors
4
+ (the expensive, intelligent step) instead of extracting every page with the
5
+ LLM (the naive, slow, expensive approach). Deterministic selectors are cached
6
+ and reused; when they break, the extractor self-heals by regenerating them.
7
+ """
8
+
9
+ __version__ = "0.1.0"
crawlr/alerts.py ADDED
@@ -0,0 +1,130 @@
1
+ """Alerting: notify external sinks when monitored data changes (roadmap item 1).
2
+
3
+ Sinks (all optional, configured via env): console/log, generic webhook, Slack
4
+ incoming webhook, and email (SMTP). A simple rule layer decides which changes
5
+ are worth alerting on (e.g. only price drops above a threshold).
6
+
7
+ Design goals: never raise into the monitor loop (a broken sink must not stop
8
+ scraping), and stay dependency-light (httpx + stdlib smtplib).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import smtplib
15
+ from email.mime.text import MIMEText
16
+
17
+ import httpx
18
+
19
+ from .config import ALERTS
20
+ from .models import PriceChange
21
+
22
+ logger = logging.getLogger("crawlr.alerts")
23
+
24
+ _PRICE_FIELDS = {"price"}
25
+ _BACK_IN_STOCK = {"in stock", "instock", "available", "in_stock"}
26
+
27
+
28
+ def alertable(changes: list[PriceChange]) -> list[PriceChange]:
29
+ """Filter changes down to those worth notifying about, per configured rules."""
30
+ out: list[PriceChange] = []
31
+ for c in changes:
32
+ if c.field in _PRICE_FIELDS and ALERTS.min_price_drop_pct > 0:
33
+ drop = _price_drop_fraction(c.old_value, c.new_value)
34
+ if drop is None or drop < ALERTS.min_price_drop_pct:
35
+ continue
36
+ out.append(c)
37
+ return out
38
+
39
+
40
+ def _price_drop_fraction(old: str | None, new: str | None) -> float | None:
41
+ """Fractional price drop (0.1 == 10% cheaper); None if not computable."""
42
+ try:
43
+ o, n = float(old), float(new) # type: ignore[arg-type]
44
+ except (TypeError, ValueError):
45
+ return None
46
+ if o <= 0:
47
+ return None
48
+ return (o - n) / o
49
+
50
+
51
+ def _describe(change: PriceChange) -> str:
52
+ if change.field == "_new_item":
53
+ return f"NEW item: {change.new_value}"
54
+ if change.field == "_removed_item":
55
+ return f"REMOVED item: {change.old_value}"
56
+ prefix = ""
57
+ if change.field in _PRICE_FIELDS:
58
+ drop = _price_drop_fraction(change.old_value, change.new_value)
59
+ if drop is not None:
60
+ direction = "dropped" if drop > 0 else "rose"
61
+ prefix = f"price {direction} {abs(drop) * 100:.1f}% — "
62
+ return f"{prefix}{change.item_key_display()} {change.field}: {change.old_value} -> {change.new_value}"
63
+
64
+
65
+ def notify(site_url: str, changes: list[PriceChange]) -> list[PriceChange]:
66
+ """Send alerts for `changes` to all configured sinks. Returns those sent."""
67
+ to_send = alertable(changes)
68
+ if not to_send:
69
+ return []
70
+
71
+ lines = [_describe(c) for c in to_send]
72
+ subject = f"Crawlr: {len(to_send)} change(s) on {site_url}"
73
+ body = subject + "\n\n" + "\n".join(f"- {line}" for line in lines)
74
+
75
+ if ALERTS.console:
76
+ logger.info(body)
77
+
78
+ payload = {
79
+ "site": site_url,
80
+ "count": len(to_send),
81
+ "changes": [c.model_dump(mode="json") for c in to_send],
82
+ }
83
+ _safe(_send_webhook, payload)
84
+ _safe(_send_slack, subject, lines)
85
+ _safe(_send_email, subject, body)
86
+
87
+ return to_send
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Sinks
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def _send_webhook(payload: dict) -> None:
96
+ if not ALERTS.webhook_url:
97
+ return
98
+ with httpx.Client(timeout=15) as client:
99
+ client.post(ALERTS.webhook_url, json=payload).raise_for_status()
100
+
101
+
102
+ def _send_slack(subject: str, lines: list[str]) -> None:
103
+ if not ALERTS.slack_webhook_url:
104
+ return
105
+ text = f"*{subject}*\n" + "\n".join(f"• {line}" for line in lines)
106
+ with httpx.Client(timeout=15) as client:
107
+ client.post(ALERTS.slack_webhook_url, json={"text": text}).raise_for_status()
108
+
109
+
110
+ def _send_email(subject: str, body: str) -> None:
111
+ if not (ALERTS.email_to and ALERTS.smtp_host):
112
+ return
113
+ msg = MIMEText(body)
114
+ msg["Subject"] = subject
115
+ msg["From"] = ALERTS.smtp_from or (ALERTS.smtp_user or "crawlr@localhost")
116
+ msg["To"] = ", ".join(ALERTS.email_to)
117
+
118
+ with smtplib.SMTP(ALERTS.smtp_host, ALERTS.smtp_port, timeout=20) as server:
119
+ server.starttls()
120
+ if ALERTS.smtp_user and ALERTS.smtp_password:
121
+ server.login(ALERTS.smtp_user, ALERTS.smtp_password)
122
+ server.sendmail(msg["From"], ALERTS.email_to, msg.as_string())
123
+
124
+
125
+ def _safe(fn, *args) -> None:
126
+ """Run a sink, swallowing errors so one bad sink can't stop monitoring."""
127
+ try:
128
+ fn(*args)
129
+ except Exception as exc: # pragma: no cover - network/SMTP failure paths
130
+ logger.warning("alert sink %s failed: %s", getattr(fn, "__name__", fn), exc)
crawlr/api.py ADDED
@@ -0,0 +1,330 @@
1
+ """Crawlr dashboard + JSON API.
2
+
3
+ A black-and-white, iOS-styled watchlist: paste a product URL, pick when to be
4
+ alerted (the trigger filter), and track price movement + stock at a glance.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import asynccontextmanager
10
+
11
+ from fastapi import FastAPI, Form
12
+ from fastapi.responses import HTMLResponse, RedirectResponse
13
+
14
+ from . import schemas as schema_registry
15
+ from . import storage
16
+ from .models import MonitoredSite, TriggerType
17
+ from .monitor import run_once
18
+
19
+
20
+ @asynccontextmanager
21
+ async def _lifespan(_app: FastAPI):
22
+ storage.init_db()
23
+ yield
24
+
25
+
26
+ app = FastAPI(title="Crawlr", version="0.2.0", lifespan=_lifespan)
27
+
28
+ # Friendly labels for the trigger filter dropdown.
29
+ _TRIGGER_LABELS = {
30
+ "any_change": "Any change",
31
+ "price_drop": "Price drops",
32
+ "price_below": "Price at/below target",
33
+ "price_above": "Price at/above target",
34
+ "back_in_stock": "Back in stock",
35
+ "out_of_stock": "Out of stock",
36
+ }
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # JSON API
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ @app.get("/api/sites")
45
+ def api_sites() -> list[dict]:
46
+ return storage.list_sites()
47
+
48
+
49
+ @app.get("/api/watchlist")
50
+ def api_watchlist() -> list[dict]:
51
+ return storage.watchlist()
52
+
53
+
54
+ @app.get("/api/sites/{site_id}/records")
55
+ def api_records(site_id: int) -> list[dict]:
56
+ return storage.latest_records(site_id)
57
+
58
+
59
+ @app.get("/api/sites/{site_id}/history")
60
+ def api_history(site_id: int, item_key: str, field: str = "price") -> list[dict]:
61
+ return storage.price_history(site_id, item_key, field)
62
+
63
+
64
+ @app.get("/api/changes")
65
+ def api_changes(site_id: int | None = None, limit: int = 50) -> list[dict]:
66
+ return storage.recent_changes(site_id, limit)
67
+
68
+
69
+ @app.get("/api/schemas")
70
+ def api_schemas() -> list[dict]:
71
+ return schema_registry.available()
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Actions
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ @app.post("/sites")
80
+ def add_watch_action(
81
+ url: str = Form(...),
82
+ schema_name: str = Form("product"),
83
+ alert_trigger: str = Form("any_change"),
84
+ target_price: str = Form(""),
85
+ interval: int = Form(60),
86
+ ) -> RedirectResponse:
87
+ if schema_registry.resolve(schema_name) is not None:
88
+ try:
89
+ trigger = TriggerType(alert_trigger)
90
+ except ValueError:
91
+ trigger = TriggerType.ANY_CHANGE
92
+ target = None
93
+ try:
94
+ target = float(target_price) if target_price.strip() else None
95
+ except ValueError:
96
+ target = None
97
+ storage.add_site(
98
+ MonitoredSite(
99
+ url=url,
100
+ schema_name=schema_name,
101
+ interval_minutes=interval,
102
+ trigger=trigger,
103
+ target_price=target,
104
+ )
105
+ )
106
+ return RedirectResponse("/", status_code=303)
107
+
108
+
109
+ @app.post("/sites/{site_id}/run")
110
+ def run_site_action(site_id: int) -> RedirectResponse:
111
+ site = storage.get_site(site_id)
112
+ if site is not None:
113
+ schema = schema_registry.resolve(site["schema_name"])
114
+ if schema is not None:
115
+ run_once(site_id, schema)
116
+ return RedirectResponse("/", status_code=303)
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # HTML dashboard
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ @app.get("/", response_class=HTMLResponse)
125
+ def dashboard() -> str:
126
+ rows = storage.watchlist()
127
+ changes = storage.recent_changes(limit=20)
128
+
129
+ schema_options = "".join(
130
+ f"<option value='{s['name']}'>{s['name']}</option>" for s in schema_registry.available()
131
+ )
132
+ trigger_options = "".join(
133
+ f"<option value='{value}'>{label}</option>" for value, label in _TRIGGER_LABELS.items()
134
+ )
135
+
136
+ watch_rows = "".join(_watch_row(r) for r in rows) or (
137
+ "<tr><td colspan='8' class='empty'>Nothing watched yet — add a product below.</td></tr>"
138
+ )
139
+ change_rows = "".join(
140
+ f"<tr><td class='mono'>{c['changed_at'][:19].replace('T', ' ')}</td>"
141
+ f"<td>{_short(c['item_key'])}</td><td>{c['field']}</td>"
142
+ f"<td class='mono'>{c['old_value']}</td><td class='mono strong'>{c['new_value']}</td></tr>"
143
+ for c in changes
144
+ ) or "<tr><td colspan='5' class='empty'>No changes recorded yet.</td></tr>"
145
+
146
+ watching = sum(1 for r in rows if r["active"])
147
+ return _PAGE.format(
148
+ watch_rows=watch_rows,
149
+ change_rows=change_rows,
150
+ count=len(rows),
151
+ watching=watching,
152
+ schema_options=schema_options,
153
+ trigger_options=trigger_options,
154
+ )
155
+
156
+
157
+ def _watch_row(r: dict) -> str:
158
+ spark = _sparkline_for_site(r["id"])
159
+ return (
160
+ "<tr>"
161
+ f"<td class='title'>{_short(r['title'] or r['url'], 46)}"
162
+ f"<div class='sub'>{_short(r['url'], 52)}</div></td>"
163
+ f"<td class='num strong'>{_price(r['price'])}</td>"
164
+ f"<td class='num was'>{_price(r['prev_price'])}</td>"
165
+ f"<td class='num'>{_change(r['change_pct'])}</td>"
166
+ f"<td>{_stock(r['in_stock'])}</td>"
167
+ f"<td class='num'>{_price(r['target_price'])}</td>"
168
+ f"<td><span class='pill'>{r['status']}</span>{spark}</td>"
169
+ f"<td class='right'><form method='post' action='/sites/{r['id']}/run'>"
170
+ f"<button class='btn'>Check</button></form></td>"
171
+ "</tr>"
172
+ )
173
+
174
+
175
+ def _price(v) -> str:
176
+ if v is None:
177
+ return "<span class='dim'>—</span>"
178
+ return f"{v:g}" if isinstance(v, (int, float)) else str(v)
179
+
180
+
181
+ def _change(pct) -> str:
182
+ if pct is None:
183
+ return "<span class='dim'>—</span>"
184
+ if pct < 0:
185
+ return f"<span class='down'>&#9660; {abs(pct)}%</span>"
186
+ if pct > 0:
187
+ return f"<span class='up'>&#9650; {pct}%</span>"
188
+ return "<span class='dim'>0%</span>"
189
+
190
+
191
+ def _stock(in_stock) -> str:
192
+ if in_stock is True:
193
+ return "<span class='dot filled'></span>In stock"
194
+ if in_stock is False:
195
+ return "<span class='dot'></span>Out"
196
+ return "<span class='dim'>—</span>"
197
+
198
+
199
+ def _sparkline_for_site(site_id: int) -> str:
200
+ records = storage.latest_records(site_id)
201
+ if not records or not records[0].get("item_key"):
202
+ return ""
203
+ series = storage.price_history(site_id, records[0]["item_key"], "price")
204
+ values = [p["value"] for p in series if isinstance(p["value"], (int, float))]
205
+ if len(values) < 2:
206
+ return ""
207
+ return _sparkline(values)
208
+
209
+
210
+ def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
211
+ lo, hi = min(values), max(values)
212
+ span = (hi - lo) or 1.0
213
+ n = len(values)
214
+ points = " ".join(
215
+ f"{(i / (n - 1)) * width:.1f},{height - ((v - lo) / span) * height:.1f}"
216
+ for i, v in enumerate(values)
217
+ )
218
+ return (
219
+ f"<svg class='spark' width='{width}' height='{height}' viewBox='0 0 {width} {height}'>"
220
+ f"<polyline fill='none' stroke='currentColor' stroke-width='1.5' points='{points}'/></svg>"
221
+ )
222
+
223
+
224
+ def _short(text, n: int = 45) -> str:
225
+ if not text:
226
+ return ""
227
+ text = str(text)
228
+ return text if len(text) <= n else text[: n - 1] + "\u2026"
229
+
230
+
231
+ _PAGE = """<!doctype html>
232
+ <html lang="en">
233
+ <head>
234
+ <meta charset="utf-8">
235
+ <meta name="viewport" content="width=device-width, initial-scale=1">
236
+ <title>Crawlr</title>
237
+ <style>
238
+ :root {{
239
+ --ink: #0a0a0a; --paper: #ffffff; --line: #e5e5e5; --muted: #8a8a8a;
240
+ --chip: #f2f2f2; --radius: 14px;
241
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
242
+ "Helvetica Neue", "Segoe UI", Roboto, sans-serif;
243
+ }}
244
+ * {{ box-sizing: border-box; }}
245
+ body {{ font-family: var(--font); margin: 0; background: var(--paper); color: var(--ink);
246
+ -webkit-font-smoothing: antialiased; letter-spacing: -0.01em; }}
247
+ .wrap {{ max-width: 1000px; margin: 0 auto; padding: 3rem 1.5rem 5rem; }}
248
+ header {{ display: flex; align-items: baseline; gap: .75rem; margin-bottom: .25rem; }}
249
+ h1 {{ font-size: 2rem; font-weight: 700; margin: 0; letter-spacing: -0.03em; }}
250
+ .count {{ color: var(--muted); font-size: .95rem; }}
251
+ .tagline {{ color: var(--muted); margin: 0 0 2.5rem; font-size: 1rem; }}
252
+ h2 {{ font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em;
253
+ color: var(--muted); margin: 2.5rem 0 .75rem; }}
254
+ .card {{ border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden;
255
+ background: var(--paper); }}
256
+ table {{ width: 100%; border-collapse: collapse; font-size: .95rem; }}
257
+ th {{ text-align: left; font-weight: 600; color: var(--muted); font-size: .75rem;
258
+ text-transform: uppercase; letter-spacing: .05em; padding: .85rem 1rem; }}
259
+ td {{ padding: .85rem 1rem; border-top: 1px solid var(--line); vertical-align: middle; }}
260
+ tbody tr:hover {{ background: #fafafa; }}
261
+ .title {{ font-weight: 600; max-width: 320px; }}
262
+ .sub {{ color: var(--muted); font-weight: 400; font-size: .78rem; margin-top: .15rem; }}
263
+ .num {{ font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }}
264
+ .right {{ text-align: right; }}
265
+ .strong {{ font-weight: 700; }}
266
+ .was {{ color: var(--muted); text-decoration: line-through; }}
267
+ .up {{ font-weight: 600; }} .down {{ font-weight: 600; }}
268
+ .dim, .empty {{ color: var(--muted); }}
269
+ .empty {{ text-align: center; padding: 2rem; }}
270
+ .mono {{ font-variant-numeric: tabular-nums; }}
271
+ .pill {{ display: inline-block; background: var(--chip); border-radius: 999px;
272
+ padding: .15rem .6rem; font-size: .78rem; font-weight: 500; }}
273
+ .dot {{ display: inline-block; width: .55rem; height: .55rem; border-radius: 50%;
274
+ border: 1.5px solid var(--ink); margin-right: .4rem; vertical-align: middle; }}
275
+ .dot.filled {{ background: var(--ink); }}
276
+ .spark {{ display: block; margin-top: .4rem; color: var(--ink); }}
277
+ .btn {{ font-family: var(--font); background: var(--ink); color: var(--paper); border: 0;
278
+ border-radius: 999px; padding: .4rem .9rem; font-size: .82rem; font-weight: 600;
279
+ cursor: pointer; }}
280
+ .btn:hover {{ opacity: .85; }}
281
+ form.add {{ display: flex; gap: .6rem; flex-wrap: wrap; align-items: center;
282
+ margin-top: 1rem; padding: 1rem; border: 1px solid var(--line);
283
+ border-radius: var(--radius); }}
284
+ input, select {{ font-family: var(--font); background: var(--paper); color: var(--ink);
285
+ border: 1px solid var(--line); border-radius: 10px; padding: .55rem .7rem;
286
+ font-size: .92rem; }}
287
+ input:focus, select:focus {{ outline: 2px solid var(--ink); outline-offset: -1px; }}
288
+ input[type=url] {{ flex: 1; min-width: 240px; }}
289
+ input[type=number] {{ width: 6.5rem; }}
290
+ </style>
291
+ </head>
292
+ <body>
293
+ <div class="wrap">
294
+ <header>
295
+ <h1>Crawlr</h1>
296
+ <span class="count">{watching} active / {count} watched</span>
297
+ </header>
298
+ <p class="tagline">Competitor price &amp; stock monitoring — self-healing.</p>
299
+
300
+ <h2>Watchlist</h2>
301
+ <div class="card">
302
+ <table>
303
+ <thead><tr>
304
+ <th>Product</th><th class="num">Current</th><th class="num">Was</th>
305
+ <th class="num">Change</th><th>Stock</th><th class="num">Target</th>
306
+ <th>Status</th><th></th>
307
+ </tr></thead>
308
+ <tbody>{watch_rows}</tbody>
309
+ </table>
310
+ </div>
311
+
312
+ <form class="add" method="post" action="/sites">
313
+ <input type="url" name="url" placeholder="https://store.example/product/123" required>
314
+ <select name="schema_name" title="Schema">{schema_options}</select>
315
+ <select name="alert_trigger" title="Alert me when">{trigger_options}</select>
316
+ <input type="number" name="target_price" placeholder="Target $" step="0.01" min="0">
317
+ <input type="number" name="interval" value="60" min="1" title="Every N minutes">
318
+ <button class="btn" type="submit">Watch</button>
319
+ </form>
320
+
321
+ <h2>Recent changes</h2>
322
+ <div class="card">
323
+ <table>
324
+ <thead><tr><th>When</th><th>Item</th><th>Field</th><th>Old</th><th>New</th></tr></thead>
325
+ <tbody>{change_rows}</tbody>
326
+ </table>
327
+ </div>
328
+ </div>
329
+ </body>
330
+ </html>"""