initial push
This commit is contained in:
commit
8267064bc1
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@ -0,0 +1,10 @@
|
||||
FROM python:3.13-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
COPY README.md .
|
||||
RUN mkdir -p /data
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
44
README.md
Normal file
44
README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# What2Do
|
||||
|
||||
A lightweight, local-first event aggregator. It crawls configured venue pages with CSS selectors or schema.org JSON-LD, normalizes events, and provides date/location/radius search.
|
||||
|
||||
No LLM, ML, computer vision, browser automation, JavaScript build process, PostGIS, Redis, or external database is required.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open http://localhost:8000. The source admin is at http://localhost:8000/sources.
|
||||
|
||||
Two example sources are installed automatically:
|
||||
|
||||
- Dicey Riley's recurring weekly specials
|
||||
- La La La's dated event list
|
||||
|
||||
Click **Crawl now** beside each source after startup. Crawls then run on each source's configured schedule while the app is running.
|
||||
|
||||
## Extraction model
|
||||
|
||||
Rules are relative to a repeating event container:
|
||||
|
||||
- Event container, title, date, time, link, description, and location are CSS selectors.
|
||||
- Date parsing accepts a Python `strptime` format or flexible text parsing.
|
||||
- `json-ld` as the event container reads schema.org `Event` entries without CSS rules.
|
||||
- Weekly pages can create the next 12 occurrences from weekday labels.
|
||||
- The source's venue address is the fallback when event HTML omits a location.
|
||||
|
||||
Use **Test extraction** to preview up to 20 records before saving. Static server-rendered HTML is intentionally used; sites that only render events with JavaScript are not supported in this low-overhead version.
|
||||
|
||||
## Location search
|
||||
|
||||
Addresses are geocoded and cached using OpenStreetMap's public Nominatim service. The search field also accepts `latitude,longitude` directly, and every source has optional coordinate fields for deterministic/offline venue positioning. Set a real contact in `NOMINATIM_USER_AGENT` before public/shared use and follow the [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/). For heavier use, configure or self-host a compatible geocoder.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- SQLite data is persisted in the `what2do-data` Docker volume.
|
||||
- Fetches are limited to 5 MB and public HTTP(S) destinations to reduce SSRF risk.
|
||||
- The crawler does not bypass authentication or anti-bot systems.
|
||||
- Review each site's terms and `robots.txt`; only crawl sources you are permitted to index.
|
||||
- The MVP has no authentication. Bind it to localhost or place it behind an authenticated reverse proxy before exposing crawler administration to a network.
|
||||
95
STRATEGIES.md
Normal file
95
STRATEGIES.md
Normal file
@ -0,0 +1,95 @@
|
||||
# Generic extraction strategies
|
||||
|
||||
Sources persist a JSON strategy. Runtime behavior is determined only by this configuration—there are no hostname-specific parsers.
|
||||
|
||||
## HTML
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "html",
|
||||
"request_url": "https://example.org/events",
|
||||
"method": "GET",
|
||||
"item_selector": ".event-card",
|
||||
"title_selector": ".title",
|
||||
"date_selector": ".date",
|
||||
"time_selector": ".time",
|
||||
"link_selector": "a.details",
|
||||
"link_attribute": "href",
|
||||
"description_selector": ".summary",
|
||||
"location_selector": ".venue",
|
||||
"date_format": ""
|
||||
}
|
||||
```
|
||||
|
||||
Selectors may read attributes using `title_attribute`, `date_attribute`, `time_attribute`, `link_attribute`, `description_attribute`, and `location_attribute`. Use `:scope` to read an attribute from the event container itself.
|
||||
|
||||
## JSON API
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "json",
|
||||
"request_url": "https://example.org/api/sessions",
|
||||
"method": "GET",
|
||||
"items_path": "results.events",
|
||||
"title_path": "name",
|
||||
"date_path": "startDate",
|
||||
"end_path": "endDate",
|
||||
"description_path": "description",
|
||||
"location_path": "venue.address",
|
||||
"link_path": "url",
|
||||
"disabled_path": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
Paths use dot notation. Numeric path components index arrays.
|
||||
|
||||
### Joining metadata
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "json",
|
||||
"request_url": "https://example.org/api/sessions",
|
||||
"items_path": "",
|
||||
"date_path": "utcDate",
|
||||
"link_path": "link",
|
||||
"join_item_key": "movieId",
|
||||
"join_title_path": "name",
|
||||
"join_description_path": "summary",
|
||||
"join_duration_path": "duration",
|
||||
"join": {
|
||||
"request_url": "https://example.org/api/movies",
|
||||
"method": "GET",
|
||||
"items_path": "",
|
||||
"key_path": "id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JSON containing HTML fragments
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "html_fragment",
|
||||
"request_url": "https://example.org/ajax",
|
||||
"method": "POST",
|
||||
"body": {"action": "events", "venue": "123"},
|
||||
"html_path": "html",
|
||||
"item_selector": ".event-card",
|
||||
"title_selector": ".title",
|
||||
"date_selector": ".date",
|
||||
"link_selector": "a",
|
||||
"link_attribute": "href",
|
||||
"page_parameter": "paged",
|
||||
"page_start": 1,
|
||||
"max_pages": 10,
|
||||
"total_pages_path": "pages_total"
|
||||
}
|
||||
```
|
||||
|
||||
## Safety limits
|
||||
|
||||
- Request URLs still pass public-network SSRF validation.
|
||||
- Redirect destinations are validated for normal HTML fetches.
|
||||
- Responses default to a 5 MB limit.
|
||||
- Pagination is capped at 20 pages.
|
||||
- Browser-compatible headers are global; strategy headers may add endpoint-specific `Accept` or `Referer` values.
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
BIN
app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/crawler.cpython-314.pyc
Normal file
BIN
app/__pycache__/crawler.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/db.cpython-314.pyc
Normal file
BIN
app/__pycache__/db.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-314.pyc
Normal file
BIN
app/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/strategies.cpython-314.pyc
Normal file
BIN
app/__pycache__/strategies.cpython-314.pyc
Normal file
Binary file not shown.
287
app/crawler.py
Normal file
287
app/crawler.py
Normal file
@ -0,0 +1,287 @@
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from dateutil import parser as date_parser
|
||||
|
||||
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
BROWSER_HEADERS = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-AU,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
"DNT": "1",
|
||||
"From": "what2do local event index",
|
||||
}
|
||||
SOCIAL_WIDGET_MARKERS = ("instagram", "sbi_", "facebook", "social-feed", "tiktok")
|
||||
|
||||
@dataclass
|
||||
class ExtractedEvent:
|
||||
title: str
|
||||
starts_at: datetime
|
||||
ends_at: datetime | None = None
|
||||
description: str = ""
|
||||
url: str = ""
|
||||
address: str = ""
|
||||
|
||||
class CrawlError(Exception):
|
||||
pass
|
||||
|
||||
def validate_public_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise CrawlError("Only absolute http:// and https:// URLs are allowed.")
|
||||
try:
|
||||
infos = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80))
|
||||
except socket.gaierror as exc:
|
||||
raise CrawlError(f"Host cannot be resolved: {exc}") from exc
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if not ip.is_global:
|
||||
raise CrawlError("URLs resolving to private, loopback, link-local, or reserved addresses are blocked.")
|
||||
|
||||
def _http_client(timeout: int = 20) -> httpx.Client:
|
||||
return httpx.Client(follow_redirects=True, timeout=timeout, headers=BROWSER_HEADERS)
|
||||
|
||||
def fetch_html(url: str) -> str:
|
||||
validate_public_url(url)
|
||||
with _http_client() as client:
|
||||
response = client.get(url)
|
||||
response.raise_for_status()
|
||||
validate_public_url(str(response.url))
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "html" not in content_type.lower():
|
||||
raise CrawlError(f"Expected HTML but received {content_type or 'an unknown content type'}.")
|
||||
if len(response.content) > 5_000_000:
|
||||
raise CrawlError("Page exceeds the 5 MB crawl limit.")
|
||||
return response.text
|
||||
|
||||
def _text(node, selector: str) -> str:
|
||||
if not selector:
|
||||
return ""
|
||||
found = node.select_one(selector)
|
||||
return found.get_text(" ", strip=True) if found else ""
|
||||
|
||||
def _link(node, selector: str, base_url: str) -> str:
|
||||
found = node.select_one(selector) if selector else None
|
||||
target = found or node
|
||||
for attribute in ("href", "data-permalink", "data-url", "data-href"):
|
||||
value = target.get(attribute)
|
||||
if value:
|
||||
return urljoin(base_url, str(value))
|
||||
return base_url
|
||||
|
||||
def _parse_date_range(value: str, source: dict, now: datetime) -> tuple[datetime, datetime | None]:
|
||||
value = " ".join(value.replace("–", "-").replace("—", "-").split())
|
||||
same_month = re.fullmatch(r"(\d{1,2})\s*-\s*(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})(?:\s+(.+))?", value)
|
||||
cross_month = re.fullmatch(r"(\d{1,2})\s+([A-Za-z]+)\s*-\s*(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})(?:\s+(.+))?", value)
|
||||
if same_month:
|
||||
start_day, end_day, month, year, time_text = same_month.groups()
|
||||
suffix = f" {time_text}" if time_text else ""
|
||||
start = _parse_date(f"{start_day} {month} {year}{suffix}", source, now)
|
||||
end = _parse_date(f"{end_day} {month} {year}{suffix}", source, now)
|
||||
return start, end
|
||||
if cross_month:
|
||||
start_day, start_month, end_day, end_month, year, time_text = cross_month.groups()
|
||||
suffix = f" {time_text}" if time_text else ""
|
||||
start = _parse_date(f"{start_day} {start_month} {year}{suffix}", source, now)
|
||||
end = _parse_date(f"{end_day} {end_month} {year}{suffix}", source, now)
|
||||
return start, end
|
||||
return _parse_date(value, source, now), None
|
||||
|
||||
def _parse_date(value: str, source: dict, now: datetime) -> datetime:
|
||||
value = " ".join(value.split())
|
||||
tz = ZoneInfo(source.get("timezone") or "UTC")
|
||||
fmt = source.get("date_format", "").strip()
|
||||
if fmt:
|
||||
candidates = [fmt]
|
||||
# Event lists commonly omit a year. Resolve to the next occurrence.
|
||||
for candidate in candidates:
|
||||
parsed = datetime.strptime(value, candidate).replace(tzinfo=tz)
|
||||
if "%Y" not in candidate and "%y" not in candidate:
|
||||
parsed = parsed.replace(year=now.year)
|
||||
if parsed < now - timedelta(days=2):
|
||||
parsed = parsed.replace(year=now.year + 1)
|
||||
return parsed
|
||||
if not re.search(r"\b\d{1,4}\b", value):
|
||||
raise ValueError("Date text must contain a numeric day or year")
|
||||
parsed = date_parser.parse(value, fuzzy=True, default=now.replace(tzinfo=None), tzinfos={"INFO": None, "IPAC": None, "IMAX": None})
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=tz)
|
||||
if not now.year - 1 <= parsed.year <= now.year + 10:
|
||||
raise ValueError("Parsed year is outside the plausible event range")
|
||||
return parsed.replace(microsecond=0)
|
||||
|
||||
def _extract_json_ld(soup: BeautifulSoup, base_url: str, source: dict, now: datetime) -> list[ExtractedEvent]:
|
||||
result = []
|
||||
def walk(value):
|
||||
if isinstance(value, list):
|
||||
for item in value: yield from walk(item)
|
||||
elif isinstance(value, dict):
|
||||
if value.get("@type") == "Event" or "Event" in (value.get("@type") or []):
|
||||
yield value
|
||||
for key in ("@graph", "itemListElement"):
|
||||
if key in value: yield from walk(value[key])
|
||||
for script in soup.select('script[type="application/ld+json"]'):
|
||||
try:
|
||||
data = json.loads(script.string or script.get_text())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for event in walk(data):
|
||||
location = event.get("location") or {}
|
||||
address = location.get("address", "") if isinstance(location, dict) else ""
|
||||
if isinstance(address, dict):
|
||||
address = ", ".join(str(address.get(k, "")) for k in ("streetAddress", "addressLocality", "addressRegion", "postalCode") if address.get(k))
|
||||
try:
|
||||
start = _parse_date(str(event["startDate"]), source, now)
|
||||
end = _parse_date(str(event["endDate"]), source, now) if event.get("endDate") else None
|
||||
except (KeyError, ValueError, OverflowError):
|
||||
continue
|
||||
result.append(ExtractedEvent(title=str(event.get("name", "Untitled event")), starts_at=start, ends_at=end, description=str(event.get("description", "")), url=urljoin(base_url, str(event.get("url", base_url))), address=str(address)))
|
||||
return result
|
||||
|
||||
|
||||
def _weekly_dates(day_text: str, now: datetime, weeks: int = 12) -> list[datetime]:
|
||||
names = {name.lower(): i for i, name in enumerate(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))}
|
||||
match = next(((name, idx) for name, idx in names.items() if name in day_text.lower()), None)
|
||||
if not match:
|
||||
return []
|
||||
delta = (match[1] - now.weekday()) % 7
|
||||
first = (now + timedelta(days=delta)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return [first + timedelta(weeks=i) for i in range(weeks)]
|
||||
|
||||
def _selector_for(node) -> str:
|
||||
if node.get("id"):
|
||||
return f"#{node['id']}"
|
||||
classes = [value for value in node.get("class", []) if not value.startswith(("elementor-element-", "post-"))]
|
||||
return node.name + "".join(f".{value}" for value in classes[:2])
|
||||
|
||||
def detect_rules(html: str, url: str, timezone: str = "Australia/Sydney") -> tuple[dict, list[ExtractedEvent], str]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
now = datetime.now(ZoneInfo(timezone))
|
||||
json_source = {"url": url, "timezone": timezone, "item_selector": "json-ld", "title_selector": "", "date_selector": "", "time_selector": "", "link_selector": "", "description_selector": "", "location_selector": "", "date_format": "", "recurring_weekly": 0}
|
||||
json_events = extract_events(html, json_source)
|
||||
if json_events:
|
||||
return json_source, json_events, "Found schema.org JSON-LD Event data."
|
||||
|
||||
date_pattern = re.compile(r"(?:\b(?:mon|tue|wed|thu|fri|sat|sun)(?:day)?\b|\b\d{1,2}[\s/.-](?:\d{1,2}|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)|\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{1,2})", re.I)
|
||||
candidates = []
|
||||
container_selectors = ("article", "li", ".event", ".events", "[class*='event']", "[class*='Event']", ".event-list__item", ".item", "[class*='item']", ".wpupg-item", ".wpupg-item.wpupg-type-event", "[data-permalink]", ".wp-block-post", ".elementor-loop-item", ".tribe-events-calendar-list__event-row", ".em-event", ".mec-event-article")
|
||||
for selector in container_selectors:
|
||||
try: nodes = soup.select(selector)
|
||||
except Exception: continue
|
||||
if not 2 <= len(nodes) <= 300:
|
||||
continue
|
||||
nodes = [node for node in nodes if not any(marker in " ".join(str(value) for value in (node.get("class") or [])).casefold() for marker in SOCIAL_WIDGET_MARKERS)]
|
||||
if len(nodes) < 2: continue
|
||||
dated = [node for node in nodes if date_pattern.search(node.get_text(" ", strip=True))]
|
||||
titled = [node for node in dated if node.find(["h1", "h2", "h3", "h4", "a"]) or node.select_one("[class*='title'], [itemprop='name']")]
|
||||
score = len(titled) * 3 + len(dated) - max(0, len(nodes) - len(dated))
|
||||
if len(titled) >= 2:
|
||||
candidates.append((score, selector, titled))
|
||||
if not candidates:
|
||||
raise CrawlError("No repeated event-like HTML containers were detected. Try JSON-LD or configure CSS selectors manually.")
|
||||
trials = []
|
||||
for _, item_selector, nodes in sorted(candidates, reverse=True)[:8]:
|
||||
title_selectors = []
|
||||
date_selectors = []
|
||||
link_selectors = []
|
||||
for sample in nodes[:8]:
|
||||
for node in sample.select("h1, h2, h3, h4, a, [class*='title'], [itemprop='name']"):
|
||||
selector = _selector_for(node)
|
||||
if selector not in title_selectors: title_selectors.append(selector)
|
||||
for node in sample.find_all(["time", "span", "div", "li", "p", "ul"]):
|
||||
if date_pattern.search(node.get_text(" ", strip=True)):
|
||||
selector = _selector_for(node)
|
||||
if selector not in date_selectors: date_selectors.append(selector)
|
||||
for node in sample.find_all("a"):
|
||||
selector = _selector_for(node)
|
||||
if selector not in link_selectors: link_selectors.append(selector)
|
||||
if any(sample.get(attribute) for attribute in ("data-permalink", "data-url", "data-href")) and ":scope" not in link_selectors:
|
||||
link_selectors.insert(0, ":scope")
|
||||
time_selectors = []
|
||||
for sample in nodes[:8]:
|
||||
for node in sample.find_all(["time", "span", "div", "p"]):
|
||||
if re.search(r"\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b", node.get_text(" ", strip=True), re.IGNORECASE):
|
||||
selector = _selector_for(node)
|
||||
if selector not in time_selectors: time_selectors.append(selector)
|
||||
for title_selector in title_selectors[:6]:
|
||||
for date_selector in date_selectors[:10]:
|
||||
for time_selector in [""] + time_selectors[:4]:
|
||||
for recurring in (0, 1):
|
||||
rules = {"url": url, "timezone": timezone, "item_selector": item_selector, "title_selector": title_selector, "date_selector": date_selector, "time_selector": time_selector, "link_selector": link_selectors[0] if link_selectors else "", "description_selector": "", "location_selector": "", "date_format": "", "recurring_weekly": recurring}
|
||||
events = extract_events(html, rules)
|
||||
unique_titles = len({event.title for event in events})
|
||||
explicit_dates = sum(1 for event in events if event.starts_at.second == 0 and event.starts_at.microsecond == 0 and now.year - 1 <= event.starts_at.year <= now.year + 10)
|
||||
if events and unique_titles >= 2 and explicit_dates == len(events):
|
||||
occurrence_penalty = 12 if recurring else 1
|
||||
trials.append((unique_titles * 10 + len(events) / occurrence_penalty, rules, events, len(nodes)))
|
||||
if not trials:
|
||||
raise CrawlError("Event-like containers were found, but no selector combination produced parseable events. Adjust selectors manually.")
|
||||
_, rules, events, container_count = max(trials, key=lambda value: value[0])
|
||||
winning_nodes = soup.select(rules["item_selector"])
|
||||
sample = winning_nodes[0] if winning_nodes else None
|
||||
if sample:
|
||||
description_node = sample.find(["p", "summary"])
|
||||
location_node = next((node for node in sample.find_all(["span", "div", "p"]) if re.search(r"\b(?:venue|location|address|theatre|hall|hotel|club)\b", node.get_text(" ", strip=True), re.I)), None)
|
||||
rules["description_selector"] = _selector_for(description_node) if description_node else ""
|
||||
rules["location_selector"] = _selector_for(location_node) if location_node else ""
|
||||
return rules, events, f"Guessed CSS selectors from {container_count} repeated event-like containers."
|
||||
|
||||
def extract_events(html: str, source: dict) -> list[ExtractedEvent]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
tz = ZoneInfo(source.get("timezone") or "UTC")
|
||||
now = datetime.now(tz)
|
||||
item_selector = source.get("item_selector", "").strip()
|
||||
if not item_selector or item_selector == "json-ld":
|
||||
json_events = _extract_json_ld(soup, source["url"], source, now)
|
||||
if json_events or item_selector == "json-ld":
|
||||
return json_events
|
||||
events = []
|
||||
for item in soup.select(item_selector):
|
||||
title = _text(item, source.get("title_selector", ""))
|
||||
raw_date = _text(item, source.get("date_selector", ""))
|
||||
raw_time = _text(item, source.get("time_selector", ""))
|
||||
if not title or not raw_date:
|
||||
continue
|
||||
description = _text(item, source.get("description_selector", ""))
|
||||
address = _text(item, source.get("location_selector", ""))
|
||||
link = _link(item, source.get("link_selector", ""), source["url"])
|
||||
if source.get("recurring_weekly"):
|
||||
for start in _weekly_dates(raw_date, now):
|
||||
events.append(ExtractedEvent(title=title, starts_at=start, description=description, url=link, address=address))
|
||||
continue
|
||||
try:
|
||||
start, end = _parse_date_range(f"{raw_date} {raw_time}".strip(), source, now)
|
||||
except (ValueError, OverflowError):
|
||||
continue
|
||||
events.append(ExtractedEvent(title=title, starts_at=start, ends_at=end, description=description, url=link, address=address))
|
||||
return events
|
||||
|
||||
def infer_page_fields(html: str, url: str) -> dict:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
title = ""
|
||||
og_site = soup.select_one('meta[property="og:site_name"]')
|
||||
if og_site:
|
||||
content = og_site.get("content")
|
||||
title = str(content).strip() if content else ""
|
||||
if not title and soup.title:
|
||||
title = re.split(r"[|–—-]", soup.title.get_text(" ", strip=True))[0].strip()
|
||||
heading = soup.find("h1")
|
||||
source_name = heading.get_text(" ", strip=True) if heading else title or urlparse(url).hostname or "Event source"
|
||||
text = soup.get_text(" ", strip=True)
|
||||
address_match = re.search(r"\b\d{1,5}[A-Za-z/-]*\s+[A-Za-z][A-Za-z .'-]+(?:Street|St|Road|Rd|Lane|Ln|Avenue|Ave|Drive|Dr|Way|Place|Pl),?\s+[A-Za-z ]+\s+(?:NSW|VIC|QLD|SA|WA|TAS|NT|ACT)\s+\d{4}\b", text, re.I)
|
||||
address = address_match.group(0).strip() if address_match else ""
|
||||
return {"name": source_name, "venue_name": title or source_name, "venue_address": address}
|
||||
|
||||
def external_key(event: ExtractedEvent) -> str:
|
||||
value = f"{event.title.casefold()}|{event.starts_at.isoformat()}|{event.address.casefold()}"
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
93
app/db.py
Normal file
93
app/db.py
Normal file
@ -0,0 +1,93 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = os.getenv("DATABASE_PATH", "data/what2do.db")
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
venue_name TEXT NOT NULL,
|
||||
venue_address TEXT NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
timezone TEXT NOT NULL DEFAULT 'Australia/Sydney',
|
||||
strategy_json TEXT NOT NULL DEFAULT '{}',
|
||||
item_selector TEXT NOT NULL,
|
||||
title_selector TEXT NOT NULL,
|
||||
date_selector TEXT NOT NULL,
|
||||
time_selector TEXT NOT NULL DEFAULT '',
|
||||
link_selector TEXT NOT NULL DEFAULT '',
|
||||
description_selector TEXT NOT NULL DEFAULT '',
|
||||
location_selector TEXT NOT NULL DEFAULT '',
|
||||
date_format TEXT NOT NULL DEFAULT '',
|
||||
recurring_weekly INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
crawl_interval_minutes INTEGER NOT NULL DEFAULT 360,
|
||||
last_crawled_at TEXT,
|
||||
last_status TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||
external_key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
starts_at TEXT NOT NULL,
|
||||
ends_at TEXT,
|
||||
venue_name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
event_url TEXT NOT NULL,
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(source_id, external_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_starts_at ON events(starts_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source ON events(source_id);
|
||||
CREATE TABLE IF NOT EXISTS geocode_cache (
|
||||
query TEXT PRIMARY KEY,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
display_name TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def connect():
|
||||
path = Path(DB_PATH)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
db = sqlite3.connect(path)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def init_db():
|
||||
with connect() as db:
|
||||
db.executescript(SCHEMA)
|
||||
event_columns = {row["name"] for row in db.execute("PRAGMA table_info(events)")}
|
||||
if "hidden" not in event_columns:
|
||||
db.execute("ALTER TABLE events ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0")
|
||||
source_columns = {row["name"] for row in db.execute("PRAGMA table_info(sources)")}
|
||||
if "strategy_json" not in source_columns:
|
||||
db.execute("ALTER TABLE sources ADD COLUMN strategy_json TEXT NOT NULL DEFAULT '{}'")
|
||||
db.execute("""UPDATE sources SET strategy_json=json_object(
|
||||
'mode','html','request_url',url,'method','GET','response_type','html',
|
||||
'item_selector',item_selector,'title_selector',title_selector,'date_selector',date_selector,
|
||||
'time_selector',time_selector,'link_selector',link_selector,'description_selector',description_selector,
|
||||
'location_selector',location_selector,'date_format',date_format,'recurring_weekly',recurring_weekly
|
||||
) WHERE strategy_json='{}' OR strategy_json=''""")
|
||||
569
app/main.py
Normal file
569
app/main.py
Normal file
@ -0,0 +1,569 @@
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from calendar import Calendar
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .crawler import CrawlError, _http_client, detect_rules, external_key, fetch_html, infer_page_fields
|
||||
from .db import connect, init_db
|
||||
from .strategies import execute_strategy, parse_strategy
|
||||
|
||||
TEMPLATES = Jinja2Templates(directory="app/templates")
|
||||
NOMINATIM_USER_AGENT = os.getenv("NOMINATIM_USER_AGENT", "what2do-local/0.1")
|
||||
SCHEDULER_INTERVAL = int(os.getenv("CRAWL_INTERVAL_MINUTES", "60"))
|
||||
PICKER_SNAPSHOTS = {}
|
||||
PICKER_LOCK = threading.Lock()
|
||||
PICKER_TTL = timedelta(minutes=15)
|
||||
EVENT_TAGS = {
|
||||
"music": ("music", "band", "dj", "album", "tour", "concert", "punk", "emo", "rock", "singer"),
|
||||
"art": ("art", "gallery", "exhibition", "paint", "creative", "workshop"),
|
||||
"comedy": ("comedy", "comedian", "stand-up", "standup"),
|
||||
"trivia": ("trivia", "quiz"),
|
||||
"film": ("film", "movie", "cinema", "screening"),
|
||||
"food": ("food", "dinner", "lunch", "brunch", "schnitty", "steak", "surf & turf"),
|
||||
"sports": ("sport", "football", "rugby", "darts", "poker", "race"),
|
||||
"community": ("community", "market", "fundraiser", "family", "festival"),
|
||||
"nightlife": ("party", "club", "night", "bingo", "jackpot"),
|
||||
}
|
||||
|
||||
SEEDS = [
|
||||
{
|
||||
"name": "Dicey Riley's weekly specials", "url": "https://www.diceyrileyshotel.com.au/pub-specials/",
|
||||
"venue_name": "Dicey Riley's Hotel", "venue_address": "329-333 Crown St, Wollongong NSW 2500, Australia",
|
||||
"latitude": -34.42538, "longitude": 150.89375,
|
||||
"item_selector": "article.weekly_specials", "title_selector": "h2.elementor-heading-title",
|
||||
"date_selector": ".elementor-post-info__item--type-custom", "time_selector": "", "link_selector": "a",
|
||||
"date_format": "", "recurring_weekly": 1,
|
||||
},
|
||||
{
|
||||
"name": "La La La's events", "url": "https://www.lalalas.com.au/all-events/",
|
||||
"venue_name": "La La La's", "venue_address": "3 Globe Lane, Wollongong NSW 2500, Australia",
|
||||
"latitude": -34.42569, "longitude": 150.89816,
|
||||
"item_selector": ".slider_container > .items", "title_selector": ".feed_titles h2",
|
||||
"date_selector": ".feed_dates ul", "time_selector": "", "link_selector": "a.slider_button",
|
||||
"date_format": "%d %B %H:%M", "recurring_weekly": 0,
|
||||
},
|
||||
]
|
||||
|
||||
def source_values(form) -> dict:
|
||||
raw_strategy = form.get("strategy_json", "{}").strip() or "{}"
|
||||
try: strategy = json.loads(raw_strategy)
|
||||
except json.JSONDecodeError as exc: raise ValueError(f"Strategy JSON is invalid: {exc.msg} at line {exc.lineno}") from exc
|
||||
if strategy.get("mode", "html") == "html":
|
||||
strategy.update({"mode": "html", "request_url": form.get("url", "").strip(), "method": strategy.get("method", "GET"), "response_type": "html", "item_selector": form.get("item_selector", "").strip(), "title_selector": form.get("title_selector", "").strip(), "date_selector": form.get("date_selector", "").strip(), "time_selector": form.get("time_selector", "").strip(), "link_selector": form.get("link_selector", "").strip(), "description_selector": form.get("description_selector", "").strip(), "location_selector": form.get("location_selector", "").strip(), "date_format": form.get("date_format", "").strip(), "recurring_weekly": 1 if form.get("recurring_weekly") else 0})
|
||||
return {
|
||||
"name": form.get("name", "").strip(), "url": form.get("url", "").strip(),
|
||||
"venue_name": form.get("venue_name", "").strip(), "venue_address": form.get("venue_address", "").strip(),
|
||||
"latitude": float(form["latitude"]) if form.get("latitude") else None,
|
||||
"longitude": float(form["longitude"]) if form.get("longitude") else None,
|
||||
"timezone": form.get("timezone", "Australia/Sydney").strip(), "item_selector": form.get("item_selector", "").strip(),
|
||||
"title_selector": form.get("title_selector", "").strip(), "date_selector": form.get("date_selector", "").strip(),
|
||||
"time_selector": form.get("time_selector", "").strip(), "link_selector": form.get("link_selector", "").strip(),
|
||||
"description_selector": form.get("description_selector", "").strip(), "location_selector": form.get("location_selector", "").strip(),
|
||||
"date_format": form.get("date_format", "").strip(), "recurring_weekly": 1 if form.get("recurring_weekly") else 0,
|
||||
"strategy_json": json.dumps(strategy),
|
||||
"enabled": 1 if form.get("enabled") else 0, "crawl_interval_minutes": int(form.get("crawl_interval_minutes") or 360),
|
||||
}
|
||||
|
||||
def seed_sources():
|
||||
with connect() as db:
|
||||
for source in SEEDS:
|
||||
source_strategy = {"mode": "html", "request_url": source["url"], "method": "GET", "response_type": "html", "item_selector": source["item_selector"], "title_selector": source["title_selector"], "date_selector": source["date_selector"], "time_selector": source.get("time_selector", ""), "link_selector": source.get("link_selector", ""), "link_attribute": "href", "description_selector": "", "location_selector": "", "date_format": source.get("date_format", ""), "recurring_weekly": source.get("recurring_weekly", 0)}
|
||||
fields = {**source, "strategy_json": json.dumps(source_strategy), "timezone": "Australia/Sydney", "time_selector": source.get("time_selector", ""), "link_selector": source.get("link_selector", ""), "description_selector": "", "location_selector": "", "enabled": 1, "crawl_interval_minutes": 360}
|
||||
columns = ",".join(fields)
|
||||
db.execute(f"INSERT OR IGNORE INTO sources ({columns}) VALUES ({','.join('?' for _ in fields)})", tuple(fields.values()))
|
||||
db.execute("UPDATE sources SET latitude=COALESCE(latitude,?),longitude=COALESCE(longitude,?) WHERE url=?", (source.get("latitude"), source.get("longitude"), source["url"]))
|
||||
db.execute("UPDATE events SET latitude=COALESCE(latitude,?),longitude=COALESCE(longitude,?) WHERE source_id=(SELECT id FROM sources WHERE url=?)", (source.get("latitude"), source.get("longitude"), source["url"]))
|
||||
|
||||
def geocode(query: str):
|
||||
if not query:
|
||||
return None
|
||||
try:
|
||||
latitude, longitude = (float(value.strip()) for value in query.split(",", 1))
|
||||
if -90 <= latitude <= 90 and -180 <= longitude <= 180:
|
||||
return latitude, longitude, query
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
cache_key = query.casefold()
|
||||
with connect() as db:
|
||||
cached = db.execute("SELECT latitude, longitude, display_name FROM geocode_cache WHERE query=?", (cache_key,)).fetchone()
|
||||
if cached:
|
||||
return tuple(cached)
|
||||
parts = [part.strip() for part in query.split(",") if part.strip()]
|
||||
candidates = [query]
|
||||
if len(parts) > 1:
|
||||
candidates.append(", ".join(parts[1:]))
|
||||
result = []
|
||||
try:
|
||||
for candidate in candidates:
|
||||
response = httpx.get("https://nominatim.openstreetmap.org/search", params={"q": candidate, "format": "jsonv2", "limit": 1, "countrycodes": "au"}, headers={"User-Agent": NOMINATIM_USER_AGENT}, timeout=15)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if result:
|
||||
break
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return None
|
||||
if not result:
|
||||
return None
|
||||
value = (float(result[0]["lat"]), float(result[0]["lon"]), result[0].get("display_name", query))
|
||||
with connect() as db:
|
||||
db.execute("INSERT OR REPLACE INTO geocode_cache(query,latitude,longitude,display_name) VALUES(?,?,?,?)", (cache_key, *value))
|
||||
return value
|
||||
|
||||
def crawl_source(source_id: int):
|
||||
with connect() as db:
|
||||
row = db.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
|
||||
if not row:
|
||||
raise CrawlError("Source not found.")
|
||||
source = dict(row)
|
||||
try:
|
||||
with _http_client(timeout=45) as client:
|
||||
events = execute_strategy(source, client)
|
||||
coords = (source["latitude"], source["longitude"])
|
||||
if None in coords:
|
||||
point = geocode(source["venue_address"])
|
||||
coords = point[:2] if point else (None, None)
|
||||
seen = []
|
||||
with connect() as db:
|
||||
db.execute("UPDATE sources SET latitude=?,longitude=? WHERE id=?", (*coords, source_id))
|
||||
for event in events:
|
||||
key = external_key(event)
|
||||
seen.append(key)
|
||||
address = event.address or source["venue_address"]
|
||||
db.execute("""INSERT INTO events(source_id,external_key,title,description,starts_at,ends_at,venue_name,address,latitude,longitude,event_url)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(source_id,external_key) DO UPDATE SET title=excluded.title,description=excluded.description,
|
||||
starts_at=excluded.starts_at,ends_at=excluded.ends_at,venue_name=excluded.venue_name,address=excluded.address,latitude=excluded.latitude,longitude=excluded.longitude,event_url=excluded.event_url,last_seen_at=CURRENT_TIMESTAMP""",
|
||||
(source_id, key, event.title, event.description, event.starts_at.astimezone(timezone.utc).isoformat(), event.ends_at.astimezone(timezone.utc).isoformat() if event.ends_at else None, source["venue_name"], address, *coords, event.url))
|
||||
if seen:
|
||||
placeholders = ",".join("?" for _ in seen)
|
||||
db.execute(f"DELETE FROM events WHERE source_id=? AND external_key NOT IN ({placeholders})", (source_id, *seen))
|
||||
db.execute("UPDATE sources SET last_crawled_at=CURRENT_TIMESTAMP,last_status=?,last_error=NULL WHERE id=?", (f"Found {len(events)} events", source_id))
|
||||
return len(events)
|
||||
except Exception as exc:
|
||||
with connect() as db:
|
||||
db.execute("UPDATE sources SET last_crawled_at=CURRENT_TIMESTAMP,last_status='Failed',last_error=? WHERE id=?", (str(exc)[:1000], source_id))
|
||||
raise
|
||||
|
||||
async def scheduler():
|
||||
while True:
|
||||
await asyncio.sleep(max(1, SCHEDULER_INTERVAL) * 60)
|
||||
with connect() as db:
|
||||
rows = db.execute("SELECT id,last_crawled_at,crawl_interval_minutes FROM sources WHERE enabled=1").fetchall()
|
||||
now = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
last = datetime.fromisoformat(row["last_crawled_at"] + "+00:00") if row["last_crawled_at"] else None
|
||||
if not last or now - last >= timedelta(minutes=row["crawl_interval_minutes"]):
|
||||
try: await asyncio.to_thread(crawl_source, row["id"])
|
||||
except Exception: pass
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
init_db(); seed_sources()
|
||||
task = asyncio.create_task(scheduler())
|
||||
yield
|
||||
task.cancel()
|
||||
|
||||
app = FastAPI(title="What2Do", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
def classify_event(title: str, description: str) -> list[str]:
|
||||
text = f"{title} {description}".casefold()
|
||||
tags = [tag for tag, keywords in EVENT_TAGS.items() if any(keyword in text for keyword in keywords)]
|
||||
return tags or ["other"]
|
||||
|
||||
def distance_km(lat1, lon1, lat2, lon2):
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp, dl = math.radians(lat2-lat1), math.radians(lon2-lon1)
|
||||
a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
|
||||
return 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request, location: str = "", latitude: float | None = None, longitude: float | None = None, start: str = "", end: str = "", radius: float = 25, tag: list[str] = Query(default=[]), term: str = ""):
|
||||
point = (latitude, longitude, location) if latitude is not None and longitude is not None else (geocode(location) if location else None)
|
||||
local_now = datetime.now().astimezone()
|
||||
default_start = local_now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if default_start.month == 12:
|
||||
default_end = default_start.replace(year=default_start.year + 1, month=1)
|
||||
else:
|
||||
default_end = default_start.replace(month=default_start.month + 1)
|
||||
if not start:
|
||||
start = default_start.strftime("%Y-%m-%dT%H:%M")
|
||||
if not end:
|
||||
end = default_end.strftime("%Y-%m-%dT%H:%M")
|
||||
try: start_at = datetime.fromisoformat(start).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
start_at = default_start.astimezone(timezone.utc)
|
||||
start = default_start.strftime("%Y-%m-%dT%H:%M")
|
||||
try: end_at = datetime.fromisoformat(end).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
end_at = default_end.astimezone(timezone.utc)
|
||||
end = default_end.strftime("%Y-%m-%dT%H:%M")
|
||||
with connect() as db:
|
||||
rows = [dict(r) for r in db.execute("SELECT events.*,sources.name source_name FROM events JOIN sources ON sources.id=events.source_id WHERE events.hidden=0 AND starts_at < ? AND COALESCE(ends_at,starts_at) >= ? ORDER BY starts_at", (end_at.isoformat(), start_at.isoformat())).fetchall()]
|
||||
for event in rows:
|
||||
event["distance"] = distance_km(point[0], point[1], event["latitude"], event["longitude"]) if point and event["latitude"] is not None else None
|
||||
local_start = datetime.fromisoformat(event["starts_at"]).astimezone()
|
||||
event["display_time"] = local_start.strftime("%a %d %b %Y, %H:%M")
|
||||
if event["ends_at"]:
|
||||
local_end = datetime.fromisoformat(event["ends_at"]).astimezone()
|
||||
event["display_time"] += f" – {local_end.strftime('%a %d %b %Y, %H:%M')}"
|
||||
event["day_key"] = local_start.strftime("%Y-%m-%d")
|
||||
event["end_day_key"] = datetime.fromisoformat(event["ends_at"]).astimezone().strftime("%Y-%m-%d") if event["ends_at"] else event["day_key"]
|
||||
event["tags"] = classify_event(event["title"], event["description"])
|
||||
if point: rows = [event for event in rows if event["distance"] is not None and event["distance"] <= radius]
|
||||
selected_tags = [value for value in tag if value in EVENT_TAGS or value == "other"]
|
||||
if selected_tags: rows = [event for event in rows if set(selected_tags).intersection(event["tags"])]
|
||||
if term:
|
||||
needle = term.casefold()
|
||||
rows = [event for event in rows if needle in f"{event['title']} {event['description']} {event['venue_name']} {event['address']}".casefold()]
|
||||
calendar_start = start_at.astimezone()
|
||||
month_days = list(Calendar(firstweekday=0).itermonthdates(calendar_start.year, calendar_start.month))
|
||||
day_counts = {}
|
||||
for event in rows:
|
||||
covered_day = datetime.fromisoformat(event["day_key"]).date()
|
||||
final_day = datetime.fromisoformat(event["end_day_key"]).date()
|
||||
while covered_day <= final_day:
|
||||
key = covered_day.strftime("%Y-%m-%d")
|
||||
day_counts[key] = day_counts.get(key, 0) + 1
|
||||
covered_day += timedelta(days=1)
|
||||
calendar_days = [{"date": day, "key": day.strftime("%Y-%m-%d"), "count": day_counts.get(day.strftime("%Y-%m-%d"), 0), "in_month": day.month == calendar_start.month} for day in month_days]
|
||||
map_events = [{"id": event["id"], "title": event["title"], "venue": event["venue_name"], "time": event["display_time"], "latitude": event["latitude"], "longitude": event["longitude"]} for event in rows if event["latitude"] is not None]
|
||||
context = {"events": rows, "map_events": map_events, "calendar_days": calendar_days, "calendar_month": calendar_start.strftime("%B %Y"), "event_tags": EVENT_TAGS.keys(), "location": location, "latitude": latitude, "longitude": longitude, "start": start, "end": end, "radius": radius, "selected_tags": selected_tags, "term": term, "geocoded": point}
|
||||
return TEMPLATES.TemplateResponse(request, "index.html", context)
|
||||
|
||||
@app.get("/api/locations")
|
||||
def location_suggestions(q: str = ""):
|
||||
query = q.strip()
|
||||
if len(query) < 3:
|
||||
return []
|
||||
try:
|
||||
response = httpx.get("https://photon.komoot.io/api/", params={"q": query, "limit": 10, "lang": "en"}, headers={"User-Agent": "what2do-local/0.1"}, timeout=10)
|
||||
response.raise_for_status()
|
||||
suggestions = []
|
||||
for feature in response.json().get("features", []):
|
||||
properties = feature.get("properties", {})
|
||||
if properties.get("countrycode", "").upper() != "AU":
|
||||
continue
|
||||
coordinates = feature.get("geometry", {}).get("coordinates", [])
|
||||
if len(coordinates) < 2:
|
||||
continue
|
||||
place = properties.get("name") or properties.get("city") or properties.get("district") or properties.get("county")
|
||||
label = ", ".join(dict.fromkeys(value for value in (place, properties.get("city"), properties.get("state"), properties.get("postcode"), properties.get("country")) if value))
|
||||
suggestion = {"label": label, "latitude": float(coordinates[1]), "longitude": float(coordinates[0])}
|
||||
if label and suggestion not in suggestions:
|
||||
suggestions.append(suggestion)
|
||||
return suggestions[:5]
|
||||
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
||||
return []
|
||||
|
||||
@app.get("/api/addresses")
|
||||
def address_suggestions(q: str = ""):
|
||||
query = q.strip()
|
||||
if len(query) < 3:
|
||||
return []
|
||||
try:
|
||||
response = httpx.get("https://photon.komoot.io/api/", params={"q": query, "limit": 8, "lang": "en"}, headers={"User-Agent": "what2do-local/0.1"}, timeout=10)
|
||||
response.raise_for_status()
|
||||
results = []
|
||||
for feature in response.json().get("features", []):
|
||||
properties = feature.get("properties", {})
|
||||
if properties.get("countrycode", "").upper() != "AU": continue
|
||||
coordinates = feature.get("geometry", {}).get("coordinates", [])
|
||||
if len(coordinates) < 2: continue
|
||||
street = " ".join(value for value in (properties.get("housenumber"), properties.get("street")) if value)
|
||||
label = ", ".join(dict.fromkeys(value for value in (properties.get("name"), street, properties.get("city"), properties.get("state"), properties.get("postcode"), properties.get("country")) if value))
|
||||
item = {"label": label, "latitude": float(coordinates[1]), "longitude": float(coordinates[0])}
|
||||
if label and item not in results: results.append(item)
|
||||
return results[:5]
|
||||
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
||||
return []
|
||||
|
||||
@app.get("/events/{event_id}", response_class=HTMLResponse)
|
||||
def event_detail(request: Request, event_id: int):
|
||||
with connect() as db:
|
||||
row = db.execute("SELECT events.*,sources.name source_name,sources.url source_url FROM events JOIN sources ON sources.id=events.source_id WHERE events.id=? AND events.hidden=0", (event_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404)
|
||||
event = dict(row)
|
||||
local_start = datetime.fromisoformat(event["starts_at"]).astimezone()
|
||||
event["display_date"] = local_start.strftime("%A, %d %B %Y")
|
||||
event["display_time"] = local_start.strftime("%H:%M")
|
||||
event["display_end"] = None
|
||||
if event["ends_at"]:
|
||||
local_end = datetime.fromisoformat(event["ends_at"]).astimezone()
|
||||
event["display_end"] = local_end.strftime("%A, %d %B %Y at %H:%M")
|
||||
event["canonical_url"] = str(request.url)
|
||||
return TEMPLATES.TemplateResponse(request, "event.html", {"event": event})
|
||||
|
||||
@app.get("/sources", response_class=HTMLResponse)
|
||||
def sources(request: Request):
|
||||
with connect() as db: rows = db.execute("SELECT *, (SELECT count(*) FROM events WHERE source_id=sources.id) event_count FROM sources ORDER BY name").fetchall()
|
||||
return TEMPLATES.TemplateResponse(request, "sources.html", {"sources": rows})
|
||||
|
||||
@app.get("/sources/new", response_class=HTMLResponse)
|
||||
def new_source(request: Request):
|
||||
return TEMPLATES.TemplateResponse(request, "source_form.html", {"source": {"enabled": 1, "timezone": "Australia/Sydney", "crawl_interval_minutes": 360, "strategy_json": "{}"}, "action": "/sources", "preview": None})
|
||||
|
||||
@app.get("/sources/{source_id}", response_class=HTMLResponse)
|
||||
def edit_source(request: Request, source_id: int):
|
||||
with connect() as db: source = db.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
|
||||
if not source: raise HTTPException(404)
|
||||
return TEMPLATES.TemplateResponse(request, "source_form.html", {"source": {**dict(source), "strategy_json": json.dumps(parse_strategy(source["strategy_json"]), indent=2)}, "action": f"/sources/{source_id}", "preview": None})
|
||||
|
||||
@app.post("/sources")
|
||||
async def create_source(request: Request):
|
||||
form = await request.form(); values = source_values(form)
|
||||
with connect() as db:
|
||||
columns = ",".join(values); cursor = db.execute(f"INSERT INTO sources ({columns}) VALUES ({','.join('?' for _ in values)})", tuple(values.values()))
|
||||
return RedirectResponse(f"/sources/{cursor.lastrowid}", 303)
|
||||
|
||||
@app.post("/sources/{source_id}")
|
||||
async def update_source(request: Request, source_id: int):
|
||||
form = await request.form(); values = source_values(form)
|
||||
with connect() as db: db.execute(f"UPDATE sources SET {','.join(f'{key}=?' for key in values)} WHERE id=?", (*values.values(), source_id))
|
||||
return RedirectResponse(f"/sources/{source_id}", 303)
|
||||
|
||||
@app.get("/sources/{source_id}/events", response_class=HTMLResponse)
|
||||
def source_events(request: Request, source_id: int):
|
||||
with connect() as db:
|
||||
source = db.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
|
||||
events = db.execute("SELECT * FROM events WHERE source_id=? ORDER BY starts_at", (source_id,)).fetchall()
|
||||
if not source: raise HTTPException(404)
|
||||
return TEMPLATES.TemplateResponse(request, "source_events.html", {"source": source, "events": events})
|
||||
|
||||
@app.get("/sources/{source_id}/events/{event_id}", response_class=HTMLResponse)
|
||||
def edit_source_event(request: Request, source_id: int, event_id: int):
|
||||
with connect() as db:
|
||||
source = db.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
|
||||
event = db.execute("SELECT * FROM events WHERE id=? AND source_id=?", (event_id, source_id)).fetchone()
|
||||
if not source or not event: raise HTTPException(404)
|
||||
return TEMPLATES.TemplateResponse(request, "event_form.html", {"source": source, "event": event})
|
||||
|
||||
@app.post("/sources/{source_id}/events/{event_id}")
|
||||
async def update_source_event(request: Request, source_id: int, event_id: int):
|
||||
form = await request.form()
|
||||
values = (form.get("title", "").strip(), form.get("description", "").strip(), form.get("starts_at", "").strip(), form.get("ends_at", "").strip() or None, form.get("venue_name", "").strip(), form.get("address", "").strip(), float(form["latitude"]) if form.get("latitude") else None, float(form["longitude"]) if form.get("longitude") else None, form.get("event_url", "").strip(), event_id, source_id)
|
||||
with connect() as db:
|
||||
db.execute("UPDATE events SET title=?,description=?,starts_at=?,ends_at=?,venue_name=?,address=?,latitude=?,longitude=?,event_url=? WHERE id=? AND source_id=?", values)
|
||||
return RedirectResponse(f"/sources/{source_id}/events", 303)
|
||||
|
||||
@app.post("/sources/{source_id}/events/{event_id}/visibility")
|
||||
def toggle_event_visibility(source_id: int, event_id: int):
|
||||
with connect() as db:
|
||||
db.execute("UPDATE events SET hidden=CASE hidden WHEN 1 THEN 0 ELSE 1 END WHERE id=? AND source_id=?", (event_id, source_id))
|
||||
return RedirectResponse(f"/sources/{source_id}/events", 303)
|
||||
|
||||
@app.post("/sources/{source_id}/crawl")
|
||||
def run_crawl(source_id: int):
|
||||
try: crawl_source(source_id)
|
||||
except Exception: pass
|
||||
return RedirectResponse("/sources", 303)
|
||||
|
||||
@app.post("/sources/{source_id}/delete")
|
||||
def delete_source(source_id: int):
|
||||
with connect() as db: db.execute("DELETE FROM sources WHERE id=?", (source_id,))
|
||||
return RedirectResponse("/sources", 303)
|
||||
|
||||
def sanitize_picker_html(html: str, source_url: str) -> str:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup.select("script, iframe, frame, frameset, object, embed, form, input, button, textarea, select, base, meta[http-equiv]"):
|
||||
tag.decompose()
|
||||
for node in soup.find_all(True):
|
||||
for attribute in list(node.attrs):
|
||||
lowered = attribute.lower()
|
||||
if lowered.startswith("on") or lowered in {"srcdoc", "integrity", "nonce", "formaction"}:
|
||||
del node.attrs[attribute]
|
||||
for attribute in ("src", "href", "poster", "action"):
|
||||
value = node.get(attribute)
|
||||
if not value: continue
|
||||
value = str(value).strip()
|
||||
if value.lower().startswith(("javascript:", "data:text/html", "vbscript:")):
|
||||
del node.attrs[attribute]
|
||||
elif attribute in {"src", "poster"}:
|
||||
node.attrs[attribute] = urljoin(source_url, value)
|
||||
elif attribute == "href" and node.name == "link" and "stylesheet" in (node.get("rel") or []):
|
||||
node.attrs[attribute] = urljoin(source_url, value)
|
||||
elif attribute in {"href", "action"}:
|
||||
node.attrs[attribute] = "#"
|
||||
node.attrs.pop("target", None)
|
||||
head = soup.head
|
||||
if not head:
|
||||
head = soup.new_tag("head")
|
||||
if soup.html: soup.html.insert(0, head)
|
||||
else: soup.insert(0, head)
|
||||
style = soup.new_tag("style")
|
||||
style.string = "html,body{min-height:100%;background:white;color:#17211b} body{margin:12px} *{cursor:crosshair!important} img{max-width:100%;height:auto} .w2d-hover{outline:3px solid #dd5b32!important;outline-offset:2px!important}.w2d-picked{outline:4px solid #234c3c!important;outline-offset:2px!important}"
|
||||
head.append(style)
|
||||
script = soup.new_tag("script")
|
||||
script.string = PICKER_SCRIPT
|
||||
soup.body.append(script) if soup.body else soup.append(script)
|
||||
return str(soup)
|
||||
|
||||
PICKER_SCRIPT = r'''(() => {
|
||||
let hovered = null, picked = null;
|
||||
const esc = value => CSS.escape(value);
|
||||
function stableClasses(element) {
|
||||
return [...element.classList].filter(name => !name.startsWith('w2d-') && !/^elementor-element-[a-f0-9]+$/.test(name) && !/^post-\d+$/.test(name)).slice(0, 3);
|
||||
}
|
||||
function idPattern(id) {
|
||||
const match = id.match(/^(.*?[-_])\d{3,}$/);
|
||||
return match && match[1].length >= 3 ? `[id^="${CSS.escape(match[1])}"]` : `#${esc(id)}`;
|
||||
}
|
||||
function simpleSelector(element) {
|
||||
if (element.id) return idPattern(element.id);
|
||||
const classes = stableClasses(element);
|
||||
return element.tagName.toLowerCase() + classes.map(name => `.${esc(name)}`).join('');
|
||||
}
|
||||
function selectorFor(element) {
|
||||
if (element.id) return idPattern(element.id);
|
||||
const parts = [];
|
||||
let node = element;
|
||||
while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== 'html') {
|
||||
let part = node.tagName.toLowerCase();
|
||||
const classes = stableClasses(node).slice(0, 2);
|
||||
if (classes.length) part += classes.map(name => `.${esc(name)}`).join('');
|
||||
const parent = node.parentElement;
|
||||
if (parent && parent.querySelectorAll(`:scope > ${part}`).length > 1) {
|
||||
const siblings = [...parent.children].filter(child => child.matches(part));
|
||||
part += `:nth-of-type(${[...parent.children].indexOf(node) + 1})`;
|
||||
}
|
||||
parts.unshift(part);
|
||||
const candidate = parts.join(' > ');
|
||||
try { if (document.querySelectorAll(candidate).length === 1 || parts.length >= 4) return candidate; } catch (_) {}
|
||||
node = parent;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
function relativeSelector(element, container) {
|
||||
if (element === container) return ':scope';
|
||||
const parts = [];
|
||||
let node = element;
|
||||
while (node && node !== container) {
|
||||
let part = simpleSelector(node);
|
||||
if (!node.id) {
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
const matches = [...parent.children].filter(child => child.matches(part));
|
||||
if (matches.length > 1) part += `:nth-of-type(${[...parent.children].indexOf(node) + 1})`;
|
||||
}
|
||||
}
|
||||
parts.unshift(part); node = node.parentElement;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
function repeatedAncestor(element) {
|
||||
let node = element;
|
||||
let best = null;
|
||||
let depth = 0;
|
||||
while (node && node.tagName && node.tagName.toLowerCase() !== 'body') {
|
||||
const selector = simpleSelector(node);
|
||||
let count = 0;
|
||||
try { count = document.querySelectorAll(selector).length; } catch (_) {}
|
||||
if (count >= 2 && count <= 300) {
|
||||
const text = node.textContent.trim();
|
||||
const hasUsefulText = text.length >= 10;
|
||||
const score = (hasUsefulText ? 30 : 0) + Math.min(count, 20) - depth * 4 - (node.querySelectorAll('*').length > 300 ? 20 : 0);
|
||||
if (!best || score > best.score) best = { node, selector, count, score };
|
||||
}
|
||||
node = node.parentElement; depth += 1;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
document.addEventListener('mouseover', event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (hovered) hovered.classList.remove('w2d-hover');
|
||||
hovered = event.target; hovered.classList.add('w2d-hover');
|
||||
}, true);
|
||||
document.addEventListener('mouseout', event => { if (hovered) hovered.classList.remove('w2d-hover'); }, true);
|
||||
document.addEventListener('click', event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (picked) picked.classList.remove('w2d-picked');
|
||||
picked = event.target; picked.classList.add('w2d-picked');
|
||||
const selector = selectorFor(picked);
|
||||
const repeated = repeatedAncestor(picked);
|
||||
parent.postMessage({ type: 'what2do-selector', selector, selectorCount: document.querySelectorAll(selector).length, generalizedSelector: simpleSelector(picked), generalizedCount: document.querySelectorAll(simpleSelector(picked)).length, containerSelector: repeated?.selector || '', containerCount: repeated?.count || 0, relativeSelector: repeated ? relativeSelector(picked, repeated.node) : selector, tag: picked.tagName.toLowerCase(), text: picked.textContent.trim().slice(0, 160), href: picked.getAttribute('href') }, location.origin);
|
||||
}, true);
|
||||
})();'''
|
||||
|
||||
@app.post("/picker/snapshots")
|
||||
async def create_picker_snapshot(request: Request):
|
||||
form = await request.form()
|
||||
url = form.get("url", "").strip()
|
||||
try:
|
||||
sanitized = sanitize_picker_html(fetch_html(url), url)
|
||||
except Exception as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
token = secrets.token_urlsafe(24)
|
||||
now = datetime.now(timezone.utc)
|
||||
with PICKER_LOCK:
|
||||
expired = [key for key, value in PICKER_SNAPSHOTS.items() if now - value["created_at"] > PICKER_TTL]
|
||||
for key in expired: PICKER_SNAPSHOTS.pop(key, None)
|
||||
PICKER_SNAPSHOTS[token] = {"html": sanitized, "created_at": now}
|
||||
return {"preview_url": f"/picker/snapshots/{token}"}
|
||||
|
||||
@app.get("/picker/snapshots/{token}", response_class=HTMLResponse)
|
||||
def picker_snapshot(token: str):
|
||||
with PICKER_LOCK:
|
||||
snapshot = PICKER_SNAPSHOTS.get(token)
|
||||
if not snapshot or datetime.now(timezone.utc) - snapshot["created_at"] > PICKER_TTL:
|
||||
raise HTTPException(404, "Picker snapshot expired")
|
||||
return HTMLResponse(snapshot["html"], headers={"Content-Security-Policy": "default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline' https:; font-src https: data:; script-src 'unsafe-inline'; connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"})
|
||||
|
||||
@app.post("/auto-detect", response_class=HTMLResponse)
|
||||
async def auto_detect(request: Request):
|
||||
form = await request.form()
|
||||
url = form.get("url", "").strip()
|
||||
source = {
|
||||
"id": form.get("source_id"), "name": form.get("name", ""), "url": url,
|
||||
"venue_name": form.get("venue_name", ""), "venue_address": form.get("venue_address", ""),
|
||||
"latitude": form.get("latitude") or None, "longitude": form.get("longitude") or None,
|
||||
"timezone": form.get("timezone", "Australia/Sydney"), "enabled": 1,
|
||||
"crawl_interval_minutes": form.get("crawl_interval_minutes", 360),
|
||||
}
|
||||
try:
|
||||
html = fetch_html(url)
|
||||
rules, items, message = detect_rules(html, url, source["timezone"])
|
||||
strategy = {"mode": "html", "request_url": url, "method": "GET", "response_type": "html", **rules, "link_attribute": "href"}
|
||||
source["strategy_json"] = json.dumps(strategy, indent=2)
|
||||
with _http_client(timeout=45) as client:
|
||||
strategy_items = execute_strategy({**source, "strategy_json": source["strategy_json"]}, client)
|
||||
if strategy_items:
|
||||
items = strategy_items
|
||||
inferred = infer_page_fields(html, url)
|
||||
for key, value in inferred.items():
|
||||
if value and not source.get(key): source[key] = value
|
||||
source.update(rules)
|
||||
if source.get("venue_address") and not source.get("latitude"):
|
||||
point = geocode(source["venue_address"])
|
||||
if point: source["latitude"], source["longitude"] = point[:2]
|
||||
preview_data = [{"title": item.title, "starts_at": item.starts_at.isoformat(), "ends_at": item.ends_at.isoformat() if item.ends_at else None, "url": item.url} for item in items[:20]]
|
||||
error = None
|
||||
except Exception as exc:
|
||||
source.update({"item_selector": "", "title_selector": "", "date_selector": "", "time_selector": "", "link_selector": "", "description_selector": "", "location_selector": "", "date_format": "", "recurring_weekly": 0, "strategy_json": "{}"})
|
||||
preview_data, message, error = [], None, str(exc)
|
||||
action = f"/sources/{source['id']}" if source.get("id") else "/sources"
|
||||
return TEMPLATES.TemplateResponse(request, "source_form.html", {"source": source, "action": action, "preview": preview_data, "preview_error": error, "detect_message": message})
|
||||
|
||||
@app.post("/preview", response_class=HTMLResponse)
|
||||
async def preview(request: Request):
|
||||
form = await request.form(); values = source_values(form); values["url"] = form.get("url", "")
|
||||
try:
|
||||
with _http_client(timeout=45) as client:
|
||||
items = execute_strategy(values, client)[:20]
|
||||
preview_data = [{"title": item.title, "starts_at": item.starts_at.isoformat(), "ends_at": item.ends_at.isoformat() if item.ends_at else None, "url": item.url} for item in items]
|
||||
error = None
|
||||
except Exception as exc: preview_data, error = [], str(exc)
|
||||
source_id = form.get("source_id")
|
||||
return TEMPLATES.TemplateResponse(request, "source_form.html", {"source": values, "action": f"/sources/{source_id}" if source_id else "/sources", "preview": preview_data, "preview_error": error})
|
||||
132
app/static/app.js
Normal file
132
app/static/app.js
Normal file
@ -0,0 +1,132 @@
|
||||
const locationInput = document.querySelector('#location-input');
|
||||
const latitudeInput = document.querySelector('#latitude-input');
|
||||
const longitudeInput = document.querySelector('#longitude-input');
|
||||
const options = document.querySelector('#location-options');
|
||||
let locationTimer;
|
||||
|
||||
if (locationInput) {
|
||||
locationInput.addEventListener('input', () => {
|
||||
latitudeInput.value = '';
|
||||
longitudeInput.value = '';
|
||||
clearTimeout(locationTimer);
|
||||
const query = locationInput.value.trim();
|
||||
if (query.length < 3) { options.hidden = true; return; }
|
||||
locationTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/locations?q=${encodeURIComponent(query)}`);
|
||||
const places = await response.json();
|
||||
options.replaceChildren();
|
||||
places.forEach(place => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = place.label;
|
||||
button.addEventListener('click', () => {
|
||||
locationInput.value = place.label;
|
||||
latitudeInput.value = place.latitude;
|
||||
longitudeInput.value = place.longitude;
|
||||
options.hidden = true;
|
||||
});
|
||||
options.append(button);
|
||||
});
|
||||
if (places.length === 0) {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'location-message';
|
||||
message.textContent = 'No geographic locations found. Try a suburb, town, or postcode.';
|
||||
options.append(message);
|
||||
}
|
||||
options.hidden = false;
|
||||
} catch (_) { options.hidden = true; }
|
||||
}, 300);
|
||||
});
|
||||
document.addEventListener('click', event => { if (!event.target.closest('.location-field')) options.hidden = true; });
|
||||
}
|
||||
|
||||
const tagToggle = document.querySelector('#tag-toggle');
|
||||
const tagOptions = document.querySelector('#tag-options');
|
||||
if (tagToggle && tagOptions) {
|
||||
tagToggle.addEventListener('click', () => { tagOptions.hidden = !tagOptions.hidden; });
|
||||
tagOptions.addEventListener('change', () => {
|
||||
const count = tagOptions.querySelectorAll('input:checked').length;
|
||||
tagToggle.textContent = count ? `${count} selected` : 'All types';
|
||||
});
|
||||
document.addEventListener('click', event => {
|
||||
if (!event.target.closest('.tag-field')) tagOptions.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
let map;
|
||||
tabs.forEach(tab => tab.addEventListener('click', () => {
|
||||
tabs.forEach(item => item.classList.toggle('active', item === tab));
|
||||
document.querySelectorAll('.view-panel').forEach(panel => panel.classList.toggle('active', panel.id === tab.dataset.view));
|
||||
if (tab.dataset.view === 'map-view') initialiseMap();
|
||||
}));
|
||||
|
||||
const eventRows = [...document.querySelectorAll('[data-event-start]')];
|
||||
const visibleCount = document.querySelector('#visible-event-count');
|
||||
const clearDay = document.querySelector('#clear-day');
|
||||
const dayEmpty = document.querySelector('#day-empty');
|
||||
const rangeStatus = document.querySelector('#range-status');
|
||||
let rangeStart = '';
|
||||
let rangeEnd = '';
|
||||
|
||||
function formatDay(dayKey) {
|
||||
return new Date(`${dayKey}T00:00:00`).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
function filterToRange(startDay, endDay = startDay) {
|
||||
let count = 0;
|
||||
eventRows.forEach(row => {
|
||||
row.hidden = Boolean(startDay) && (row.dataset.eventStart > endDay || row.dataset.eventEnd < startDay);
|
||||
if (!row.hidden) count += 1;
|
||||
});
|
||||
document.querySelectorAll('.calendar-day').forEach(day => {
|
||||
const inRange = Boolean(startDay) && day.dataset.day >= startDay && day.dataset.day <= endDay;
|
||||
day.classList.toggle('selected', inRange);
|
||||
day.classList.toggle('range-start', day.dataset.day === startDay);
|
||||
day.classList.toggle('range-end', day.dataset.day === endDay && endDay !== startDay);
|
||||
});
|
||||
if (visibleCount) visibleCount.textContent = count;
|
||||
if (clearDay) clearDay.hidden = !startDay;
|
||||
if (dayEmpty) dayEmpty.hidden = count !== 0;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.calendar-day').forEach(day => day.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
const clicked = day.dataset.day;
|
||||
if (!rangeStart || rangeEnd) {
|
||||
rangeStart = clicked;
|
||||
rangeEnd = '';
|
||||
filterToRange(rangeStart);
|
||||
if (rangeStatus) rangeStatus.textContent = `${formatDay(rangeStart)} selected · choose an end date`;
|
||||
} else {
|
||||
rangeEnd = clicked;
|
||||
if (rangeEnd < rangeStart) [rangeStart, rangeEnd] = [rangeEnd, rangeStart];
|
||||
filterToRange(rangeStart, rangeEnd);
|
||||
if (rangeStatus) rangeStatus.textContent = `${formatDay(rangeStart)} – ${formatDay(rangeEnd)}`;
|
||||
document.querySelector('#event-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}));
|
||||
if (clearDay) clearDay.addEventListener('click', event => {
|
||||
event.preventDefault(); rangeStart = ''; rangeEnd = ''; filterToRange('');
|
||||
if (rangeStatus) rangeStatus.textContent = 'Select a start date';
|
||||
});
|
||||
|
||||
function initialiseMap() {
|
||||
if (map || !window.maplibregl) return;
|
||||
const events = JSON.parse(document.querySelector('#map-events').textContent);
|
||||
const center = events.length ? [events[0].longitude, events[0].latitude] : [150.8931, -34.4278];
|
||||
map = new maplibregl.Map({ container: 'events-map', style: { version: 8, sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors' } }, layers: [{ id: 'osm', type: 'raster', source: 'osm' }] }, center, zoom: 12 });
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
events.forEach(event => {
|
||||
const popup = new maplibregl.Popup({ offset: 20 }).setHTML(`<strong>${escapeHtml(event.title)}</strong><br>${escapeHtml(event.venue)}<br><a href="/events/${event.id}">View event</a>`);
|
||||
new maplibregl.Marker({ color: '#dd5b32' }).setLngLat([event.longitude, event.latitude]).setPopup(popup).addTo(map);
|
||||
bounds.extend([event.longitude, event.latitude]);
|
||||
});
|
||||
if (events.length > 1) map.fitBounds(bounds, { padding: 60, maxZoom: 14 });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
const node = document.createElement('div'); node.textContent = value; return node.innerHTML;
|
||||
}
|
||||
16
app/static/event.js
Normal file
16
app/static/event.js
Normal file
@ -0,0 +1,16 @@
|
||||
const mapNode = document.querySelector('#event-map');
|
||||
if (mapNode && window.maplibregl) {
|
||||
const longitude = Number(mapNode.dataset.longitude);
|
||||
const latitude = Number(mapNode.dataset.latitude);
|
||||
const map = new maplibregl.Map({ container: mapNode, style: { version: 8, sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors' } }, layers: [{ id: 'osm', type: 'raster', source: 'osm' }] }, center: [longitude, latitude], zoom: 15 });
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
new maplibregl.Marker({ color: '#dd5b32' }).setLngLat([longitude, latitude]).setPopup(new maplibregl.Popup({ offset: 20 }).setText(mapNode.dataset.title)).addTo(map);
|
||||
}
|
||||
|
||||
const shareButton = document.querySelector('#share-button');
|
||||
if (shareButton) shareButton.addEventListener('click', async () => {
|
||||
try {
|
||||
if (navigator.share) await navigator.share({ title: document.title, url: location.href });
|
||||
else { await navigator.clipboard.writeText(location.href); shareButton.textContent = 'Link copied'; }
|
||||
} catch (_) {}
|
||||
});
|
||||
118
app/static/source.js
Normal file
118
app/static/source.js
Normal file
@ -0,0 +1,118 @@
|
||||
const pickerFrame = document.querySelector('#selector-picker');
|
||||
const loadPicker = document.querySelector('#load-picker');
|
||||
const pickerStatus = document.querySelector('#picker-status');
|
||||
const pickerSelection = document.querySelector('#picker-selection');
|
||||
const pickedSelector = document.querySelector('#picked-selector');
|
||||
const pickedText = document.querySelector('#picked-text');
|
||||
const pickedCount = document.querySelector('#picked-count');
|
||||
const pickedContainer = document.querySelector('#picked-container');
|
||||
const pickedRelative = document.querySelector('#picked-relative');
|
||||
const pickerTarget = document.querySelector('#picker-target');
|
||||
const applySelector = document.querySelector('#apply-selector');
|
||||
const applyPattern = document.querySelector('#apply-pattern');
|
||||
let currentSelection = null;
|
||||
|
||||
if (loadPicker && pickerFrame) loadPicker.addEventListener('click', async () => {
|
||||
const url = document.querySelector('input[name="url"]')?.value.trim();
|
||||
if (!url) { pickerStatus.textContent = 'Enter a page URL first.'; return; }
|
||||
loadPicker.disabled = true; pickerStatus.textContent = 'Downloading and sanitizing page…';
|
||||
try {
|
||||
const body = new FormData(); body.append('url', url);
|
||||
const response = await fetch('/picker/snapshots', { method: 'POST', body });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Unable to create preview');
|
||||
pickerFrame.src = result.preview_url; pickerFrame.hidden = false;
|
||||
pickerStatus.textContent = 'Hover to highlight, then click an element.';
|
||||
} catch (error) { pickerStatus.textContent = error.message; }
|
||||
finally { loadPicker.disabled = false; }
|
||||
});
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
if (!pickerFrame || event.source !== pickerFrame.contentWindow || event.data?.type !== 'what2do-selector') return;
|
||||
currentSelection = event.data;
|
||||
pickedSelector.textContent = event.data.selector;
|
||||
pickedCount.textContent = `${event.data.selectorCount} exact match${event.data.selectorCount === 1 ? '' : 'es'}; ${event.data.generalizedCount} similar elements`;
|
||||
pickedContainer.textContent = event.data.containerSelector || 'No repeated ancestor detected';
|
||||
pickedRelative.textContent = event.data.containerSelector ? `${event.data.containerCount} containers · child: ${event.data.relativeSelector}` : '';
|
||||
pickedText.textContent = event.data.text || event.data.tag;
|
||||
applyPattern.disabled = !event.data.containerSelector;
|
||||
pickerSelection.hidden = false;
|
||||
});
|
||||
|
||||
function updateSelectorField(target, selector) {
|
||||
const input = document.querySelector(`[name="${target}"]`);
|
||||
if (input) input.value = selector;
|
||||
try {
|
||||
const strategy = JSON.parse(strategyJson.value || '{}');
|
||||
if ((strategy.mode || 'html') === 'html') {
|
||||
strategy[target] = selector;
|
||||
strategyJson.value = JSON.stringify(strategy, null, 2);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (applySelector) applySelector.addEventListener('click', () => {
|
||||
if (!currentSelection) return;
|
||||
updateSelectorField(pickerTarget.value, currentSelection.selector);
|
||||
pickerStatus.textContent = `Applied exact selector to ${pickerTarget.options[pickerTarget.selectedIndex].text}.`;
|
||||
});
|
||||
|
||||
if (applyPattern) applyPattern.addEventListener('click', () => {
|
||||
if (!currentSelection?.containerSelector) return;
|
||||
const target = pickerTarget.value;
|
||||
updateSelectorField('item_selector', currentSelection.containerSelector);
|
||||
updateSelectorField(target, target === 'item_selector' ? currentSelection.containerSelector : currentSelection.relativeSelector);
|
||||
pickerStatus.textContent = `Matched ${currentSelection.containerCount} event containers and applied a relative ${pickerTarget.options[pickerTarget.selectedIndex].text.toLowerCase()} selector.`;
|
||||
});
|
||||
|
||||
const strategyTemplates = {
|
||||
html: { mode: 'html', request_url: 'https://example.org/events', method: 'GET', item_selector: '.event-card', title_selector: '.title', date_selector: '.date', time_selector: '.time', link_selector: 'a', link_attribute: 'href', description_selector: '.description', location_selector: '.venue', date_format: '' },
|
||||
json: { mode: 'json', request_url: 'https://example.org/api/events', method: 'GET', items_path: 'results', title_path: 'title', date_path: 'startDate', end_path: 'endDate', description_path: 'description', location_path: 'location.address', link_path: 'url', disabled_path: 'disabled' },
|
||||
json_join: { mode: 'json', request_url: 'https://example.org/api/sessions', method: 'GET', items_path: '', date_path: 'startDate', link_path: 'url', join_item_key: 'itemId', join_title_path: 'name', join_description_path: 'description', join_duration_path: 'duration', join: { request_url: 'https://example.org/api/items', method: 'GET', items_path: '', key_path: 'id' } },
|
||||
html_fragment: { mode: 'html_fragment', request_url: 'https://example.org/ajax', method: 'POST', body: { action: 'events' }, html_path: 'html', item_selector: '.event-card', title_selector: '.title', date_selector: '.date', link_selector: 'a', link_attribute: 'href', page_parameter: 'page', page_start: 1, max_pages: 10, total_pages_path: 'pages_total' }
|
||||
};
|
||||
const strategyTemplate = document.querySelector('#strategy-template');
|
||||
const strategyJson = document.querySelector('#strategy-json');
|
||||
if (strategyTemplate && strategyJson) strategyTemplate.addEventListener('change', () => {
|
||||
const template = strategyTemplates[strategyTemplate.value];
|
||||
if (template) strategyJson.value = JSON.stringify(template, null, 2);
|
||||
});
|
||||
|
||||
const addressInput = document.querySelector('#venue-address');
|
||||
const addressOptions = document.querySelector('#address-options');
|
||||
const latitudeInput = document.querySelector('#venue-latitude');
|
||||
const longitudeInput = document.querySelector('#venue-longitude');
|
||||
let addressTimer;
|
||||
|
||||
if (addressInput && addressOptions) {
|
||||
addressInput.addEventListener('input', () => {
|
||||
clearTimeout(addressTimer);
|
||||
latitudeInput.value = '';
|
||||
longitudeInput.value = '';
|
||||
const query = addressInput.value.trim();
|
||||
if (query.length < 3) { addressOptions.hidden = true; return; }
|
||||
addressTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/addresses?q=${encodeURIComponent(query)}`);
|
||||
const addresses = await response.json();
|
||||
addressOptions.replaceChildren();
|
||||
addresses.forEach(address => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = address.label;
|
||||
button.addEventListener('click', () => {
|
||||
addressInput.value = address.label;
|
||||
latitudeInput.value = address.latitude;
|
||||
longitudeInput.value = address.longitude;
|
||||
addressOptions.hidden = true;
|
||||
});
|
||||
addressOptions.append(button);
|
||||
});
|
||||
addressOptions.hidden = addresses.length === 0;
|
||||
} catch (_) { addressOptions.hidden = true; }
|
||||
}, 300);
|
||||
});
|
||||
document.addEventListener('click', event => {
|
||||
if (!event.target.closest('.address-field')) addressOptions.hidden = true;
|
||||
});
|
||||
}
|
||||
1
app/static/style.css
Normal file
1
app/static/style.css
Normal file
File diff suppressed because one or more lines are too long
164
app/strategies.py
Normal file
164
app/strategies.py
Normal file
@ -0,0 +1,164 @@
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urljoin
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .crawler import BROWSER_HEADERS, CrawlError, ExtractedEvent, _extract_json_ld, _parse_date_range, _weekly_dates, validate_public_url
|
||||
|
||||
|
||||
def parse_strategy(value) -> dict:
|
||||
if isinstance(value, dict):
|
||||
return deepcopy(value)
|
||||
try:
|
||||
return json.loads(value or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def json_path(value, path: str, default=None):
|
||||
if not path:
|
||||
return value
|
||||
current = value
|
||||
for component in path.split("."):
|
||||
if current is None:
|
||||
return default
|
||||
if isinstance(current, list):
|
||||
try: current = current[int(component)]
|
||||
except (ValueError, IndexError): return default
|
||||
elif isinstance(current, dict):
|
||||
current = current.get(component, default)
|
||||
else:
|
||||
return default
|
||||
return current
|
||||
|
||||
|
||||
def _value(node, selector: str, attribute: str = "") -> str:
|
||||
if not selector:
|
||||
return ""
|
||||
target = node if selector == ":scope" else node.select_one(selector)
|
||||
if not target:
|
||||
return ""
|
||||
if attribute:
|
||||
return str(target.get(attribute, ""))
|
||||
return target.get_text(" ", strip=True)
|
||||
|
||||
|
||||
def _request(client, strategy: dict, page: int | None = None):
|
||||
url = str(strategy.get("request_url") or strategy.get("url") or "")
|
||||
validate_public_url(url)
|
||||
method = strategy.get("method", "GET").upper()
|
||||
params = deepcopy(strategy.get("params") or {})
|
||||
body = deepcopy(strategy.get("body") or {})
|
||||
page_parameter = strategy.get("page_parameter", "")
|
||||
if page is not None and page_parameter:
|
||||
(params if method == "GET" else body)[page_parameter] = str(page)
|
||||
headers = {**BROWSER_HEADERS, **(strategy.get("headers") or {})}
|
||||
response = client.request(method, url, params=params, data=body if method != "GET" else None, headers=headers)
|
||||
response.raise_for_status()
|
||||
if len(response.content) > int(strategy.get("max_bytes", 5_000_000)):
|
||||
raise CrawlError("Strategy response exceeds configured size limit.")
|
||||
return response
|
||||
|
||||
|
||||
def _events_from_html(html: str, strategy: dict, source: dict) -> list[ExtractedEvent]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
now = datetime.now(ZoneInfo(source.get("timezone") or "UTC"))
|
||||
if strategy.get("item_selector") == "json-ld":
|
||||
return _extract_json_ld(soup, source["url"], source, now)
|
||||
events = []
|
||||
for item in soup.select(strategy.get("item_selector", "")):
|
||||
title = _value(item, strategy.get("title_selector", ""), strategy.get("title_attribute", ""))
|
||||
raw_date = _value(item, strategy.get("date_selector", ""), strategy.get("date_attribute", ""))
|
||||
for old, new in (strategy.get("date_replacements") or {}).items(): raw_date = raw_date.replace(old, new)
|
||||
raw_time = _value(item, strategy.get("time_selector", ""), strategy.get("time_attribute", ""))
|
||||
if not title or not raw_date: continue
|
||||
config = {**source, "date_format": strategy.get("date_format", "")}
|
||||
if strategy.get("recurring_weekly"):
|
||||
for start in _weekly_dates(raw_date, now):
|
||||
events.append(ExtractedEvent(title=title, starts_at=start, description=_value(item, strategy.get("description_selector", ""), strategy.get("description_attribute", "")), url=urljoin(source["url"], _value(item, strategy.get("link_selector", ""), strategy.get("link_attribute", "href"))), address=_value(item, strategy.get("location_selector", ""), strategy.get("location_attribute", ""))))
|
||||
continue
|
||||
try: start, end = _parse_date_range(f"{raw_date} {raw_time}".strip(), config, now)
|
||||
except (ValueError, OverflowError): continue
|
||||
duration = strategy.get("duration_minutes")
|
||||
if duration and not end: end = start + timedelta(minutes=int(duration))
|
||||
link = _value(item, strategy.get("link_selector", ""), strategy.get("link_attribute", "href"))
|
||||
description = _value(item, strategy.get("description_selector", ""), strategy.get("description_attribute", ""))
|
||||
location = _value(item, strategy.get("location_selector", ""), strategy.get("location_attribute", ""))
|
||||
events.append(ExtractedEvent(title=title, starts_at=start, ends_at=end, description=description, url=urljoin(source["url"], link), address=location))
|
||||
return events
|
||||
|
||||
|
||||
def _index_join(items: list, join_items: list, item_key: str, join_key: str) -> dict:
|
||||
index = {}
|
||||
for item in join_items:
|
||||
keys = json_path(item, join_key, [])
|
||||
if not isinstance(keys, list): keys = [keys]
|
||||
for key in keys:
|
||||
if key is not None: index[str(key)] = item
|
||||
return index
|
||||
|
||||
|
||||
def _events_from_json(payload, strategy: dict, source: dict, join_index: dict | None = None) -> list[ExtractedEvent]:
|
||||
items = json_path(payload, strategy.get("items_path", ""), [])
|
||||
if not isinstance(items, list): return []
|
||||
now = datetime.now(ZoneInfo(source.get("timezone") or "UTC"))
|
||||
events = []
|
||||
for item in items:
|
||||
if strategy.get("disabled_path") and json_path(item, strategy["disabled_path"]): continue
|
||||
joined = join_index.get(str(json_path(item, strategy.get("join_item_key", "")))) if join_index else None
|
||||
def field(name, default=""):
|
||||
item_path = strategy.get(f"{name}_path", "")
|
||||
join_path = strategy.get(f"join_{name}_path", "")
|
||||
value = json_path(item, item_path, None) if item_path else None
|
||||
if (value is None or value == "") and joined is not None and join_path:
|
||||
value = json_path(joined, join_path, default)
|
||||
return value if value is not None else default
|
||||
title = str(field("title") or strategy.get("title_fallback", "Untitled event"))
|
||||
date_value = str(field("date"))
|
||||
if not date_value: continue
|
||||
try: start, end = _parse_date_range(date_value, source, now)
|
||||
except (ValueError, OverflowError): continue
|
||||
end_value = field("end")
|
||||
if end_value:
|
||||
try: end, _ = _parse_date_range(str(end_value), source, now)
|
||||
except (ValueError, OverflowError): pass
|
||||
duration = field("duration") or strategy.get("duration_minutes")
|
||||
if duration and not end: end = start + timedelta(minutes=int(duration))
|
||||
events.append(ExtractedEvent(title=title, starts_at=start, ends_at=end, description=str(field("description")), url=urljoin(source["url"], str(field("link"))), address=str(field("location"))))
|
||||
return events
|
||||
|
||||
|
||||
def execute_strategy(source: dict, client) -> list[ExtractedEvent]:
|
||||
strategy = parse_strategy(source.get("strategy_json"))
|
||||
mode = strategy.get("mode", "html")
|
||||
if mode == "html":
|
||||
response = _request(client, strategy)
|
||||
return _events_from_html(response.text, strategy, source)
|
||||
if mode not in {"json", "html_fragment"}:
|
||||
raise CrawlError(f"Unsupported extraction mode: {mode}")
|
||||
join_index = None
|
||||
join = strategy.get("join")
|
||||
if join:
|
||||
join_response = _request(client, join)
|
||||
join_payload = join_response.json()
|
||||
join_items = json_path(join_payload, join.get("items_path", ""), [])
|
||||
join_index = _index_join([], join_items if isinstance(join_items, list) else [], strategy.get("join_item_key", ""), join.get("key_path", ""))
|
||||
events = []
|
||||
page = int(strategy.get("page_start", 1))
|
||||
max_pages = min(int(strategy.get("max_pages", 1)), 20)
|
||||
for _ in range(max_pages):
|
||||
response = _request(client, strategy, page)
|
||||
payload = response.json()
|
||||
if mode == "html_fragment":
|
||||
fragment = json_path(payload, strategy.get("html_path", "html"), "")
|
||||
page_events = _events_from_html(str(fragment), strategy, source)
|
||||
else:
|
||||
page_events = _events_from_json(payload, strategy, source, join_index)
|
||||
events.extend(page_events)
|
||||
total_pages = json_path(payload, strategy.get("total_pages_path", ""), None)
|
||||
if not strategy.get("page_parameter") or not page_events or (total_pages is not None and page >= int(total_pages)): break
|
||||
page += 1
|
||||
return events
|
||||
5
app/templates/base.html
Normal file
5
app/templates/base.html
Normal file
@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{% block title %}What2Do{% endblock %}</title><link rel="stylesheet" href="/static/style.css">{% block head %}{% endblock %}</head>
|
||||
<body><header><a class="brand" href="/">What2Do</a><nav><a href="/">Find events</a><a href="/sources">Crawler sources</a></nav></header>
|
||||
<main>{% block content %}{% endblock %}</main><footer>Local-first event discovery · No AI, tracking, or proprietary map dependency</footer>{% block scripts %}{% endblock %}</body></html>
|
||||
22
app/templates/event.html
Normal file
22
app/templates/event.html
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ event.title }} · What2Do{% endblock %}
|
||||
{% block head %}<meta name="description" content="{{ event.title }} at {{ event.venue_name }}"><meta property="og:title" content="{{ event.title }}"><meta property="og:description" content="{{ event.display_date }} at {{ event.display_time }} · {{ event.venue_name }}"><link href="https://unpkg.com/maplibre-gl@5.7.1/dist/maplibre-gl.css" rel="stylesheet">{% endblock %}
|
||||
{% block content %}
|
||||
<a href="/" class="back-link">← All events</a>
|
||||
<article class="event-detail">
|
||||
<div>
|
||||
<div class="eyebrow">{{ event.display_date }}</div>
|
||||
<div class="detail-title">{{ event.title }}</div>
|
||||
<div class="detail-time">{{ event.display_time }}{% if event.display_end %} – {{ event.display_end }}{% endif %}</div>
|
||||
{% if event.description %}<div class="description">{{ event.description }}</div>{% endif %}
|
||||
<div class="detail-card card">
|
||||
<div class="detail-field"><div class="detail-label">Venue</div><div class="detail-value">{{ event.venue_name }}</div></div>
|
||||
<div class="detail-field"><div class="detail-label">Address</div><div class="detail-value">{{ event.address }}</div></div>
|
||||
<div class="detail-field"><div class="detail-label">Source</div><div class="detail-value"><a href="{{ event.event_url }}" target="_blank" rel="noopener">Original event page ↗</a></div></div>
|
||||
</div>
|
||||
<div class="detail-actions"><button type="button" id="share-button">Share event</button><a class="button secondary" href="{{ event.event_url }}" target="_blank" rel="noopener">Visit source</a></div>
|
||||
</div>
|
||||
<div>{% if event.latitude is not none %}<div id="event-map" class="detail-map card" data-latitude="{{ event.latitude }}" data-longitude="{{ event.longitude }}" data-title="{{ event.venue_name }}"></div>{% else %}<div class="empty card">No map coordinates are available for this event.</div>{% endif %}</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="https://unpkg.com/maplibre-gl@5.7.1/dist/maplibre-gl.js"></script><script src="/static/event.js"></script>{% endblock %}
|
||||
6
app/templates/event_form.html
Normal file
6
app/templates/event_form.html
Normal file
@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit event · What2Do{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head"><div><div class="eyebrow">EDIT NORMALIZED EVENT</div><div class="form-title">{{ event.title }}</div><div class="muted">Changes may be overwritten on the next crawl if the source still publishes different values.</div></div><a href="/sources/{{ source.id }}/events">← Source events</a></div>
|
||||
<form class="card event-admin-form" method="post" action="/sources/{{ source.id }}/events/{{ event.id }}"><div class="field">Title<input required name="title" value="{{ event.title }}"></div><div class="field">Description<textarea name="description" rows="6">{{ event.description }}</textarea></div><div class="two"><div class="field">Starts at<input required name="starts_at" value="{{ event.starts_at }}"></div><div class="field">Ends at<input name="ends_at" value="{{ event.ends_at or '' }}"></div></div><div class="two"><div class="field">Venue name<input required name="venue_name" value="{{ event.venue_name }}"></div><div class="field">Address<input required name="address" value="{{ event.address }}"></div></div><div class="two"><div class="field">Latitude<input type="number" step="any" name="latitude" value="{{ event.latitude if event.latitude is not none else '' }}"></div><div class="field">Longitude<input type="number" step="any" name="longitude" value="{{ event.longitude if event.longitude is not none else '' }}"></div></div><div class="field">Event URL<input required type="url" name="event_url" value="{{ event.event_url }}"></div><div class="sticky-actions"><input type="submit" class="primary" value="Save event"></div></form>
|
||||
{% endblock %}
|
||||
31
app/templates/index.html
Normal file
31
app/templates/index.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Find events · What2Do{% endblock %}
|
||||
{% block head %}<link href="https://unpkg.com/maplibre-gl@5.7.1/dist/maplibre-gl.css" rel="stylesheet">{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero"><div class="eyebrow">LOCAL EVENT INDEX</div><div class="hero-title">Find something worth going out for.</div><div class="hero-copy">Search events gathered from local venue websites.</div></section>
|
||||
<form class="search search-filters card" method="get" id="search-form">
|
||||
<div class="field location-field">Near<input id="location-input" name="location" value="{{ location }}" placeholder="Start typing a suburb or address" autocomplete="off"><input type="hidden" id="latitude-input" name="latitude" value="{{ latitude if latitude is not none else '' }}"><input type="hidden" id="longitude-input" name="longitude" value="{{ longitude if longitude is not none else '' }}"><div id="location-options" class="location-options" hidden></div></div>
|
||||
<div class="field">Search term<input name="term" value="{{ term }}" placeholder="Band, venue, activity..."></div>
|
||||
<div class="field tag-field">Event types<button type="button" id="tag-toggle" class="tag-toggle">{{ selected_tags|length ~ ' selected' if selected_tags else 'All types' }}</button><div id="tag-options" class="tag-options" hidden>{% for option in event_tags %}<label class="tag-option"><input type="checkbox" name="tag" value="{{ option }}" {% if option in selected_tags %}checked{% endif %}><span>{{ option|title }}</span></label>{% endfor %}<label class="tag-option"><input type="checkbox" name="tag" value="other" {% if 'other' in selected_tags %}checked{% endif %}><span></span>Other</span></label></div></div>
|
||||
<div class="field range-field">Within<input type="number" min="1" max="500" name="radius" value="{{ radius }}"><span class="suffix">km</span></div>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
{% if location and not geocoded %}<div class="notice error">That location could not be resolved. Select an autocomplete suggestion to apply distance filtering.</div>{% endif %}
|
||||
<div class="section-head"><div><div class="eyebrow">WHAT'S ON</div><div class="section-title"><span id="visible-event-count">{{ events|length }}</span> events</div></div><a class="button secondary" href="/sources/new">+ Add source</a></div>
|
||||
<div class="view-tabs"><button type="button" class="tab active" data-view="list-view">List</button><button type="button" class="tab" data-view="map-view">Map</button></div>
|
||||
<section id="list-view" class="view-panel active">
|
||||
<div class="date-calendar card">
|
||||
<div class="calendar-heading">Choose a range · {{ calendar_month }}</div>
|
||||
<div class="calendar-tools"><span id="range-status">Select a start date</span><button type="button" id="clear-day" class="clear-day" hidden>Show all</button></div>
|
||||
<div class="weekdays"><input type="button" value="Mon" disabled><input type="button" value="Tue" disabled><input type="button" value="Wed" disabled><input type="button" value="Thu" disabled><input type="button" value="Fri" disabled><input type="button" value="Sat" disabled><input type="button" value="Sun" disabled></div>
|
||||
<div class="calendar-grid">{% for day in calendar_days %}<button type="button" class="calendar-day {{ '' if day.in_month else 'outside' }} {{ 'has-events' if day.count else '' }}" data-day="{{ day.key }}"><span>{{ day.date.day }}</span>{% if day.count %}<b>{{ day.count }}</b>{% endif %}</button>{% endfor %}</div>
|
||||
</div>
|
||||
<div id="event-list" class="day-events">
|
||||
{% for event in events %}<a class="event-row card" href="/events/{{ event.id }}" data-event-start="{{ event.day_key }}" data-event-end="{{ event.end_day_key }}"><div><div class="date">{{ event.display_time }}</div><div class="event-title">{{ event.title }}</div><div class="muted">{{ event.venue_name }} · {{ event.address }}</div><div class="tag-list">{% for event_tag in event.tags %}<span class="pill">{{ event_tag }}</span>{% endfor %}</div></div><div class="event-link-label">View →</div></a>{% else %}<div class="empty card">No events match these filters.</div>{% endfor %}
|
||||
<div id="day-empty" class="empty card" hidden>No events occur on this day.</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="map-view" class="view-panel"><div id="events-map" class="map card"></div></section>
|
||||
<script id="map-events" type="application/json">{{ map_events|tojson }}</script>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="https://unpkg.com/maplibre-gl@5.7.1/dist/maplibre-gl.js"></script><script src="/static/app.js"></script>{% endblock %}
|
||||
6
app/templates/source_events.html
Normal file
6
app/templates/source_events.html
Normal file
@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ source.name }} events · What2Do{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head"><div><div class="eyebrow">SOURCE EVENTS</div><div class="form-title">{{ source.name }}</div><div class="muted">Edit normalized events or hide them from public search and maps.</div></div><a href="/sources">← Sources</a></div>
|
||||
<div class="admin-events">{% for event in events %}<article class="card admin-event {{ 'is-hidden' if event.hidden else '' }}"><div><div class="date">{{ event.starts_at }}{% if event.ends_at %} → {{ event.ends_at }}{% endif %}</div><h3>{{ event.title }}</h3><div class="muted">{{ event.venue_name }} · {{ event.address }}</div>{% if event.hidden %}<span class="pill">Hidden</span>{% endif %}</div><div class="actions"><a class="button secondary" href="/sources/{{ source.id }}/events/{{ event.id }}">Edit</a><form method="post" action="/sources/{{ source.id }}/events/{{ event.id }}/visibility"><button>{{ 'Show' if event.hidden else 'Hide' }}</button></form><a class="button secondary" href="/events/{{ event.id }}">Public page</a></div></article>{% else %}<div class="empty card">No events have been crawled for this source.</div>{% endfor %}</div>
|
||||
{% endblock %}
|
||||
27
app/templates/source_form.html
Normal file
27
app/templates/source_form.html
Normal file
@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Configure source · What2Do{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head"><div><div class="eyebrow">SOURCE CONFIGURATION</div><div class="form-title">Configure website</div><div class="muted">Auto-detect standard HTML or configure a reusable extraction strategy.</div></div><a href="/sources">← Sources</a></div>
|
||||
{% if detect_message %}<div class="notice success">{{ detect_message }} Review the strategy below before saving.</div>{% endif %}
|
||||
{% if preview_error %}<div class="notice error">Detection or preview failed: {{ preview_error }}</div>{% endif %}
|
||||
<form class="config-grid" method="post" action="{{ action }}">
|
||||
<section class="card form-section"><div class="form-heading">Website and venue</div>
|
||||
<div class="field">Source name<input required name="name" value="{{ source['name'] or '' }}"></div>
|
||||
<div class="field">Page URL<div class="url-detect-row"><input required type="url" name="url" value="{{ source['url'] or '' }}" placeholder="https://venue.example/events"><input class="detect-submit" type="submit" value="Auto-detect HTML" formaction="/auto-detect" formnovalidate></div></div>
|
||||
<div class="field">Venue name<input required name="venue_name" value="{{ source['venue_name'] or '' }}"></div>
|
||||
<div class="field address-field">Venue address<input required id="venue-address" name="venue_address" value="{{ source['venue_address'] or '' }}" autocomplete="off"><div id="address-options" class="location-options" hidden></div></div>
|
||||
<div class="two"><div class="field">Latitude<input id="venue-latitude" type="number" step="any" name="latitude" value="{{ source['latitude'] if source['latitude'] is not none else '' }}"></div><div class="field">Longitude<input id="venue-longitude" type="number" step="any" name="longitude" value="{{ source['longitude'] if source['longitude'] is not none else '' }}"></div></div>
|
||||
<div class="field">Timezone<input required name="timezone" value="{{ source['timezone'] or 'Australia/Sydney' }}"></div></section>
|
||||
<section class="card picker-panel"><div class="form-heading">Visual selector picker</div><div class="hint">Loads a sanitized, non-interactive copy of the downloaded HTML. Dynamic JavaScript content may not appear.</div><div class="picker-controls"><label class="field">Assign clicks to<select id="picker-target"><option value="item_selector">Event container</option><option value="title_selector">Title</option><option value="date_selector">Date or range</option><option value="time_selector">Time</option><option value="link_selector">Link</option><option value="description_selector">Description</option><option value="location_selector">Location</option></select></label><button type="button" id="load-picker">Load visual picker</button></div><div id="picker-status" class="hint"></div><iframe id="selector-picker" class="selector-picker" sandbox="allow-scripts" title="Sanitized website selector picker" hidden></iframe><div id="picker-selection" class="picker-selection" hidden><div><strong>Sele</strong>cted</strong><code id="picked-selector"></code><span id="picked-count"></span></div><div><strong>R</strong>epeated event pattern</strong><code id="picked-container"></code><span id="picked-relative"></span></div><span id="picked-text"></span><div class="picker-actions"><button type="button" id="apply-selector">Use exact selector</button><button type="button" id="apply-pattern">Use repeated pattern</button></div></div></section>
|
||||
<details class="card rules-details" {% if detect_message or preview_error %}open{% endif %}><summary>Extraction strategy — advanced</summary><section class="form-section">
|
||||
<div class="hint">HTML mode uses the convenient selector fields below. JSON and HTML-fragment modes use the generic JSON strategy directly.</div>
|
||||
<div class="two"><div class="field">Event container selector<input name="item_selector" value="{{ source['item_selector'] or '' }}"></div><div class="field">Title selector<input name="title_selector" value="{{ source['title_selector'] or '' }}"></div><div class="field">Date or range selector<input name="date_selector" value="{{ source['date_selector'] or '' }}"></div><div class="field">Time selector<input name="time_selector" value="{{ source['time_selector'] or '' }}"></div><div class="field">Link selector<input name="link_selector" value="{{ source['link_selector'] or '' }}"></div><div class="field">Description selector<input name="description_selector" value="{{ source['description_selector'] or '' }}"></div><div class="field">Location selector<input name="location_selector" value="{{ source['location_selector'] or '' }}"></div><div class="field">Date format<input name="date_format" value="{{ source['date_format'] or '' }}"></div></div>
|
||||
<label class="check"><input type="checkbox" name="recurring_weekly" {% if source['recurring_weekly'] %}checked{% endif %}> Generate weekly occurrences from weekday labels</label>
|
||||
<div class="field">Strategy template<select id="strategy-template"><option value="">Keep current strategy</option><option value="html">HTML page</option><option value="json">JSON API</option><option value="json_join">JSON API with metadata join</option><option value="html_fragment">API returning HTML fragments</option></select></div><div class="field">Persisted strategy JSON<textarea id="strategy-json" name="strategy_json" rows="18">{{ source['strategy_json'] or '{}' }}</textarea><div class="hint">Supports request configuration, dotted JSON paths, CSS selectors and attributes, pagination, joins, and response limits. See STRATEGIES.md.</div></div>
|
||||
</section></details>
|
||||
<section class="card form-section"><div class="form-heading">Scheduling</div><div class="field">Crawl every (minutes)<input type="number" min="15" name="crawl_interval_minutes" value="{{ source['crawl_interval_minutes'] or 360 }}"></div><label class="check"><input type="checkbox" name="enabled" {% if source['enabled'] %}checked{% endif %}> Enable scheduled crawling</label></section>
|
||||
<div class="sticky-actions">{% if source.get('id') %}<input type="hidden" name="source_id" value="{{ source['id'] }}">{% endif %}<input type="submit" value="Test strategy" formaction="/preview"><input type="submit" class="primary" value="Save source"></div></form>
|
||||
|
||||
{% if preview is not none %}<section class="card preview"><div class="form-heading">Preview ({{ preview|length }})</div>{% for event in preview %}<div><strong>{{ event.title }}</strong><span>{{ event.starts_at }}{% if event.ends_at %} → {{ event.ends_at }}{% endif %}</span></div>{% else %}<p>No events matched this strategy.</p>{% endfor %}</section>{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/source.js"></script>{% endblock %}
|
||||
6
app/templates/sources.html
Normal file
6
app/templates/sources.html
Normal file
@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sources · What2Do{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head"><div><div class="eyebrow">CRAWLER ADMIN</div><div class="form-title">Sources</div><div class="muted">Configure crawlers and manage their normalized events.</div></div><a class="button" href="/sources/new">+ Add website</a></div>
|
||||
<div class="source-grid">{% for source in sources %}<article class="card source"><div class="source-title"><div><h3>{{ source.name }}</h3><a href="{{ source.url }}" target="_blank" rel="noopener">{{ source.url }}</a></div><span class="pill {{ 'good' if source.enabled else '' }}">{{ 'Enabled' if source.enabled else 'Paused' }}</span></div><div class="source-stats"><div><small>Venue</</small>small><strong>{{ source.venue_name }}</strong></div><div><small>Events</small></small><strong>{{ source.event_count }}</strong></div><div><small></small>Last result</small><strong>{{ source.last_status or 'Not crawled' }}</strong></div></div>{% if source.last_error %}<div class="notice error">{{ source.last_error }}</div>{% endif %}<div class="actions"><form method="post" action="/sources/{{ source.id }}/crawl"><button>Crawl now</button></form><a class="button secondary" href="/sources/{{ source.id }}">Configure</a><a class="button secondary" href="/sources/{{ source.id }}/events">Manage events</a><form method="post" action="/sources/{{ source.id }}/delete" onsubmit="return confirm('Delete this source and its events?')"><button class="danger">Delete</button></form></div></article>{% endfor %}</div>
|
||||
{% endblock %}
|
||||
16
compose.yaml
Normal file
16
compose.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_PATH: /data/what2do.db
|
||||
PUBLIC_BASE_URL: http://localhost:8000
|
||||
CRAWL_INTERVAL_MINUTES: "60"
|
||||
NOMINATIM_USER_AGENT: what2do-local/0.1 (set-a-contact@example.com)
|
||||
volumes:
|
||||
- what2do-data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
what2do-data:
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn==0.35.0
|
||||
httpx==0.28.1
|
||||
beautifulsoup4==4.13.4
|
||||
python-multipart==0.0.20
|
||||
jinja2==3.1.6
|
||||
python-dateutil==2.9.0.post0
|
||||
Loading…
x
Reference in New Issue
Block a user