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 +9 -0
- crawlr/alerts.py +130 -0
- crawlr/api.py +330 -0
- crawlr/cli.py +369 -0
- crawlr/config.py +109 -0
- crawlr/db.py +153 -0
- crawlr/extractor.py +152 -0
- crawlr/fetcher.py +175 -0
- crawlr/llm.py +268 -0
- crawlr/models.py +125 -0
- crawlr/monitor.py +185 -0
- crawlr/scheduler.py +52 -0
- crawlr/schemas.py +89 -0
- crawlr/selector_cache.py +74 -0
- crawlr/simplifier.py +89 -0
- crawlr/storage.py +268 -0
- crawlr/triggers.py +211 -0
- crawlr/usage.py +73 -0
- crawlr/validate.py +65 -0
- crawlr/verticals/__init__.py +9 -0
- crawlr/verticals/ecommerce.py +84 -0
- crawlr-0.1.0.dist-info/METADATA +326 -0
- crawlr-0.1.0.dist-info/RECORD +26 -0
- crawlr-0.1.0.dist-info/WHEEL +4 -0
- crawlr-0.1.0.dist-info/entry_points.txt +2 -0
- crawlr-0.1.0.dist-info/licenses/LICENSE +21 -0
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'>▼ {abs(pct)}%</span>"
|
|
186
|
+
if pct > 0:
|
|
187
|
+
return f"<span class='up'>▲ {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 & 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>"""
|