""" Upcoming events in Mauritius, for the app's "Events" tab. Two public sources, merged + de-duplicated into one normalised list: * partyapp.mu — a Next.js app whose homepage streams its event data in the React-Server-Components payload (`self.__next_f.push(...)`). We parse the embedded objects (name, date, image, category, cheapest price, description) and resolve the venue NAME from each event's public JSON-LD detail page, then geocode it (closest OSM match). We never touch the App-Check-protected Firestore that holds precise coordinates. * otayo.com — a WooCommerce site whose listing page carries each event's name, date, price, venue and *exact* lat/long in the card markup, so no geocoding is needed. Each event is normalised to: {name, date, dateLabel, price, priceLabel, category, image, description, venue, lat, lon, url, source} sorted by date (soonest first). The client adds distance from the user. """ import os import re import json import time import html as _html import threading from datetime import date, timedelta from concurrent.futures import ThreadPoolExecutor import requests import geocode UA = "MoBaz/1.0 (Mauritius transit app; syadone@gmail.com)" SOURCE_URL = "https://partyapp.mu/en" EVENT_URL = "https://partyapp.mu/en/e/{id}" # Otayo blocks unusual user-agents, so present a browser-like one. OTAYO_URL = "https://otayo.com/en/events-and-concerts-in-mauritius" OTAYO_UA = "Mozilla/5.0 (compatible; MoBaz/1.0; +mailto:syadone@gmail.com)" # Events change slowly; refetching on every request would be wasteful (and slow # on the free-tier Space). Cache the parsed list and refresh ~4x/day — the # keep-warm cron pings /api/events frequently, triggering a background refresh # (stale-while-revalidate) once this TTL lapses. _CACHE = {"at": 0.0, "events": None} _TTL = 6 * 3600 # 6 hours -> ~4 refreshes/day _LOCK = threading.Lock() _REFRESHING = threading.Event() # The homepage only carries an opaque venue id; the venue NAME lives in the # public JSON-LD of each event's detail page (/en/e/). That's an extra fetch # per event, so we cache resolved venues by event id between scrapes. _VENUE_CACHE_FILE = os.path.join(os.path.dirname(__file__), "data", "venue_cache.json") _venues = None _MONTHS = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ] def _rsc_blob(html): """Concatenate + unescape the streamed RSC string chunks of a Next.js page. `unicode_escape` handles the \\n / \\" / \\uXXXX escapes but mis-reads raw UTF-8 (emoji, accents) as Latin-1, so we round-trip those bytes back to proper UTF-8 (the page's \\u escapes are all ASCII-range, so unaffected).""" chunks = re.findall(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', html, re.S) blob = "".join(chunks).encode().decode("unicode_escape", errors="replace") return blob.encode("latin-1", "ignore").decode("utf-8", "ignore") def _resolve_text_ref(blob, ref): """Resolve a Next.js RSC reference like "$21" to its text. The flight stream stores long strings in separate rows encoded as `:T,`; the text is raw UTF-8 that our unescape step mis-decoded as Latin-1, so we round- trip it back. Returns "" when the reference isn't a resolvable text row.""" if not isinstance(ref, str) or not ref.startswith("$"): return ref.strip() if isinstance(ref, str) else "" m = re.search(r"(?:^|\n)" + re.escape(ref[1:]) + r":T([0-9a-f]+),", blob) if not m: return "" # The length is in UTF-8 bytes of the original stream; the blob is now proper # unicode, so accumulate characters until we've consumed that many bytes. length = int(m.group(1), 16) out, used = [], 0 for ch in blob[m.end() :]: used += len(ch.encode("utf-8")) if used > length: break out.append(ch) return "".join(out).strip() def _object_around(s, anchor): """Return the balanced {...} JSON object that contains index `anchor`.""" depth, start = 0, None for i in range(anchor, -1, -1): c = s[i] if c == "}": depth += 1 elif c == "{": if depth == 0: start = i break depth -= 1 if start is None: return None depth = 0 for j in range(start, len(s)): c = s[j] if c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: return s[start : j + 1] return None def _min_price(tickets): prices = [ t.get("price") for t in (tickets or []) if isinstance(t.get("price"), (int, float)) and t.get("price") > 0 ] return min(prices) if prices else None def _date_label(iso): # "2026-06-27T14:00:00.000Z" -> "Sat 27 Jun" (best-effort, no tz library) m = re.match(r"(\d{4})-(\d{2})-(\d{2})", iso or "") if not m: return "" y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3)) return f"{d} {_MONTHS[mo - 1]} {y}" if 1 <= mo <= 12 else "" def _parse(html): blob = _rsc_blob(html) seen, events = set(), [] for m in re.finditer(r'"ticketingEnabled":true', blob): raw = _object_around(blob, m.start()) if not raw: continue try: e = json.loads(raw) except Exception: continue eid = e.get("id") name = e.get("name") starts = e.get("startsAt") if not name or not starts or not eid or eid in seen: continue if e.get("draft"): continue seen.add(eid) price = _min_price(e.get("tickets")) events.append( { "id": eid, "name": name, "date": starts, "endsAt": e.get("endsAt"), "dateLabel": _date_label(starts), "price": price, "priceLabel": (f"from Rs {price}" if price else "Free / see tickets"), "category": e.get("category") or "", "image": e.get("image") or "", "description": _resolve_text_ref(blob, e.get("description")), "venue": "", "lat": None, "lon": None, "url": EVENT_URL.format(id=eid), "source": "partyapp", } ) # Drop events that have already ended; soonest first. now = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()) events = [e for e in events if not e.get("endsAt") or e["endsAt"] >= now] events.sort(key=lambda e: e["date"]) for e in events: e.pop("endsAt", None) return events def _load_venues(): global _venues if _venues is None: try: with open(_VENUE_CACHE_FILE, encoding="utf-8") as fh: _venues = json.load(fh) except Exception: _venues = {} return _venues def _save_venues(): try: os.makedirs(os.path.dirname(_VENUE_CACHE_FILE), exist_ok=True) with open(_VENUE_CACHE_FILE, "w", encoding="utf-8") as fh: json.dump(_venues, fh, ensure_ascii=False) except Exception: pass def _venue_from_detail(eid): """The venue NAME from an event's public JSON-LD (schema.org/Event.location). The detail page is the public, indexable event page — we only read the location name it advertises for SEO; we never touch the App-Check-protected Firestore that holds the precise coordinates.""" try: r = requests.get( EVENT_URL.format(id=eid), headers={"User-Agent": UA}, timeout=12 ) r.raise_for_status() m = re.search(r"application/ld\+json[^>]*>(.*?)", r.text, re.S) if not m: return "" loc = json.loads(m.group(1)).get("location") or {} name = (loc.get("name") or "").strip() locality = ((loc.get("address") or {}).get("addressLocality") or "").strip() return ", ".join(p for p in (name, locality) if p) except Exception: return "" def _geocode_venue(name): """Closest coordinate for a venue name from our OSM-based geocoder (best effort; returns None when nothing matches). Powers the in-app routing option — Maps is offered alongside it for precise, POI-aware directions.""" try: res = geocode.geocode_search(name, limit=1) return res[0]["coord"] if res else None except Exception: return None def _resolve_venue(eid, known_name): """Resolve a venue to {name, lat, lon}; reuse a cached name to avoid an extra detail-page fetch when only coordinates are missing.""" name = known_name if known_name else _venue_from_detail(eid) coord = _geocode_venue(name) if name else None return { "name": name or "", "lat": coord[0] if coord else None, "lon": coord[1] if coord else None, } def _enrich_venues(events): """Fill each event's venue {name, lat, lon} from its detail page + geocoder, resolving only the ones we haven't cached before (keyed by event id). Detail fetches + geocoding run in parallel so a cold scrape stays a few seconds.""" cache = _load_venues() todo = [] # (eid, known_name) for entries missing or lacking coordinates for e in events: eid = e["id"] entry = cache.get(eid) if isinstance(entry, str): # migrate the older name-only cache format entry = {"name": entry} cache[eid] = entry if eid and (entry is None or "lat" not in entry): todo.append((eid, (entry or {}).get("name"))) if todo: with ThreadPoolExecutor(max_workers=8) as pool: resolved = list(pool.map(lambda a: _resolve_venue(*a), todo)) for (eid, _), entry in zip(todo, resolved): cache[eid] = entry _save_venues() for e in events: entry = cache.get(e["id"]) or {} e["venue"] = entry.get("name", "") e["lat"] = entry.get("lat") e["lon"] = entry.get("lon") return events _MONTH_NUM = {m: i + 1 for i, m in enumerate(_MONTHS)} def _otayo_iso(day, mon): """Build an ISO date from Otayo's day + 3-letter month, inferring the year: listings are upcoming, so a month already well past rolls over to next year.""" mo = _MONTH_NUM.get((mon or "")[:3].title()) if not mo or not (day or "").isdigit(): return "" d = int(day) today = date.today() year = today.year try: if date(year, mo, d) < today - timedelta(days=14): year += 1 except ValueError: return "" return f"{year}-{mo:02d}-{d:02d}T00:00:00.000Z" def _parse_otayo(html): """Parse otayo.com event cards: name, date, price, venue + exact lat/long.""" def field(b, pat): m = re.search(pat, b, re.S) return _html.unescape(m.group(1).strip()) if m else "" events = [] for m in re.finditer(r'class="event-name-dt"', html): w = html[m.start() - 1500 : m.start() + 1400] url = field(w, r'href="(https://otayo\.com/en/event/[^"]+)"') name = field(w, r'class="event-name-dt">([^<]+)<') iso = _otayo_iso( field(w, r'class="day">([^<]+)<'), field(w, r'class="month">([^<]+)<') ) if not name or not url or not iso: continue venue = field(w, r'/en/venues/[^"]+">([^<]+)<') or field( w, r'data-address="([^"]*)"' ) lat = field(w, r'data-lat="([^"]*)"') lon = field(w, r'data-long="([^"]*)"') price_txt = field(w, r'class="price-dt">([^<]+)<') pm = re.search(r"([\d,]+)", price_txt) price = int(pm.group(1).replace(",", "")) if pm else None image = field(w, r'src="(https://otayo\.com/wp-content/uploads/[^"]+)"') events.append( { "name": name, "date": iso, "dateLabel": _date_label(iso), "price": price, "priceLabel": (f"from Rs {price}" if price else "See tickets"), "category": "", "image": image, "description": "", "venue": venue, "lat": float(lat) if lat else None, "lon": float(lon) if lon else None, "url": url, "source": "otayo", } ) return events def _otayo_events(): try: r = requests.get(OTAYO_URL, headers={"User-Agent": OTAYO_UA}, timeout=20) r.raise_for_status() return _parse_otayo(r.text) except Exception: return [] def _partyapp_events(): r = requests.get(SOURCE_URL, headers={"User-Agent": UA}, timeout=15) r.raise_for_status() return _enrich_venues(_parse(r.text)) def _dedupe_key(e): # Unify curly/straight apostrophes first (otherwise _norm drops the curly one # but turns the straight one into a space → "cest" vs "c est" wouldn't match). name = (e["name"] or "").replace("’", "'").replace("‘", "'") name = geocode._norm(name) name = re.sub(r"\b\d{1,2}(st|nd|rd|th)\b", "", name).strip() # "19th june" noise name = re.sub(r"\s+", " ", name).strip() return (name, (e.get("date") or "")[:10]) def _merge_pair(a, b): """Combine two records for the same event, preferring precise coords (Otayo) and a real description (PartyApp), the cheaper price and any image/venue.""" if a.get("lat") is None and b.get("lat") is not None: a["lat"], a["lon"], a["venue"] = ( b["lat"], b["lon"], b.get("venue") or a.get("venue"), ) if not a.get("description") and b.get("description"): a["description"] = b["description"] if not a.get("image") and b.get("image"): a["image"] = b["image"] if not a.get("category") and b.get("category"): a["category"] = b["category"] if not a.get("venue") and b.get("venue"): a["venue"] = b["venue"] if b.get("price") is not None and ( a.get("price") is None or b["price"] < a["price"] ): a["price"] = b["price"] a["priceLabel"] = b["priceLabel"] if a.get("source") and b.get("source") and b["source"] not in a["source"]: a["source"] = f"{a['source']}+{b['source']}" return a def _merge(events): """De-duplicate across sources by (name, date) and sort by date.""" merged = {} for e in events: key = _dedupe_key(e) if key in merged: _merge_pair(merged[key], e) else: merged[key] = e out = list(merged.values()) out.sort(key=lambda e: e.get("date") or "") return out def _scrape(): # Otayo first so its precise coordinates win during the merge; a failure of # one source must not lose the other. events = [] events += _otayo_events() try: events += _partyapp_events() except Exception: pass return _merge(events) def _store(events): if events: with _LOCK: _CACHE["events"] = events _CACHE["at"] = time.time() def _refresh_async(): """Scrape in a background thread, never more than one at a time.""" if _REFRESHING.is_set(): return _REFRESHING.set() def run(): try: _store(_scrape()) except Exception: pass # keep serving the last good list finally: _REFRESHING.clear() threading.Thread(target=run, daemon=True).start() def prewarm(): """Populate the cache at server startup so the first request is instant.""" _refresh_async() def health(): """Lightweight cache status for /api/health. Never triggers a scrape, so it is safe to poll frequently. Reports how many events are cached, how many carry coordinates, the per-source breakdown, and how stale the cache is.""" with _LOCK: events = _CACHE["events"] at = _CACHE["at"] count = len(events) if events else 0 with_coords = sum(1 for e in (events or []) if e.get("lat") is not None) by_source = {} for e in events or []: src = e.get("source") or "?" by_source[src] = by_source.get(src, 0) + 1 age = (time.time() - at) if at else None return { "count": count, "with_coords": with_coords, "sources": by_source, "age_seconds": int(age) if age is not None else None, "stale": age is None or age >= _TTL, "refreshing": _REFRESHING.is_set(), } def get_events(force=False): """Parsed upcoming events. Serves the cache instantly and refreshes in the background when stale (stale-while-revalidate), so a user request never waits on the ~10s partyapp.mu scrape once the cache has been warmed. Returns [] only when nothing has ever been cached and a fresh scrape fails.""" with _LOCK: cached = _CACHE["events"] fresh = cached is not None and (time.time() - _CACHE["at"]) < _TTL if cached is not None and not force: if not fresh: _refresh_async() # refresh in the background; serve stale now return cached # Cold cache (or forced): we have nothing to serve, so scrape synchronously. try: events = _scrape() except Exception: return cached or [] _store(events) return events or cached or [] if __name__ == "__main__": evs = get_events(force=True) print(f"{len(evs)} events") for e in evs: coord = f"{e['lat']:.3f},{e['lon']:.3f}" if e.get("lat") else "(no coord)" print( f" {e['dateLabel']:>12} · {e['source']:<14} · {e['venue'][:20] or '(no venue)':<20}" f" · {coord:<18} · {e['name'][:32]}" )