570 lines
34 KiB
Python
570 lines
34 KiB
Python
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})
|