MoBis_MRU / events.py
SamDNX's picture
Deploy MoBis: live planner API + web app + dataset
15eefe5
Raw
History Blame Contribute Delete
17.8 kB
"""
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/<id>). 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 `<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 ""
# 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[^>]*>(.*?)</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 = [] # (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]}"
)