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})