Spaces:
Sleeping
Sleeping
| """Curate the harvested candidate pool down to ~150 side quests. | |
| Scores candidates (Wikidata notability + image + website), then greedily selects | |
| with type quotas and per-neighbourhood caps so the result is spread across the | |
| region (the "discover neighbourhoods" bias). Builds bilingual blurbs grounded in | |
| the Wikidata description where available (templated factual fallback otherwise — | |
| no invented facts), assigns a per-type dwell time, and writes web/side_quests.json. | |
| Images are downloaded later by fetch_quest_images.py. | |
| Run: python scripts/curate_quests.py | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from pathlib import Path | |
| # A POI named after a person often carries the PERSON's wikidata tag in OSM, so its | |
| # description/image/wikipedia are the person's bio/portrait, not the place. Detect | |
| # those (occupation words EN/FR, or birth–death years) and drop the enrichment so | |
| # the place falls back to a correct templated blurb. "sculpture/statue/monument/ | |
| # memorial" are intentionally NOT here — those are legit artworks. | |
| _PERSON = re.compile( | |
| r"\b(1[5-9]\d\d|20\d\d)\s*[–\-]\s*(1[5-9]\d\d|20\d\d)\b" | |
| r"|\b(activist|politician|writer|author|novelist|painter|poet|friar|missionary|" | |
| r"priest|saint|bishop|singer|actor|actress|composer|general|soldier|explorer|" | |
| r"founder|businessman|philanthropist|scientist|physician|lawyer|nun|monk|mayor|" | |
| r"premier|minister|journalist|architect|engineer|economist|historian|" | |
| r"militant|femme politique|homme politique|écrivain|écrivaine|peintre|poète|" | |
| r"prêtre|missionnaire|religieu|frère|évêque|chanteu|acteur|actrice|explorateur|" | |
| r"fondateu|médecin|avocat|journaliste)\b", re.IGNORECASE) | |
| def _looks_like_person(c) -> bool: | |
| d = (c.get("desc_en") or "") + " | " + (c.get("desc_fr") or "") | |
| return bool(d.strip(" |")) and bool(_PERSON.search(d)) | |
| ROOT = Path(__file__).resolve().parent.parent | |
| CAND = ROOT / "data" / "processed" / "quest_candidates.json" | |
| OUT = ROOT / "web" / "side_quests.json" | |
| # region area centroids -> nearest gives each POI a neighbourhood (display + binning) | |
| AREAS = { | |
| "Ville-Marie": (45.508, -73.564), "Le Plateau-Mont-Royal": (45.527, -73.585), | |
| "Mile End": (45.523, -73.601), "Rosemont–La Petite-Patrie": (45.545, -73.585), | |
| "Villeray": (45.545, -73.620), "Ahuntsic-Cartierville": (45.555, -73.660), | |
| "Hochelaga-Maisonneuve": (45.545, -73.545), "Mercier": (45.585, -73.520), | |
| "Anjou": (45.605, -73.555), "Saint-Léonard": (45.585, -73.595), | |
| "Montréal-Nord": (45.600, -73.635), "Rivière-des-Prairies": (45.640, -73.510), | |
| "Pointe-aux-Trembles": (45.660, -73.500), "Le Sud-Ouest": (45.480, -73.585), | |
| "Verdun / Île-des-Sœurs": (45.458, -73.570), "LaSalle": (45.430, -73.620), | |
| "Lachine": (45.435, -73.675), "Côte-des-Neiges–NDG": (45.475, -73.625), | |
| "Outremont": (45.515, -73.610), "Westmount": (45.485, -73.600), | |
| "Côte-Saint-Luc": (45.470, -73.665), "Saint-Laurent": (45.500, -73.700), | |
| "Pierrefonds-Roxboro": (45.490, -73.860), "Dollard-des-Ormeaux": (45.495, -73.810), | |
| "Pointe-Claire": (45.450, -73.815), "Dorval": (45.450, -73.745), | |
| "Beaconsfield / Kirkland": (45.435, -73.860), "L'Île-Bizard": (45.495, -73.890), | |
| "Laval (Chomedey)": (45.545, -73.750), "Laval-des-Rapides": (45.555, -73.710), | |
| "Sainte-Rose (Laval)": (45.610, -73.785), "Sainte-Dorothée (Laval)": (45.530, -73.810), | |
| "Vimont (Laval)": (45.620, -73.720), "Vieux-Longueuil": (45.535, -73.510), | |
| "Greenfield Park": (45.495, -73.470), "Saint-Hubert": (45.490, -73.420), | |
| "Brossard": (45.460, -73.465), "Saint-Lambert": (45.500, -73.510), | |
| "Boucherville": (45.595, -73.435), "Saint-Bruno": (45.535, -73.350), | |
| "Châteauguay": (45.380, -73.750), | |
| } | |
| DWELL = {"eatery": 75, "park": 75, "neighborhood": 120, "viewpoint": 25, | |
| "market": 50, "landmark": 75, "historic": 45, "art": 75} | |
| QUOTA = {"park": 40, "eatery": 25, "neighborhood": 18, "landmark": 22, | |
| "art": 15, "historic": 14, "viewpoint": 8, "market": 8} | |
| PER_AREA = 6 # max quests per neighbourhood (spread out) | |
| PER_AREA_TYPE = 2 # max per (neighbourhood, type) | |
| def nearest_area(lat, lon): | |
| best, bd = None, 1e9 | |
| for name, (alat, alon) in AREAS.items(): | |
| d = (lat - alat) ** 2 + (lon - alon) ** 2 | |
| if d < bd: | |
| best, bd = name, d | |
| return best | |
| def score(c): | |
| s = c.get("sitelinks", 0) * 2.0 | |
| if c.get("image"): | |
| s += 5 | |
| if c.get("website"): | |
| s += 2 | |
| if c.get("desc_fr") or c.get("desc_en"): | |
| s += 2 | |
| # parks/eateries without any notability are weak; keep but de-rank | |
| if c["type"] in ("park", "eatery") and not c.get("wikidata") and not c.get("website"): | |
| s -= 3 | |
| return s | |
| # experiential types read better with an inviting template than with the dry | |
| # Wikidata category ("urban park in Montreal"); informational types keep the desc. | |
| _EXPERIENTIAL = {"park", "neighborhood", "eatery", "viewpoint", "market"} | |
| def blurb(c, area): | |
| df, de = c.get("desc_fr"), c.get("desc_en") | |
| cap = lambda s: s[:1].upper() + s[1:] if s else s | |
| if (df or de) and c["type"] not in _EXPERIENTIAL: | |
| return cap(df or de), cap(de or df) | |
| t, cui = c["type"], (c.get("cuisine") or "").split(";")[0].replace("_", " ") | |
| name = c["name"] | |
| T = { | |
| "eatery": (f"{cui.capitalize()}, {area}." if cui else f"Une table de quartier, {area}.", | |
| f"{cui.capitalize()} in {area}." if cui else f"A neighbourhood table in {area}."), | |
| "park": (f"Un espace vert où souffler un après-midi, dans {area}.", | |
| f"Green space to slow down for an afternoon, in {area}."), | |
| "neighborhood": (f"Flânez dans {name} : ruelles, cafés et trouvailles au hasard.", | |
| f"Go wander {name}: lanes, cafés and happy accidents."), | |
| "viewpoint": (f"Un point de vue sur la ville, à {area}.", | |
| f"A lookout over the city, in {area}."), | |
| "market": (f"Un marché à arpenter à {area}.", f"A market to browse in {area}."), | |
| "landmark": (f"Un arrêt qui vaut le détour à {area}.", | |
| f"A stop worth the detour in {area}."), | |
| "historic": (f"Un morceau d'histoire à {area}.", f"A slice of history in {area}."), | |
| "art": (f"Pour les yeux : art et galeries à {area}.", | |
| f"For the eyes: art around {area}."), | |
| } | |
| return T.get(t, (f"À découvrir à {area}.", f"Worth discovering in {area}.")) | |
| def main(): | |
| cands = json.loads(CAND.read_text(encoding="utf-8")) | |
| # drop person-bio enrichment (place named after someone -> OSM points at the | |
| # person): blank desc/image/wikipedia and the wikidata link so it's treated as | |
| # an unenriched OSM place with a correct templated blurb. | |
| persons = 0 | |
| for c in cands: | |
| if _looks_like_person(c): | |
| c["desc_en"] = c["desc_fr"] = c["image"] = c["wikipedia"] = None | |
| c["wikidata"] = None | |
| persons += 1 | |
| print(f"Neutralised {persons} person-named entries") | |
| # dedupe: by wikidata id (keep best), then by (name, ~coords) | |
| best_by_qid: dict[str, dict] = {} | |
| rest = [] | |
| for c in cands: | |
| q = c.get("wikidata") | |
| if q: | |
| if q not in best_by_qid or score(c) > score(best_by_qid[q]): | |
| best_by_qid[q] = c | |
| else: | |
| rest.append(c) | |
| seen = set() | |
| pool = list(best_by_qid.values()) | |
| for c in rest: | |
| key = (c["name"].lower(), round(c["lat"], 3), round(c["lon"], 3)) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| pool.append(c) | |
| for c in pool: | |
| c["_area"] = nearest_area(c["lat"], c["lon"]) | |
| c["_score"] = score(c) | |
| pool.sort(key=lambda c: -c["_score"]) | |
| chosen, type_n, area_n, area_type_n = [], {}, {}, {} | |
| for c in pool: | |
| t, a = c["type"], c["_area"] | |
| if type_n.get(t, 0) >= QUOTA.get(t, 0): | |
| continue | |
| if area_n.get(a, 0) >= PER_AREA: | |
| continue | |
| if area_type_n.get((a, t), 0) >= PER_AREA_TYPE: | |
| continue | |
| chosen.append(c) | |
| type_n[t] = type_n.get(t, 0) + 1 | |
| area_n[a] = area_n.get(a, 0) + 1 | |
| area_type_n[(a, t)] = area_type_n.get((a, t), 0) + 1 | |
| out = [] | |
| for i, c in enumerate(sorted(chosen, key=lambda c: (c["_area"], c["type"]))): | |
| bf, be = blurb(c, c["_area"]) | |
| out.append({ | |
| "id": f"sq-{i:03d}", | |
| "name": c["name"], | |
| "type": c["type"], | |
| "lat": c["lat"], "lon": c["lon"], | |
| "neighborhood": c["_area"], | |
| "avg_dwell_min": DWELL.get(c["type"], 60), | |
| "blurb_fr": bf, "blurb_en": be, | |
| "website": c.get("website"), | |
| "wikipedia": c.get("wikipedia"), | |
| "image": c.get("image"), # remote Commons url; localised by fetch_quest_images.py | |
| "attribution": None, | |
| "source": (f"wikidata:{c['wikidata']}" if c.get("wikidata") else f"osm:{c['osm']}"), | |
| }) | |
| OUT.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8") | |
| print(f"Selected {len(out)} quests -> {OUT}") | |
| print("By type:", dict(sorted(type_n.items(), key=lambda x: -x[1]))) | |
| print("Neighbourhoods covered:", len(area_n), "/", len(AREAS)) | |
| print("With image:", sum(1 for o in out if o["image"]), | |
| "| with website:", sum(1 for o in out if o["website"]), | |
| "| with wikipedia:", sum(1 for o in out if o["wikipedia"])) | |
| if __name__ == "__main__": | |
| main() | |