| """ |
| 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_URL = "https://otayo.com/en/events-and-concerts-in-mauritius" |
| OTAYO_UA = "Mozilla/5.0 (compatible; MoBaz/1.0; +mailto:syadone@gmail.com)" |
|
|
| |
| |
| |
| |
| _CACHE = {"at": 0.0, "events": None} |
| _TTL = 6 * 3600 |
| _LOCK = threading.Lock() |
| _REFRESHING = threading.Event() |
|
|
| |
| |
| |
| _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 `<id>:T<hexlen>,<text>`; 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 "" |
| |
| |
| 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): |
| |
| 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", |
| } |
| ) |
| |
| 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[^>]*>(.*?)</script>", 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 = [] |
| for e in events: |
| eid = e["id"] |
| entry = cache.get(eid) |
| if isinstance(entry, str): |
| 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): |
| |
| |
| 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() |
| 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(): |
| |
| |
| 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 |
| 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() |
| return cached |
|
|
| |
| 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]}" |
| ) |
|
|