Phase 2 — connecteurs : heure+description La Vitrine, statuts, end_time, offres, taxonomies
- lavitrine (plus grosse source, 5 087 à venir) : capte l HEURE de la slide (« 15:00 HNE », start_time 0 % → 51 % dès le 1er lot de 1 100 fiches) et la DESCRIPTION (accroche courte + début du texte À propos, desc≥200 0 % → 50 %) ; re-visite versionnée v2 du cache (montée progressive, ~400 fiches/sync). - eventbrite : end_time (98,8 %), full_description>JSON-LD>summary, organisateur via le JSON-LD de la fiche déjà visitée pour le prix (0 % → 67 % et montant), statut is_cancelled CONSERVÉ (bandeau) + urgency_signals sold out → soldout. Les séries (series_id) ne sont PAS déroulées : 0 série observée sur les pages découverte sondées. - atuvu : og:description enfin affectée à l événement (payée en crédits Scrapfly, elle était jetée) — SAUF le gabarit marketing « trouvez des billets… » constaté sur fiche réelle, jamais publié ; backfill au fil des re-scrapes lastmod, aucun crédit supplémentaire. - evenko : supports (premières parties) ajoutés aux artistes, door_time et tour_name en description (« Tournée : … · Portes : … »), representation_status → statut (soldout/cancelled). - bandsintown : offers[] type Tickets → lien billets direct + « sold out » → statut soldout. - brossard : taxonomie type-event résolue (1 requête WP REST, vraies catégories) ; description INTÉGRALE (fin de la coupe à 600 caractères) ; heure de fin de la bannière « de 15 h 00 à 16 h 00 » et du JSON-LD biblio. - longueuil : image captée dans l état Nuxt (mediaImages front_*_16_9/21_9) ; heure de fin des cartes <time>. - laval : préfixe « ANNULÉ - » du titre → statut cancelled, titre nettoyé. - ticketmaster (repli sans clé) : les annulés sont conservés avec statut ; mode API (si TICKETMASTER_API_KEY fournie) : dates.status.code → statut, end localTime → end_time. - montreal : heure de FIN du JSON-LD des fiches (endDate), backfill progressif sous le même plafond MTL_IMG_MAX. - sherbrooke : heure de fin des périodes UTC → locale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 changed files +354 −58
modified
sortika/connectors/atuvu.py
+12 −0
@@ -70,6 +70,16 @@ class AtuvuConnector(BaseConnector): | |||||
| 70 | if not title: | 70 | if not title: | ||
| 71 | return [] | 71 | return [] | ||
| 72 | image = (_OG_RE["image"].search(html) or [None, ""])[1] | 72 | image = (_OG_RE["image"].search(html) or [None, ""])[1] | ||
| 73 | + # og:description : déjà téléchargée (crédits Scrapfly) — Phase 2, on | ||||
| 74 | + # l'affecte enfin à l'événement (elle était compilée mais jetée)… | ||||
| 75 | + # SAUF quand c'est le gabarit marketing du site (« trouvez des billets | ||||
| 76 | + # à tarif réduit… »), constaté sur fiche réelle : on ne publie pas de | ||||
| 77 | + # texte boilerplate comme description d'événement. Le backfill se fait | ||||
| 78 | + # au fil des re-scrapes lastmod (aucun crédit Scrapfly ajouté). | ||||
| 79 | + description = clean_text((_OG_RE["description"].search(html) | ||||
| 80 | + or [None, ""])[1]) | ||||
| 81 | + if "trouvez des billets" in description.lower(): | ||||
| 82 | + description = "" | ||||
| 73 | cats = [clean_text(c) for c in _CAT_RE.findall(html)][:2] | 83 | cats = [clean_text(c) for c in _CAT_RE.findall(html)][:2] | ||
| 74 | price_m = _PRICE_RE.search(html) | 84 | price_m = _PRICE_RE.search(html) | ||
| 75 | price = float(price_m.group(1).replace(",", ".")) if price_m else None | 85 | price = float(price_m.group(1).replace(",", ".")) if price_m else None | ||
@@ -83,6 +93,7 @@ class AtuvuConnector(BaseConnector): | |||||
| 83 | "external_id": f"{slug}-{day}", | 93 | "external_id": f"{slug}-{day}", | ||
| 84 | "start_time": parse_time(date_txt), # « … - 20h00 » de la fiche | 94 | "start_time": parse_time(date_txt), # « … - 20h00 » de la fiche | ||
| 85 | "url": url, "title": title, "image": image, | 95 | "url": url, "title": title, "image": image, | ||
| 96 | + "description": description, | ||||
| 86 | "raw_categories": cats, "venue": clean_text(venue), | 97 | "raw_categories": cats, "venue": clean_text(venue), | ||
| 87 | "city": clean_text(city or ""), "start_date": day, | 98 | "city": clean_text(city or ""), "start_date": day, | ||
| 88 | "end_date": day, "price_min": price, | 99 | "end_date": day, "price_min": price, | ||
@@ -116,6 +127,7 @@ class AtuvuConnector(BaseConnector): | |||||
| 116 | source=self.source_id, | 127 | source=self.source_id, | ||
| 117 | external_id=row["external_id"], | 128 | external_id=row["external_id"], | ||
| 118 | url=row["url"], title=row["title"], | 129 | url=row["url"], title=row["title"], | ||
| 130 | + description=row.get("description", ""), | ||||
| 119 | raw_categories=row.get("raw_categories") or [], | 131 | raw_categories=row.get("raw_categories") or [], | ||
| 120 | venue=row.get("venue", ""), city=row.get("city", ""), | 132 | venue=row.get("venue", ""), city=row.get("city", ""), | ||
| 121 | start_date=row.get("start_date"), | 133 | start_date=row.get("start_date"), | ||
modified
sortika/connectors/bandsintown.py
+13 −1
@@ -78,9 +78,18 @@ class BandsintownConnector(BaseConnector): | |||||
| 78 | continue | 78 | continue | ||
| 79 | dt = e.get("datetime") or "" # heure locale de la salle | 79 | dt = e.get("datetime") or "" # heure locale de la salle | ||
| 80 | art = e.get("artist") or {} | 80 | art = e.get("artist") or {} | ||
| 81 | + # offres billetterie : lien billets direct + statut de vente | ||||
| 82 | + # (type Tickets ; status "available" / "sold out" chez la source) | ||||
| 83 | + ticket_url, offer_status = "", "" | ||||
| 84 | + for off in e.get("offers") or []: | ||||
| 85 | + if (off.get("type") or "").lower() == "tickets" and off.get("url"): | ||||
| 86 | + ticket_url = off["url"] | ||||
| 87 | + offer_status = (off.get("status") or "").lower() | ||||
| 88 | + break | ||||
| 81 | rows.append({ | 89 | rows.append({ | ||
| 82 | "id": str(e.get("id") or ""), | 90 | "id": str(e.get("id") or ""), | ||
| 83 | − "url": e.get("url") or "", | 91 | + "url": ticket_url or e.get("url") or "", | ||
| 92 | + "offer_status": offer_status, | ||||
| 84 | "title": e.get("title") or "", | 93 | "title": e.get("title") or "", | ||
| 85 | "description": e.get("description") or "", | 94 | "description": e.get("description") or "", | ||
| 86 | "artist_name": art.get("name") or artist, | 95 | "artist_name": art.get("name") or artist, | ||
@@ -144,6 +153,9 @@ class BandsintownConnector(BaseConnector): | |||||
| 144 | start_time=(None if row.get("time") == "00:00" | 153 | start_time=(None if row.get("time") == "00:00" | ||
| 145 | else row.get("time")), | 154 | else row.get("time")), | ||
| 146 | end_date=row.get("date"), | 155 | end_date=row.get("date"), | ||
| 156 | + # statut de vente de l'offre (« sold out » explicite) | ||||
| 157 | + status=("soldout" if "sold" in (row.get("offer_status") or "") | ||||
| 158 | + else ""), | ||||
| 147 | artists=row.get("lineup") or [row["artist_name"]], | 159 | artists=row.get("lineup") or [row["artist_name"]], | ||
| 148 | # `free` vaut false par défaut chez Bandsintown : seul un | 160 | # `free` vaut false par défaut chez Bandsintown : seul un | ||
| 149 | # true explicite est significatif (on n'invente rien) | 161 | # true explicite est significatif (on n'invente rien) | ||
modified
sortika/connectors/brossard.py
+31 −5
@@ -38,10 +38,13 @@ _LD_RE = re.compile(r'<script type="application/ld\+json"[^>]*>(.*?)</script>', | |||||
| 38 | re.S) | 38 | re.S) | ||
| 39 | _OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"') | 39 | _OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"') | ||
| 40 | # « Cet événement aura lieu le 6 décembre 2026, de 15 h 00 à 16 h 00 » | 40 | # « Cet événement aura lieu le 6 décembre 2026, de 15 h 00 à 16 h 00 » | ||
| 41 | +# « … aura lieu le 6 décembre 2026, à 15 h 00 » | ||||
| 41 | # « … aura lieu du 3 juillet 2026 au 5 juillet 2026 » | 42 | # « … aura lieu du 3 juillet 2026 au 5 juillet 2026 » | ||
| 42 | _BANNER_RE = re.compile( | 43 | _BANNER_RE = re.compile( | ||
| 43 | r"aura lieu\s+(?:le\s+(?P<jour>\d{1,2}(?:er)?\s+\S+\s+\d{4})" | 44 | r"aura lieu\s+(?:le\s+(?P<jour>\d{1,2}(?:er)?\s+\S+\s+\d{4})" | ||
| 44 | − r"(?:,?\s*(?:de|à)\s+(?P<heure>\d{1,2}\s*h\s*(?:\d{2})?))?" | 45 | + r"(?:,?\s*de\s+(?P<heure>\d{1,2}\s*h\s*(?:\d{2})?)" | ||
| 46 | + r"\s*(?:à|-|–)\s*(?P<fin>\d{1,2}\s*h\s*(?:\d{2})?)" | ||||
| 47 | + r"|,?\s*à\s+(?P<heure2>\d{1,2}\s*h\s*(?:\d{2})?))?" | ||||
| 45 | r"|du\s+(?P<du>\d{1,2}(?:er)?\s+\S+\s+\d{4})\s+au\s+" | 48 | r"|du\s+(?P<du>\d{1,2}(?:er)?\s+\S+\s+\d{4})\s+au\s+" | ||
| 46 | r"(?P<au>\d{1,2}(?:er)?\s+\S+\s+\d{4}))", re.I) | 49 | r"(?P<au>\d{1,2}(?:er)?\s+\S+\s+\d{4}))", re.I) | ||
| 47 | 50 | ||||
@@ -58,7 +61,7 @@ class BrossardConnector(BaseConnector): | |||||
| 58 | batch = self.get_json(API, params={ | 61 | batch = self.get_json(API, params={ | ||
| 59 | "per_page": PAGE_SIZE, "page": page, "orderby": "modified", | 62 | "per_page": PAGE_SIZE, "page": page, "orderby": "modified", | ||
| 60 | "order": "desc", | 63 | "order": "desc", | ||
| 61 | − "_fields": "id,link,title,content,modified"}) | 64 | + "_fields": "id,link,title,content,modified,type-event"}) | ||
| 62 | except Exception: | 65 | except Exception: | ||
| 63 | if page > 1: # « rest_post_invalid_page_number » = fin | 66 | if page > 1: # « rest_post_invalid_page_number » = fin | ||
| 64 | break | 67 | break | ||
@@ -71,6 +74,20 @@ class BrossardConnector(BaseConnector): | |||||
| 71 | page += 1 | 74 | page += 1 | ||
| 72 | return out | 75 | return out | ||
| 73 | 76 | ||||
| 77 | + # -- taxonomie type-event : id → nom (1 requête, ~11 termes) --------------- | ||||
| 78 | + def _type_event_map(self) -> dict[int, str]: | ||||
| 79 | + """Vraies catégories WordPress (« Bibliothèque — adultes », | ||||
| 80 | + « Événements de la Ville »…) — remplace l'heuristique par titre.""" | ||||
| 81 | + try: | ||||
| 82 | + terms = self.get_json( | ||||
| 83 | + "https://brossard.ca/wp-json/wp/v2/type-event", | ||||
| 84 | + params={"per_page": 100, "_fields": "id,name"}) | ||||
| 85 | + return {int(t["id"]): clean_text(t.get("name") or "") | ||||
| 86 | + for t in terms if t.get("id")} | ||||
| 87 | + except Exception as exc: # taxonomie indisponible ≠ source cassée | ||||
| 88 | + print(f"[sorti-ka] brossard : taxonomie type-event ignorée : {exc}") | ||||
| 89 | + return {} | ||||
| 90 | + | ||||
| 74 | # -- fiche : dates/heure/lieu (bannière ville OU JSON-LD biblio) ---------- | 91 | # -- fiche : dates/heure/lieu (bannière ville OU JSON-LD biblio) ---------- | ||
| 75 | def _parse_fiche(self, html: str) -> dict: | 92 | def _parse_fiche(self, html: str) -> dict: | ||
| 76 | info: dict = {} | 93 | info: dict = {} | ||
@@ -89,6 +106,7 @@ class BrossardConnector(BaseConnector): | |||||
| 89 | info["start_date"] = start[:10] or None | 106 | info["start_date"] = start[:10] or None | ||
| 90 | info["end_date"] = end[:10] or None | 107 | info["end_date"] = end[:10] or None | ||
| 91 | info["time"] = parse_time(start) | 108 | info["time"] = parse_time(start) | ||
| 109 | + info["end_time"] = parse_time(end) | ||||
| 92 | loc = ld.get("location") or [] | 110 | loc = ld.get("location") or [] | ||
| 93 | loc = loc[0] if isinstance(loc, list) and loc else loc | 111 | loc = loc[0] if isinstance(loc, list) and loc else loc | ||
| 94 | if isinstance(loc, dict): | 112 | if isinstance(loc, dict): | ||
@@ -105,7 +123,9 @@ class BrossardConnector(BaseConnector): | |||||
| 105 | if m.group("jour"): | 123 | if m.group("jour"): | ||
| 106 | info["start_date"] = parse_date_fr(m.group("jour")) | 124 | info["start_date"] = parse_date_fr(m.group("jour")) | ||
| 107 | info["end_date"] = info["start_date"] | 125 | info["end_date"] = info["start_date"] | ||
| 108 | − info["time"] = parse_time(m.group("heure") or "") | 126 | + info["time"] = parse_time(m.group("heure") | ||
| 127 | + or m.group("heure2") or "") | ||||
| 128 | + info["end_time"] = parse_time(m.group("fin") or "") | ||||
| 109 | else: | 129 | else: | ||
| 110 | info["start_date"] = parse_date_fr(m.group("du")) | 130 | info["start_date"] = parse_date_fr(m.group("du")) | ||
| 111 | info["end_date"] = parse_date_fr(m.group("au")) | 131 | info["end_date"] = parse_date_fr(m.group("au")) | ||
@@ -140,6 +160,7 @@ class BrossardConnector(BaseConnector): | |||||
| 140 | records = [r for r in self._list() | 160 | records = [r for r in self._list() | ||
| 141 | if r.get("link") and (r.get("title") or {}).get("rendered")] | 161 | if r.get("link") and (r.get("title") or {}).get("rendered")] | ||
| 142 | cache = self._enrich(records) | 162 | cache = self._enrich(records) | ||
| 163 | + type_names = self._type_event_map() | ||||
| 143 | events: list[Event] = [] | 164 | events: list[Event] = [] | ||
| 144 | for rec in records: | 165 | for rec in records: | ||
| 145 | info = cache.get(rec["link"]) or {} | 166 | info = cache.get(rec["link"]) or {} | ||
@@ -148,14 +169,18 @@ class BrossardConnector(BaseConnector): | |||||
| 148 | start = info.get("start_date") or parse_date_fr(title) | 169 | start = info.get("start_date") or parse_date_fr(title) | ||
| 149 | if not start: | 170 | if not start: | ||
| 150 | continue # fiche pas encore visitée et titre sans date | 171 | continue # fiche pas encore visitée et titre sans date | ||
| 172 | + cats = [type_names[t] for t in rec.get("type-event") or [] | ||||
| 173 | + if t in type_names] | ||||
| 151 | events.append(Event( | 174 | events.append(Event( | ||
| 152 | source=self.source_id, | 175 | source=self.source_id, | ||
| 153 | external_id=str(rec["id"]), | 176 | external_id=str(rec["id"]), | ||
| 154 | url=rec["link"], | 177 | url=rec["link"], | ||
| 155 | title=title, | 178 | title=title, | ||
| 179 | + # description intégrale (la coupe à 600 caractères de la | ||||
| 180 | + # vague 1 amputait 90 % des fiches — Phase 2) | ||||
| 156 | description=clean_text( | 181 | description=clean_text( | ||
| 157 | − (rec.get("content") or {}).get("rendered") or "")[:600], | 182 | + (rec.get("content") or {}).get("rendered") or ""), | ||
| 158 | − raw_categories=[title], | 183 | + raw_categories=cats or [title], | ||
| 159 | venue=info.get("venue", ""), | 184 | venue=info.get("venue", ""), | ||
| 160 | address=info.get("address", ""), | 185 | address=info.get("address", ""), | ||
| 161 | postal_code=info.get("postal_code", ""), | 186 | postal_code=info.get("postal_code", ""), | ||
@@ -164,6 +189,7 @@ class BrossardConnector(BaseConnector): | |||||
| 164 | start_date=start, | 189 | start_date=start, | ||
| 165 | start_time=info.get("time"), | 190 | start_time=info.get("time"), | ||
| 166 | end_date=info.get("end_date") or start, | 191 | end_date=info.get("end_date") or start, | ||
| 192 | + end_time=info.get("end_time"), | ||||
| 167 | organizer="Ville de Brossard", | 193 | organizer="Ville de Brossard", | ||
| 168 | image=info.get("image", ""), | 194 | image=info.get("image", ""), | ||
| 169 | )) | 195 | )) | ||
modified
sortika/connectors/evenko.py
+30 −5
@@ -22,14 +22,15 @@ import base64 | |||||
| 22 | import json | 22 | import json | ||
| 23 | from datetime import datetime, timezone | 23 | from datetime import datetime, timezone | ||
| 24 | 24 | ||||
| 25 | −from ..schema import Event | 25 | +from ..schema import Event, normalize_status | ||
| 26 | from .base import BaseConnector | 26 | from .base import BaseConnector | ||
| 27 | 27 | ||||
| 28 | API = "https://evenko.ca/api/search" | 28 | API = "https://evenko.ca/api/search" | ||
| 29 | PAGE_SIZE = 100 | 29 | PAGE_SIZE = 100 | ||
| 30 | 30 | ||||
| 31 | −ATTRS = ["objectID", "title", "subtitle", "headliners", "venue", "show_date", | 31 | +ATTRS = ["objectID", "title", "subtitle", "headliners", "supports", "venue", | ||
| 32 | − "show_time", "hide_show_time", "category", "genre", "description", | 32 | + "show_date", "show_time", "hide_show_time", "door_time", "tour_name", | ||
| 33 | + "category", "genre", "description", | ||||
| 33 | "free", "age", "thumbnail", "status", "additional_information", | 34 | "free", "age", "thumbnail", "status", "additional_information", | ||
| 34 | "presented_by", "event_tag", "promotional_tag"] | 35 | "presented_by", "event_tag", "promotional_tag"] | ||
| 35 | 36 | ||||
@@ -110,8 +111,31 @@ class EvenkoConnector(BaseConnector): | |||||
| 110 | # explicitement de la cacher (hide_show_time) | 111 | # explicitement de la cacher (hide_show_time) | ||
| 111 | start_time = (None if hit.get("hide_show_time") | 112 | start_time = (None if hit.get("hide_show_time") | ||
| 112 | else _unix_to_local_time(hit.get("show_time"))) | 113 | else _unix_to_local_time(hit.get("show_time"))) | ||
| 113 | − # artistes : champ explicite `headliners` de la source | 114 | + # artistes : têtes d'affiche (`headliners`) + premières | ||
| 115 | + # parties (`supports`) — champs explicites de la source | ||||
| 114 | artists = [str(a) for a in hit.get("headliners") or [] if a] | 116 | artists = [str(a) for a in hit.get("headliners") or [] if a] | ||
| 117 | + artists += [str(a) for a in hit.get("supports") or [] if a] | ||||
| 118 | + # statut billetterie : additional_information.representation_status | ||||
| 119 | + # (['buy_now'], ['sold_out'], ['cancelled']…) — jamais inventé | ||||
| 120 | + rep = (hit.get("additional_information") or {}) \ | ||||
| 121 | + .get("representation_status") or [] | ||||
| 122 | + status = "" | ||||
| 123 | + for token in (rep if isinstance(rep, list) else [rep]): | ||||
| 124 | + status = normalize_status(str(token)) | ||||
| 125 | + if status: | ||||
| 126 | + break | ||||
| 127 | + # description enrichie : tournée + heure des portes (factuels, | ||||
| 128 | + # publiés par la source ; aucun champ dédié au schéma) | ||||
| 129 | + description = hit.get("description") or "" | ||||
| 130 | + extras = [] | ||||
| 131 | + if hit.get("tour_name"): | ||||
| 132 | + extras.append(f"Tournée : {str(hit['tour_name']).strip()}") | ||||
| 133 | + door = _unix_to_local_time(hit.get("door_time")) | ||||
| 134 | + if door: | ||||
| 135 | + extras.append(f"Portes : {door.replace(':', ' h ')}") | ||||
| 136 | + if extras: | ||||
| 137 | + description = " — ".join( | ||||
| 138 | + p for p in (description, " · ".join(extras)) if p) | ||||
| 115 | # gratuité : champ `free` (aujourd'hui toujours null côté | 139 | # gratuité : champ `free` (aujourd'hui toujours null côté | ||
| 116 | # source, gardé au cas où il serait re-rempli) + étiquettes | 140 | # source, gardé au cas où il serait re-rempli) + étiquettes | ||
| 117 | # explicites « gratuit » (event_tag / promotional_tag). | 141 | # explicites « gratuit » (event_tag / promotional_tag). | ||
@@ -124,7 +148,7 @@ class EvenkoConnector(BaseConnector): | |||||
| 124 | external_id=ext_id, | 148 | external_id=ext_id, | ||
| 125 | url=_ticket_url(hit) or (venue.get("website") or "https://evenko.ca/fr/calendrier"), | 149 | url=_ticket_url(hit) or (venue.get("website") or "https://evenko.ca/fr/calendrier"), | ||
| 126 | title=title, | 150 | title=title, | ||
| 127 | − description=hit.get("description") or "", | 151 | + description=description, | ||
| 128 | raw_categories=[c for c in cats if c] or ["Concert"], | 152 | raw_categories=[c for c in cats if c] or ["Concert"], | ||
| 129 | audience={"everyone": "Pour tous", "all_ages": "Pour tous", | 153 | audience={"everyone": "Pour tous", "all_ages": "Pour tous", | ||
| 130 | "18+": "18 ans et plus", "16+": "16 ans et plus", | 154 | "18+": "18 ans et plus", "16+": "16 ans et plus", | ||
@@ -134,6 +158,7 @@ class EvenkoConnector(BaseConnector): | |||||
| 134 | start_date=day, | 158 | start_date=day, | ||
| 135 | start_time=start_time, | 159 | start_time=start_time, | ||
| 136 | end_date=day, | 160 | end_date=day, | ||
| 161 | + status=status, | ||||
| 137 | artists=artists, | 162 | artists=artists, | ||
| 138 | is_free=is_free, | 163 | is_free=is_free, | ||
| 139 | organizer="evenko", | 164 | organizer="evenko", | ||
modified
sortika/connectors/eventbrite.py
+54 −20
@@ -58,8 +58,10 @@ def _server_data(html: str) -> dict: | |||||
| 58 | return data | 58 | return data | ||
| 59 | 59 | ||||
| 60 | 60 | ||||
| 61 | −def _parse_offers(html: str) -> dict | None: | 61 | +def _parse_detail(html: str) -> dict: | ||
| 62 | − """AggregateOffer (lowPrice/highPrice/priceCurrency) du JSON-LD d'une fiche.""" | 62 | + """JSON-LD d'une fiche : AggregateOffer (lowPrice/highPrice/priceCurrency) | ||
| 63 | + + organisateur (organizer.name) + description complète — même requête.""" | ||||
| 64 | + price, organizer, description = None, "", "" | ||||
| 63 | for m in _LD_JSON_RE.finditer(html): | 65 | for m in _LD_JSON_RE.finditer(html): | ||
| 64 | try: | 66 | try: | ||
| 65 | d, _ = json.JSONDecoder().raw_decode(html[m.end():].lstrip()) | 67 | d, _ = json.JSONDecoder().raw_decode(html[m.end():].lstrip()) | ||
@@ -68,8 +70,14 @@ def _parse_offers(html: str) -> dict | None: | |||||
| 68 | for doc in (d if isinstance(d, list) else [d]): | 70 | for doc in (d if isinstance(d, list) else [d]): | ||
| 69 | if not isinstance(doc, dict): | 71 | if not isinstance(doc, dict): | ||
| 70 | continue | 72 | continue | ||
| 73 | + org = doc.get("organizer") | ||||
| 74 | + if isinstance(org, dict) and org.get("name") and not organizer: | ||||
| 75 | + organizer = str(org["name"]) | ||||
| 76 | + desc = doc.get("description") | ||||
| 77 | + if isinstance(desc, str) and len(desc) > len(description): | ||||
| 78 | + description = desc[:2000] | ||||
| 71 | offers = doc.get("offers") | 79 | offers = doc.get("offers") | ||
| 72 | − if not offers: | 80 | + if not offers or price is not None: | ||
| 73 | continue | 81 | continue | ||
| 74 | for off in (offers if isinstance(offers, list) else [offers]): | 82 | for off in (offers if isinstance(offers, list) else [offers]): | ||
| 75 | if not isinstance(off, dict) or off.get("@type") != "AggregateOffer": | 83 | if not isinstance(off, dict) or off.get("@type") != "AggregateOffer": | ||
@@ -79,9 +87,10 @@ def _parse_offers(html: str) -> dict | None: | |||||
| 79 | high = float(off.get("highPrice") or off.get("lowPrice")) | 87 | high = float(off.get("highPrice") or off.get("lowPrice")) | ||
| 80 | except (TypeError, ValueError): | 88 | except (TypeError, ValueError): | ||
| 81 | continue | 89 | continue | ||
| 82 | − return {"low": low, "high": high, | 90 | + price = {"low": low, "high": high, | ||
| 83 | − "cur": off.get("priceCurrency") or ""} | 91 | + "cur": off.get("priceCurrency") or ""} | ||
| 84 | − return None | 92 | + break | ||
| 93 | + return {"price": price, "organizer": organizer, "description": description} | ||||
| 85 | 94 | ||||
| 86 | 95 | ||||
| 87 | class EventbriteConnector(BaseConnector): | 96 | class EventbriteConnector(BaseConnector): | ||
@@ -110,17 +119,34 @@ class EventbriteConnector(BaseConnector): | |||||
| 110 | return results | 119 | return results | ||
| 111 | 120 | ||||
| 112 | # -- fiche → Event --------------------------------------------------------- | 121 | # -- fiche → Event --------------------------------------------------------- | ||
| 113 | − def _result_to_event(self, r: dict, price: dict | None) -> Event | None: | 122 | + def _result_to_event(self, r: dict, detail: dict | None) -> Event | None: | ||
| 114 | ext_id = str(r.get("id") or r.get("eventbrite_event_id") or "") | 123 | ext_id = str(r.get("id") or r.get("eventbrite_event_id") or "") | ||
| 115 | title = r.get("name") or "" | 124 | title = r.get("name") or "" | ||
| 116 | if not ext_id or not title: | 125 | if not ext_id or not title: | ||
| 117 | return None | 126 | return None | ||
| 118 | − if r.get("is_online_event") or r.get("is_cancelled"): | 127 | + if r.get("is_online_event"): | ||
| 119 | return None | 128 | return None | ||
| 120 | venue = r.get("primary_venue") or {} | 129 | venue = r.get("primary_venue") or {} | ||
| 121 | addr = venue.get("address") or {} | 130 | addr = venue.get("address") or {} | ||
| 122 | if (addr.get("region") or "") != "QC": # filtre Québec strict | 131 | if (addr.get("region") or "") != "QC": # filtre Québec strict | ||
| 123 | return None | 132 | return None | ||
| 133 | + # statut billetterie (Phase 2) : annulé (is_cancelled — l'événement est | ||||
| 134 | + # conservé et affiché barré/bandeau) ; complet via urgency_signals | ||||
| 135 | + # (catégorie/message « sold out » explicite seulement, jamais déduit). | ||||
| 136 | + status = "cancelled" if r.get("is_cancelled") else "" | ||||
| 137 | + if not status: | ||||
| 138 | + us = r.get("urgency_signals") or {} | ||||
| 139 | + signals = " ".join([*(us.get("messages") or []), | ||||
| 140 | + *(us.get("categories") or [])]).lower() | ||||
| 141 | + if "sold" in signals and "out" in signals: | ||||
| 142 | + status = "soldout" | ||||
| 143 | + price = (detail or {}).get("price") | ||||
| 144 | + organizer = (detail or {}).get("organizer") or "" | ||||
| 145 | + # description : full_description (vide dans le flux de liste, constaté | ||||
| 146 | + # 2026-08-19) > JSON-LD de la fiche détail > summary (140 caractères) | ||||
| 147 | + description = (r.get("full_description") | ||||
| 148 | + or (detail or {}).get("description") | ||||
| 149 | + or r.get("summary") or "") | ||||
| 124 | cats: list[str] = [] | 150 | cats: list[str] = [] | ||
| 125 | for t in r.get("tags") or []: | 151 | for t in r.get("tags") or []: | ||
| 126 | if (t.get("prefix") or "").startswith("Eventbrite"): | 152 | if (t.get("prefix") or "").startswith("Eventbrite"): | ||
@@ -148,7 +174,7 @@ class EventbriteConnector(BaseConnector): | |||||
| 148 | external_id=ext_id, | 174 | external_id=ext_id, | ||
| 149 | url=r.get("url") or "", | 175 | url=r.get("url") or "", | ||
| 150 | title=title, | 176 | title=title, | ||
| 151 | − description=r.get("summary") or "", | 177 | + description=description, | ||
| 152 | raw_categories=cats, | 178 | raw_categories=cats, | ||
| 153 | venue=venue.get("name") or "", | 179 | venue=venue.get("name") or "", | ||
| 154 | address=addr.get("address_1") or "", | 180 | address=addr.get("address_1") or "", | ||
@@ -159,16 +185,20 @@ class EventbriteConnector(BaseConnector): | |||||
| 159 | start_date=r.get("start_date"), | 185 | start_date=r.get("start_date"), | ||
| 160 | start_time=r.get("start_time"), | 186 | start_time=r.get("start_time"), | ||
| 161 | end_date=r.get("end_date"), | 187 | end_date=r.get("end_date"), | ||
| 188 | + end_time=r.get("end_time"), | ||||
| 189 | + status=status, | ||||
| 162 | is_free=is_free, | 190 | is_free=is_free, | ||
| 163 | price_min=price_min, | 191 | price_min=price_min, | ||
| 164 | price_label=label, | 192 | price_label=label, | ||
| 193 | + organizer=organizer, | ||||
| 165 | image=img, | 194 | image=img, | ||
| 166 | ) | 195 | ) | ||
| 167 | 196 | ||||
| 168 | − # -- enrichissement prix (incrémental, plafonné) --------------------------- | 197 | + # -- enrichissement prix + organisateur (incrémental, plafonné) ------------ | ||
| 169 | − def _enrich_prices(self, rows: list[dict]) -> dict: | 198 | + def _enrich_details(self, rows: list[dict]) -> dict: | ||
| 170 | − """Cache {event_id: {fetched_at, price|None}} ; visite les fiches à | 199 | + """Cache {event_id: {fetched_at, price|None, organizer}} ; fiches | ||
| 171 | − venir sans prix connu d'abord, re-visite roulante après 7 jours.""" | 200 | + jamais visitées d'abord, puis celles d'avant la Phase 2 (sans clé | ||
| 201 | + organizer), puis re-visite roulante après 7 jours.""" | ||||
| 172 | cache = self.load_cache() | 202 | cache = self.load_cache() | ||
| 173 | now = time.time() | 203 | now = time.time() | ||
| 174 | current = {str(r.get("id")): r for r in rows if r.get("id")} | 204 | current = {str(r.get("id")): r for r in rows if r.get("id")} | ||
@@ -178,18 +208,22 @@ class EventbriteConnector(BaseConnector): | |||||
| 178 | (eid for eid, r in current.items() | 208 | (eid for eid, r in current.items() | ||
| 179 | if r.get("url") | 209 | if r.get("url") | ||
| 180 | and (eid not in cache | 210 | and (eid not in cache | ||
| 211 | + or "organizer" not in cache[eid] | ||||
| 212 | + or "description" not in cache[eid] | ||||
| 181 | or now - cache[eid].get("fetched_at", 0) > REFRESH_AFTER)), | 213 | or now - cache[eid].get("fetched_at", 0) > REFRESH_AFTER)), | ||
| 182 | key=lambda eid: cache.get(eid, {}).get("fetched_at", 0))[:EB_DETAIL_MAX] | 214 | key=lambda eid: cache.get(eid, {}).get("fetched_at", 0))[:EB_DETAIL_MAX] | ||
| 183 | url_to_id = {current[eid]["url"]: eid for eid in candidates} | 215 | url_to_id = {current[eid]["url"]: eid for eid in candidates} | ||
| 184 | 216 | ||||
| 185 | def worker(url, session): | 217 | def worker(url, session): | ||
| 186 | − return _parse_offers(session.get(url, timeout=20).text) | 218 | + return _parse_detail(session.get(url, timeout=20).text) | ||
| 187 | 219 | ||||
| 188 | − for url, price in self.fetch_many(list(url_to_id), worker, | 220 | + for url, detail in self.fetch_many(list(url_to_id), worker, | ||
| 189 | − max_workers=4).items(): | 221 | + max_workers=4).items(): | ||
| 190 | − cache[url_to_id[url]] = {"fetched_at": now, "price": price} | 222 | + entry = {"fetched_at": now} | (detail or { | ||
| 223 | + "price": None, "organizer": "", "description": ""}) | ||||
| 224 | + cache[url_to_id[url]] = entry | ||||
| 191 | self.save_cache(cache) | 225 | self.save_cache(cache) | ||
| 192 | − return {eid: (entry or {}).get("price") for eid, entry in cache.items()} | 226 | + return cache | ||
| 193 | 227 | ||||
| 194 | # -- interface -------------------------------------------------------------- | 228 | # -- interface -------------------------------------------------------------- | ||
| 195 | def fetch(self) -> list[Event]: | 229 | def fetch(self) -> list[Event]: | ||
@@ -200,10 +234,10 @@ class EventbriteConnector(BaseConnector): | |||||
| 200 | eid = str(r.get("id") or "") | 234 | eid = str(r.get("id") or "") | ||
| 201 | if eid: | 235 | if eid: | ||
| 202 | uniq.setdefault(eid, r) | 236 | uniq.setdefault(eid, r) | ||
| 203 | − prices = self._enrich_prices(list(uniq.values())) | 237 | + details = self._enrich_details(list(uniq.values())) | ||
| 204 | events: list[Event] = [] | 238 | events: list[Event] = [] | ||
| 205 | for eid, r in uniq.items(): | 239 | for eid, r in uniq.items(): | ||
| 206 | − ev = self._result_to_event(r, prices.get(eid)) | 240 | + ev = self._result_to_event(r, details.get(eid)) | ||
| 207 | if ev is not None: | 241 | if ev is not None: | ||
| 208 | events.append(ev) | 242 | events.append(ev) | ||
| 209 | return events | 243 | return events | ||
modified
sortika/connectors/laval.py
+11 −0
@@ -10,10 +10,15 @@ | |||||
| 10 | # ----------------------------------------------------------------------------- | 10 | # ----------------------------------------------------------------------------- | ||
| 11 | from __future__ import annotations | 11 | from __future__ import annotations | ||
| 12 | 12 | ||||
| 13 | +import re | ||||
| 14 | + | ||||
| 13 | from ..normalize import utc_to_local | 15 | from ..normalize import utc_to_local | ||
| 14 | from ..schema import Event | 16 | from ..schema import Event | ||
| 15 | from .base import BaseConnector | 17 | from .base import BaseConnector | ||
| 16 | 18 | ||||
| 19 | +# la Ville publie l'annulation DANS le titre : « ANNULÉ - Rencontre avec … » | ||||
| 20 | +_CANCELLED_RE = re.compile(r"^\s*annul[eé]e?\s*[-–—:]\s*", re.I) | ||||
| 21 | + | ||||
| 17 | FEED = ("https://www.donneesquebec.ca/recherche/dataset/" | 22 | FEED = ("https://www.donneesquebec.ca/recherche/dataset/" | ||
| 18 | "df7add74-a741-4374-b3bd-22da8921c8b3/resource/" | 23 | "df7add74-a741-4374-b3bd-22da8921c8b3/resource/" | ||
| 19 | "b51a25de-bd06-4247-87ba-2b1ea8228005/download/calendrier-activites.json") | 24 | "b51a25de-bd06-4247-87ba-2b1ea8228005/download/calendrier-activites.json") | ||
@@ -34,6 +39,11 @@ class LavalConnector(BaseConnector): | |||||
| 34 | page = (rec.get("PageUrl") or "").strip() | 39 | page = (rec.get("PageUrl") or "").strip() | ||
| 35 | if not title or not page: | 40 | if not title or not page: | ||
| 36 | continue | 41 | continue | ||
| 42 | + # statut : préfixe « ANNULÉ - » du titre → cancelled (titre nettoyé) | ||||
| 43 | + status = "" | ||||
| 44 | + if _CANCELLED_RE.match(title): | ||||
| 45 | + status = "cancelled" | ||||
| 46 | + title = _CANCELLED_RE.sub("", title).strip() | ||||
| 37 | image = rec.get("ImageUrl") or "" | 47 | image = rec.get("ImageUrl") or "" | ||
| 38 | # une activité récurrente = une entrée par occurrence, même PageUrl | 48 | # une activité récurrente = une entrée par occurrence, même PageUrl | ||
| 39 | # → l'id externe inclut la date pour ne pas écraser les occurrences | 49 | # → l'id externe inclut la date pour ne pas écraser les occurrences | ||
@@ -60,6 +70,7 @@ class LavalConnector(BaseConnector): | |||||
| 60 | start_date=start_local or rec.get("EventDate"), | 70 | start_date=start_local or rec.get("EventDate"), | ||
| 61 | start_time=time_local, | 71 | start_time=time_local, | ||
| 62 | end_date=end_local or rec.get("EndDate"), | 72 | end_date=end_local or rec.get("EndDate"), | ||
| 73 | + status=status, | ||||
| 63 | organizer="Ville de Laval", | 74 | organizer="Ville de Laval", | ||
| 64 | image=(SITE + image) if image.startswith("/") else image, | 75 | image=(SITE + image) if image.startswith("/") else image, | ||
| 65 | )) | 76 | )) | ||
modified
sortika/connectors/lavitrine.py
+34 −9
@@ -6,8 +6,10 @@ | |||||
| 6 | # concerts (Grand Montréal + Québec + tournées). | 6 | # concerts (Grand Montréal + Québec + tournées). | ||
| 7 | # Extraction: sitemap.xml public (~2 000 fiches /fr/evenement/ + 185 | 7 | # Extraction: sitemap.xml public (~2 000 fiches /fr/evenement/ + 185 | ||
| 8 | # /fr/exposition/, lastmod par URL) puis pages fiche rendues | 8 | # /fr/exposition/, lastmod par URL) puis pages fiche rendues | ||
| 9 | −# serveur (h1, bloc .event_calendar : jour/mois/année, salle, | 9 | +# serveur (h1, bloc .event_calendar : jour/mois/année, HEURE | ||
| 10 | −# ville ; og:image). GET directs — pas d'anti-bot. | 10 | +# (« 15:00 HNE » dans la slide), salle, ville ; og:image ; | ||
| 11 | +# description = event_tabs_description-short + about-description). | ||||
| 12 | +# GET directs — pas d'anti-bot. | ||||
| 11 | # INCRÉMENTAL avec cache disque (data/lavitrine_cache.json) : | 13 | # INCRÉMENTAL avec cache disque (data/lavitrine_cache.json) : | ||
| 12 | # nouvelles fiches d'abord, re-visite roulante après 7 jours, | 14 | # nouvelles fiches d'abord, re-visite roulante après 7 jours, | ||
| 13 | # plafonné à LV_MAX_FETCH fiches/sync. | 15 | # plafonné à LV_MAX_FETCH fiches/sync. | ||
@@ -44,13 +46,19 @@ _DATE_RE = re.compile( | |||||
| 44 | r'<div class="event_calendar_slide-calender-date-text-small">([^<]+)</div>' | 46 | r'<div class="event_calendar_slide-calender-date-text-small">([^<]+)</div>' | ||
| 45 | r'<div class="event_calendar_slide-calender-date-text-small">(\d{4})</div>') | 47 | r'<div class="event_calendar_slide-calender-date-text-small">(\d{4})</div>') | ||
| 46 | _PLACE_RE = re.compile(r'class="text-style-2lines">([^<]*)</div><div>([^<]*)</div>') | 48 | _PLACE_RE = re.compile(r'class="text-style-2lines">([^<]*)</div><div>([^<]*)</div>') | ||
| 49 | +# l'heure de la représentation, dans la même slide, entre la date et la salle : | ||||
| 50 | +# <div class="text-size-small text-height-1-2">15:00 HNE</div> | ||||
| 51 | +_TIME_RE = re.compile(r'class="text-size-small[^"]*">\s*(\d{1,2}[:h]\d{2})') | ||||
| 52 | +# description de la fiche : accroche courte + début du texte long (onglet À propos) | ||||
| 53 | +_DESC_SHORT_RE = re.compile(r'event_tabs_description-short">(.*?)</div>', re.S) | ||||
| 54 | +_DESC_LONG_RE = re.compile(r'id="about-description">(.*?)</div>', re.S) | ||||
| 47 | 55 | ||||
| 48 | 56 | ||||
| 49 | _SLIDE_A_RE = re.compile( | 57 | _SLIDE_A_RE = re.compile( | ||
| 50 | r'<a[^>]*href="([^"]*)"[^>]*class="event_calendar_slide-content(.*?)</a>', re.S) | 58 | r'<a[^>]*href="([^"]*)"[^>]*class="event_calendar_slide-content(.*?)</a>', re.S) | ||
| 51 | 59 | ||||
| 52 | 60 | ||||
| 53 | −def _slides(html: str) -> list[tuple[str, str, str, str, str, str, str]]: | 61 | +def _slides(html: str) -> list[tuple[str, str, str, str, str, str, str, str | None]]: | ||
| 54 | """Slides du calendrier : (id de fiche de la slide, url canonique, jour, | 62 | """Slides du calendrier : (id de fiche de la slide, url canonique, jour, | ||
| 55 | mois, année, salle, ville). | 63 | mois, année, salle, ville). | ||
| 56 | 64 | ||||
@@ -71,8 +79,10 @@ def _slides(html: str) -> list[tuple[str, str, str, str, str, str, str]]: | |||||
| 71 | if not d: | 79 | if not d: | ||
| 72 | continue | 80 | continue | ||
| 73 | p = _PLACE_RE.search(block) | 81 | p = _PLACE_RE.search(block) | ||
| 82 | + t = _TIME_RE.search(block) | ||||
| 74 | out.append((slide_id, canon, d.group(1), d.group(2), d.group(3), | 83 | out.append((slide_id, canon, d.group(1), d.group(2), d.group(3), | ||
| 75 | − p.group(1) if p else "", p.group(2) if p else "")) | 84 | + p.group(1) if p else "", p.group(2) if p else "", | ||
| 85 | + t.group(1).replace("h", ":") if t else None)) | ||||
| 76 | return out | 86 | return out | ||
| 77 | 87 | ||||
| 78 | 88 | ||||
@@ -99,10 +109,17 @@ class LaVitrineConnector(BaseConnector): | |||||
| 99 | return [] | 109 | return [] | ||
| 100 | img = _OG_IMG_RE.search(html) | 110 | img = _OG_IMG_RE.search(html) | ||
| 101 | image = img.group(1) if img else "" | 111 | image = img.group(1) if img else "" | ||
| 112 | + # description : accroche courte de la fiche + début du texte long | ||||
| 113 | + # (déjà dans le HTML téléchargé — Phase 2, gisement n° 1 de l'audit) | ||||
| 114 | + d_short = _DESC_SHORT_RE.search(html) | ||||
| 115 | + d_long = _DESC_LONG_RE.search(html) | ||||
| 116 | + description = " ".join(p for p in ( | ||||
| 117 | + clean_text(d_short.group(1)) if d_short else "", | ||||
| 118 | + clean_text(d_long.group(1)) if d_long else "") if p)[:2000] | ||||
| 102 | ext = url.rstrip("/").rsplit("/", 1)[-1] # id numérique stable | 119 | ext = url.rstrip("/").rsplit("/", 1)[-1] # id numérique stable | ||
| 103 | slug = url.rstrip("/").split("/")[-2] | 120 | slug = url.rstrip("/").split("/")[-2] | ||
| 104 | rows = [] | 121 | rows = [] | ||
| 105 | − for slide_id, canon, day, month_txt, year, venue, city in _slides(html)[:60]: | 122 | + for slide_id, canon, day, month_txt, year, venue, city, hhmm in _slides(html)[:60]: | ||
| 106 | mo = _MONTHS.get(clean_text(month_txt).lower()[:4].rstrip(".")) \ | 123 | mo = _MONTHS.get(clean_text(month_txt).lower()[:4].rstrip(".")) \ | ||
| 107 | or _MONTHS.get(clean_text(month_txt).lower()[:3]) | 124 | or _MONTHS.get(clean_text(month_txt).lower()[:3]) | ||
| 108 | if not mo: | 125 | if not mo: | ||
@@ -113,9 +130,11 @@ class LaVitrineConnector(BaseConnector): | |||||
| 113 | # même date vue depuis plusieurs fiches → même uid, zéro doublon | 130 | # même date vue depuis plusieurs fiches → même uid, zéro doublon | ||
| 114 | "external_id": f"{slide_id or ext}-{iso}", | 131 | "external_id": f"{slide_id or ext}-{iso}", | ||
| 115 | "url": canon or url, "title": title, "image": image, | 132 | "url": canon or url, "title": title, "image": image, | ||
| 133 | + "description": description, | ||||
| 116 | "raw_categories": [title, slug.replace("-", " ")], | 134 | "raw_categories": [title, slug.replace("-", " ")], | ||
| 117 | "venue": clean_text(venue or ""), "city": clean_text(city or ""), | 135 | "venue": clean_text(venue or ""), "city": clean_text(city or ""), | ||
| 118 | "start_date": iso, "end_date": iso, | 136 | "start_date": iso, "end_date": iso, | ||
| 137 | + "start_time": hhmm, # heure de la slide (« 15:00 HNE ») | ||||
| 119 | }) | 138 | }) | ||
| 120 | return rows | 139 | return rows | ||
| 121 | 140 | ||||
@@ -126,11 +145,15 @@ class LaVitrineConnector(BaseConnector): | |||||
| 126 | cache = {u: c for u, c in cache.items() if u in current} | 145 | cache = {u: c for u, c in cache.items() if u in current} | ||
| 127 | 146 | ||||
| 128 | now = time.time() | 147 | now = time.time() | ||
| 129 | − # nouvelles fiches d'abord, puis les plus anciennes visites (roulant) | 148 | + # nouvelles fiches d'abord, puis celles parsées avant la Phase 2 | ||
| 149 | + # (v < 2 : sans heure ni description — montée progressive), puis les | ||||
| 150 | + # plus anciennes visites (roulant) | ||||
| 130 | candidates = sorted( | 151 | candidates = sorted( | ||
| 131 | (u for u in urls | 152 | (u for u in urls | ||
| 132 | − if u not in cache or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER), | 153 | + if u not in cache or cache[u].get("v", 1) < 2 | ||
| 133 | − key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:LV_MAX_FETCH] | 154 | + or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER), | ||
| 155 | + key=lambda u: (cache.get(u, {}).get("v", 1) if u in cache else 0, | ||||
| 156 | + cache.get(u, {}).get("fetched_at", 0)))[:LV_MAX_FETCH] | ||||
| 134 | 157 | ||||
| 135 | def worker(url, session): | 158 | def worker(url, session): | ||
| 136 | return self._parse_fiche(url, session.get(url, timeout=20).text) | 159 | return self._parse_fiche(url, session.get(url, timeout=20).text) | ||
@@ -139,7 +162,7 @@ class LaVitrineConnector(BaseConnector): | |||||
| 139 | if rows is None: # fiche cassée : on ne bloque pas la source | 162 | if rows is None: # fiche cassée : on ne bloque pas la source | ||
| 140 | print(f"[sorti-ka] lavitrine : fiche ignorée {url}") | 163 | print(f"[sorti-ka] lavitrine : fiche ignorée {url}") | ||
| 141 | continue | 164 | continue | ||
| 142 | − cache[url] = {"fetched_at": now, "rows": rows} | 165 | + cache[url] = {"fetched_at": now, "rows": rows, "v": 2} | ||
| 143 | self.save_cache(cache) | 166 | self.save_cache(cache) | ||
| 144 | 167 | ||||
| 145 | events: list[Event] = [] | 168 | events: list[Event] = [] | ||
@@ -153,9 +176,11 @@ class LaVitrineConnector(BaseConnector): | |||||
| 153 | source=self.source_id, | 176 | source=self.source_id, | ||
| 154 | external_id=row["external_id"], | 177 | external_id=row["external_id"], | ||
| 155 | url=row["url"], title=row["title"], | 178 | url=row["url"], title=row["title"], | ||
| 179 | + description=row.get("description", ""), | ||||
| 156 | raw_categories=row.get("raw_categories") or [], | 180 | raw_categories=row.get("raw_categories") or [], | ||
| 157 | venue=row.get("venue", ""), city=row.get("city", ""), | 181 | venue=row.get("venue", ""), city=row.get("city", ""), | ||
| 158 | start_date=row.get("start_date"), | 182 | start_date=row.get("start_date"), | ||
| 183 | + start_time=row.get("start_time"), | ||||
| 159 | end_date=row.get("end_date"), | 184 | end_date=row.get("end_date"), | ||
| 160 | image=row.get("image", ""), | 185 | image=row.get("image", ""), | ||
| 161 | )) | 186 | )) | ||
modified
sortika/connectors/longueuil.py
+23 −1
@@ -42,6 +42,14 @@ _EXCERPT_RE = re.compile(r"event__excerpt[^>]*>\s*(.*?)\s*</p>", re.S) | |||||
| 42 | _ADDR_RE = re.compile(r"address_line1:\"([^\"]*)\"") | 42 | _ADDR_RE = re.compile(r"address_line1:\"([^\"]*)\"") | ||
| 43 | _POSTAL_RE = re.compile(r"postal_code:\"([^\"]*)\"") | 43 | _POSTAL_RE = re.compile(r"postal_code:\"([^\"]*)\"") | ||
| 44 | _LOCALITY_RE = re.compile(r"locality:\"([^\"]*)\"") | 44 | _LOCALITY_RE = re.compile(r"locality:\"([^\"]*)\"") | ||
| 45 | +# visuel de la fiche : URL mediaImages du CMS dans l'état Nuxt (styles | ||||
| 46 | +# front_medium_16_9 / front_large_21_9), échappée / | ||||
| 47 | +_IMG_RES = ( | ||||
| 48 | + re.compile(r"\"(https:\\u002F\\u002Fcms\.longueuil\.quebec\\u002Fsites" | ||||
| 49 | + r"[^\"]*?front_medium_16_9[^\"]*?)\""), | ||||
| 50 | + re.compile(r"\"(https:\\u002F\\u002Fcms\.longueuil\.quebec\\u002Fsites" | ||||
| 51 | + r"[^\"]*?front_large_21_9[^\"]*?)\""), | ||||
| 52 | +) | ||||
| 45 | 53 | ||||
| 46 | 54 | ||||
| 47 | def _js_str(raw: str) -> str: | 55 | def _js_str(raw: str) -> str: | ||
@@ -89,6 +97,7 @@ class LongueuilConnector(BaseConnector): | |||||
| 89 | to_fetch = sorted( | 97 | to_fetch = sorted( | ||
| 90 | (u for u in current | 98 | (u for u in current | ||
| 91 | if u not in cache | 99 | if u not in cache | ||
| 100 | + or "image" not in cache[u] # entrées d'avant la Phase 2 | ||||
| 92 | or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER), | 101 | or now - cache[u].get("fetched_at", 0) > REFRESH_AFTER), | ||
| 93 | key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:LGL_FICHE_MAX] | 102 | key=lambda u: cache.get(u, {}).get("fetched_at", 0))[:LGL_FICHE_MAX] | ||
| 94 | 103 | ||||
@@ -97,9 +106,16 @@ class LongueuilConnector(BaseConnector): | |||||
| 97 | addr = _ADDR_RE.search(html) | 106 | addr = _ADDR_RE.search(html) | ||
| 98 | postal = _POSTAL_RE.search(html) | 107 | postal = _POSTAL_RE.search(html) | ||
| 99 | loc = _LOCALITY_RE.search(html) | 108 | loc = _LOCALITY_RE.search(html) | ||
| 109 | + image = "" | ||||
| 110 | + for img_re in _IMG_RES: | ||||
| 111 | + m = img_re.search(html) | ||||
| 112 | + if m: | ||||
| 113 | + image = _js_str(m.group(1)) | ||||
| 114 | + break | ||||
| 100 | return {"address": _js_str(addr.group(1)) if addr else "", | 115 | return {"address": _js_str(addr.group(1)) if addr else "", | ||
| 101 | "postal": _js_str(postal.group(1)) if postal else "", | 116 | "postal": _js_str(postal.group(1)) if postal else "", | ||
| 102 | − "city": _js_str(loc.group(1)) if loc else ""} | 117 | + "city": _js_str(loc.group(1)) if loc else "", | ||
| 118 | + "image": image} | ||||
| 103 | 119 | ||||
| 104 | for url, info in self.fetch_many(to_fetch, worker, max_workers=4).items(): | 120 | for url, info in self.fetch_many(to_fetch, worker, max_workers=4).items(): | ||
| 105 | if info is not None: | 121 | if info is not None: | ||
@@ -111,6 +127,7 @@ class LongueuilConnector(BaseConnector): | |||||
| 111 | r["address"] = info.get("address", "") | 127 | r["address"] = info.get("address", "") | ||
| 112 | r["postal"] = info.get("postal", "") | 128 | r["postal"] = info.get("postal", "") | ||
| 113 | r["city"] = info.get("city") or "Longueuil" | 129 | r["city"] = info.get("city") or "Longueuil" | ||
| 130 | + r["image"] = info.get("image", "") | ||||
| 114 | 131 | ||||
| 115 | def fetch(self) -> list[Event]: | 132 | def fetch(self) -> list[Event]: | ||
| 116 | rows = self._cards(self.get(LIST_URL).text) | 133 | rows = self._cards(self.get(LIST_URL).text) | ||
@@ -126,6 +143,9 @@ class LongueuilConnector(BaseConnector): | |||||
| 126 | start_time = parse_time(r["start"]) | 143 | start_time = parse_time(r["start"]) | ||
| 127 | if start_time == "00:00": # minuit = « toute la journée » | 144 | if start_time == "00:00": # minuit = « toute la journée » | ||
| 128 | start_time = None | 145 | start_time = None | ||
| 146 | + end_time = parse_time(r["end"]) if r["end"] != r["start"] else None | ||||
| 147 | + if end_time == "00:00": | ||||
| 148 | + end_time = None | ||||
| 129 | events.append(Event( | 149 | events.append(Event( | ||
| 130 | source=self.source_id, | 150 | source=self.source_id, | ||
| 131 | external_id=r["external_id"], | 151 | external_id=r["external_id"], | ||
@@ -140,5 +160,7 @@ class LongueuilConnector(BaseConnector): | |||||
| 140 | start_date=start_date, | 160 | start_date=start_date, | ||
| 141 | start_time=start_time, | 161 | start_time=start_time, | ||
| 142 | end_date=end_date or start_date, | 162 | end_date=end_date or start_date, | ||
| 163 | + end_time=end_time, | ||||
| 164 | + image=r.get("image", ""), | ||||
| 143 | )) | 165 | )) | ||
| 144 | return events | 166 | return events | ||
modified
sortika/connectors/montreal.py
+13 −7
@@ -26,9 +26,10 @@ PAGE = 1000 | |||||
| 26 | IMG_MAX = int(os.environ.get("MTL_IMG_MAX", "300")) | 26 | IMG_MAX = int(os.environ.get("MTL_IMG_MAX", "300")) | ||
| 27 | 27 | ||||
| 28 | _OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"') | 28 | _OG_IMG_RE = re.compile(r'property="og:image" content="([^"]*)"') | ||
| 29 | −# heure locale de la fiche montreal.ca (JSON-LD Event, offset Québec inclus) : | 29 | +# heures locales de la fiche montreal.ca (JSON-LD Event, offset Québec inclus) : | ||
| 30 | −# "startDate":"2026-10-14T19:30:00-04:00" → "19:30" | 30 | +# "startDate":"2026-10-14T19:30:00-04:00" → "19:30" ; idem "endDate" → heure de fin | ||
| 31 | _LD_TIME_RE = re.compile(r'"startDate":"\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}):') | 31 | _LD_TIME_RE = re.compile(r'"startDate":"\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}):') | ||
| 32 | +_LD_END_TIME_RE = re.compile(r'"endDate":"\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}):') | ||||
| 32 | 33 | ||||
| 33 | # Valeurs génériques du champ `emplacement` = mode de l'événement, PAS un nom | 34 | # Valeurs génériques du champ `emplacement` = mode de l'événement, PAS un nom | ||
| 34 | # de lieu (vérifié sur le datastore complet 2026-08 : seules ces 3 premières | 35 | # de lieu (vérifié sur le datastore complet 2026-08 : seules ces 3 premières | ||
@@ -64,11 +65,12 @@ class MontrealConnector(BaseConnector): | |||||
| 64 | to_fetch = [] | 65 | to_fetch = [] | ||
| 65 | for ev in events: | 66 | for ev in events: | ||
| 66 | entry = cache.get(ev.url) | 67 | entry = cache.get(ev.url) | ||
| 67 | − if isinstance(entry, dict): # déjà au format vague 2 | 68 | + if isinstance(entry, dict) and "end_time" in entry: | ||
| 68 | − continue | 69 | + continue # déjà au format Phase 2 | ||
| 69 | last_day = (ev.end_date or ev.start_date or "")[:10] | 70 | last_day = (ev.end_date or ev.start_date or "")[:10] | ||
| 70 | if last_day >= today and len(to_fetch) < IMG_MAX: | 71 | if last_day >= today and len(to_fetch) < IMG_MAX: | ||
| 71 | − # None = jamais visité (prioritaire) ; str = hérité (backfill) | 72 | + # None = jamais visité (prioritaire) ; str/dict vague 2 = | ||
| 73 | + # hérité (backfill de l'heure de fin, priorité basse) | ||||
| 72 | to_fetch.append((entry is not None, ev.url)) | 74 | to_fetch.append((entry is not None, ev.url)) | ||
| 73 | to_fetch = [u for _, u in sorted(to_fetch, key=lambda t: t[0])][:IMG_MAX] | 75 | to_fetch = [u for _, u in sorted(to_fetch, key=lambda t: t[0])][:IMG_MAX] | ||
| 74 | 76 | ||||
@@ -76,11 +78,13 @@ class MontrealConnector(BaseConnector): | |||||
| 76 | html = session.get(url, timeout=15).text | 78 | html = session.get(url, timeout=15).text | ||
| 77 | img = _OG_IMG_RE.search(html) | 79 | img = _OG_IMG_RE.search(html) | ||
| 78 | tm = _LD_TIME_RE.search(html) | 80 | tm = _LD_TIME_RE.search(html) | ||
| 81 | + end_tm = _LD_END_TIME_RE.search(html) | ||||
| 79 | return {"image": img.group(1) if img else "", | 82 | return {"image": img.group(1) if img else "", | ||
| 80 | − "time": tm.group(1) if tm else None} | 83 | + "time": tm.group(1) if tm else None, | ||
| 84 | + "end_time": end_tm.group(1) if end_tm else None} | ||||
| 81 | 85 | ||||
| 82 | for url, info in self.fetch_many(to_fetch, worker, max_workers=6).items(): | 86 | for url, info in self.fetch_many(to_fetch, worker, max_workers=6).items(): | ||
| 83 | − cache[url] = info or {"image": "", "time": None} | 87 | + cache[url] = info or {"image": "", "time": None, "end_time": None} | ||
| 84 | self.save_cache(cache) | 88 | self.save_cache(cache) | ||
| 85 | for ev in events: | 89 | for ev in events: | ||
| 86 | entry = cache.get(ev.url) | 90 | entry = cache.get(ev.url) | ||
@@ -92,6 +96,8 @@ class MontrealConnector(BaseConnector): | |||||
| 92 | ev.image = entry.get("image") or "" | 96 | ev.image = entry.get("image") or "" | ||
| 93 | if entry.get("time") and entry["time"] != "00:00": | 97 | if entry.get("time") and entry["time"] != "00:00": | ||
| 94 | ev.start_time = entry["time"] | 98 | ev.start_time = entry["time"] | ||
| 99 | + if entry.get("end_time") and entry["end_time"] != "00:00": | ||||
| 100 | + ev.end_time = entry["end_time"] | ||||
| 95 | 101 | ||||
| 96 | def fetch(self) -> list[Event]: | 102 | def fetch(self) -> list[Event]: | ||
| 97 | events: list[Event] = [] | 103 | events: list[Event] = [] | ||
modified
sortika/connectors/sherbrooke.py
+2 −1
@@ -67,7 +67,7 @@ class SherbrookeConnector(BaseConnector): | |||||
| 67 | # les périodes sont publiées en UTC (« …T04:00:00Z » = minuit | 67 | # les périodes sont publiées en UTC (« …T04:00:00Z » = minuit | ||
| 68 | # local, placeholder « toute la journée ») → date + heure locales | 68 | # local, placeholder « toute la journée ») → date + heure locales | ||
| 69 | start, start_time = utc_to_local(periods[0].get("dateDebut")) | 69 | start, start_time = utc_to_local(periods[0].get("dateDebut")) | ||
| 70 | − end, _ = utc_to_local(periods[-1].get("dateFin")) | 70 | + end, end_time = utc_to_local(periods[-1].get("dateFin")) | ||
| 71 | 71 | ||||
| 72 | cats, audience = [], [] | 72 | cats, audience = [], [] | ||
| 73 | c = rec.get("categories") or {} | 73 | c = rec.get("categories") or {} | ||
@@ -100,6 +100,7 @@ class SherbrookeConnector(BaseConnector): | |||||
| 100 | start_date=start or None, | 100 | start_date=start or None, | ||
| 101 | start_time=start_time, | 101 | start_time=start_time, | ||
| 102 | end_date=end or start or None, | 102 | end_date=end or start or None, | ||
| 103 | + end_time=end_time, | ||||
| 103 | is_free=(not payant) if isinstance(payant, bool) else None, | 104 | is_free=(not payant) if isinstance(payant, bool) else None, | ||
| 104 | organizer="Ville de Sherbrooke", | 105 | organizer="Ville de Sherbrooke", | ||
| 105 | image=image, | 106 | image=image, | ||
modified
sortika/connectors/ticketmaster.py
+13 −3
@@ -25,7 +25,7 @@ import json | |||||
| 25 | import os | 25 | import os | ||
| 26 | 26 | ||||
| 27 | from ..normalize import artists_from_title, utc_to_local | 27 | from ..normalize import artists_from_title, utc_to_local | ||
| 28 | −from ..schema import Event | 28 | +from ..schema import Event, normalize_status | ||
| 29 | from .base import BaseConnector | 29 | from .base import BaseConnector | ||
| 30 | 30 | ||||
| 31 | # --- mode API officielle (avec clé) ------------------------------------------ | 31 | # --- mode API officielle (avec clé) ------------------------------------------ | ||
@@ -116,8 +116,11 @@ class TicketmasterConnector(BaseConnector): | |||||
| 116 | title = hit.get("title") or "" | 116 | title = hit.get("title") or "" | ||
| 117 | if not ext_id or not title: | 117 | if not ext_id or not title: | ||
| 118 | return None | 118 | return None | ||
| 119 | − if hit.get("cancelled") or hit.get("virtual"): | 119 | + if hit.get("virtual"): | ||
| 120 | return None | 120 | return None | ||
| 121 | + # Phase 2 : un événement annulé est CONSERVÉ avec status=cancelled | ||||
| 122 | + # (bandeau côté frontend + eventStatus schema.org) au lieu d'être jeté. | ||||
| 123 | + status = "cancelled" if hit.get("cancelled") else "" | ||||
| 121 | v = hit.get("venue") or {} | 124 | v = hit.get("venue") or {} | ||
| 122 | dates = hit.get("dates") or {} | 125 | dates = hit.get("dates") or {} | ||
| 123 | # startDate publié en UTC + fuseau → date/heure locales Québec ; | 126 | # startDate publié en UTC + fuseau → date/heure locales Québec ; | ||
@@ -142,6 +145,7 @@ class TicketmasterConnector(BaseConnector): | |||||
| 142 | start_date=start_date, | 145 | start_date=start_date, | ||
| 143 | start_time=start_time, | 146 | start_time=start_time, | ||
| 144 | end_date=start_date, | 147 | end_date=start_date, | ||
| 148 | + status=status, | ||||
| 145 | artists=artists_from_title(title, venue_name), | 149 | artists=artists_from_title(title, venue_name), | ||
| 146 | # seul visuel publié dans ce flux : le plan de salle officiel | 150 | # seul visuel publié dans ce flux : le plan de salle officiel | ||
| 147 | image=hit.get("seatmapUrl") or "", | 151 | image=hit.get("seatmapUrl") or "", | ||
@@ -190,7 +194,11 @@ class TicketmasterConnector(BaseConnector): | |||||
| 190 | v = venues[0] | 194 | v = venues[0] | ||
| 191 | loc = v.get("location") or {} | 195 | loc = v.get("location") or {} | ||
| 192 | dates = (hit.get("dates") or {}).get("start") or {} | 196 | dates = (hit.get("dates") or {}).get("start") or {} | ||
| 193 | − end = ((hit.get("dates") or {}).get("end") or {}).get("localDate") | 197 | + end_obj = (hit.get("dates") or {}).get("end") or {} | ||
| 198 | + end = end_obj.get("localDate") | ||||
| 199 | + # statut officiel Discovery : onsale/offsale/cancelled/postponed/rescheduled | ||||
| 200 | + status = normalize_status( | ||||
| 201 | + ((hit.get("dates") or {}).get("status") or {}).get("code")) | ||||
| 194 | # classifications → libellés source (segment + genre + sous-genre) | 202 | # classifications → libellés source (segment + genre + sous-genre) | ||
| 195 | cats: list[str] = [] | 203 | cats: list[str] = [] | ||
| 196 | for c in hit.get("classifications") or []: | 204 | for c in hit.get("classifications") or []: | ||
@@ -227,6 +235,8 @@ class TicketmasterConnector(BaseConnector): | |||||
| 227 | start_time=(None if dates.get("timeTBA") or dates.get("noSpecificTime") | 235 | start_time=(None if dates.get("timeTBA") or dates.get("noSpecificTime") | ||
| 228 | else dates.get("localTime")), | 236 | else dates.get("localTime")), | ||
| 229 | end_date=end or dates.get("localDate"), | 237 | end_date=end or dates.get("localDate"), | ||
| 238 | + end_time=end_obj.get("localTime"), | ||||
| 239 | + status=status, | ||||
| 230 | artists=artists, | 240 | artists=artists, | ||
| 231 | price_min=price_min, | 241 | price_min=price_min, | ||
| 232 | price_label=price_label, | 242 | price_label=price_label, | ||
modified
tests/test_connectors.py
+118 −6
@@ -46,8 +46,15 @@ def test_evenko_parses_fixture(monkeypatch): | |||||
| 46 | assert "musique" in ev.categories or ev.categories | 46 | assert "musique" in ev.categories or ev.categories | ||
| 47 | # vague 2 : heure locale du show_time + artistes du champ headliners | 47 | # vague 2 : heure locale du show_time + artistes du champ headliners | ||
| 48 | deep = next(e for e in events if "Deep Purple" in e.title) | 48 | deep = next(e for e in events if "Deep Purple" in e.title) | ||
| 49 | − assert deep.artists == ["Deep Purple"] | 49 | + # Phase 2 : les premières parties (`supports`) s'ajoutent aux artistes | ||
| 50 | + assert deep.artists == ["Deep Purple", "Kansas", "Jefferson Starship"] | ||||
| 50 | assert deep.start_time and len(deep.start_time) == 5 # "HH:MM" | 51 | assert deep.start_time and len(deep.start_time) == 5 # "HH:MM" | ||
| 52 | + # Phase 2 : tournée + heure des portes dans la description (factuels) | ||||
| 53 | + myles = next(e for e in events if "Myles Smith" in e.title) | ||||
| 54 | + assert "Tournée : My Mess, My Heart, My Life Tour" in myles.description | ||||
| 55 | + assert "Portes :" in myles.description | ||||
| 56 | + # representation_status ['buy_now'] = prévu → statut vide | ||||
| 57 | + assert all(e.status == "" for e in events) | ||||
| 51 | 58 | ||||
| 52 | 59 | ||||
| 53 | def test_atuvu_parses_fixture(): | 60 | def test_atuvu_parses_fixture(): | ||
@@ -86,6 +93,12 @@ def test_lavitrine_parses_fixture(): | |||||
| 86 | assert rows[0]["venue"] == "Théâtre Petit Champlain" | 93 | assert rows[0]["venue"] == "Théâtre Petit Champlain" | ||
| 87 | assert rows[0]["city"] == "Québec" | 94 | assert rows[0]["city"] == "Québec" | ||
| 88 | assert rows[0]["external_id"].startswith("23777-") | 95 | assert rows[0]["external_id"].startswith("23777-") | ||
| 96 | + # Phase 2 : l'heure de la slide (« 15:00 HNE ») est enfin captée | ||||
| 97 | + assert rows[0]["start_time"] == "15:00" | ||||
| 98 | + # Phase 2 : description = accroche courte + début du texte À propos | ||||
| 99 | + assert rows[0]["description"].startswith( | ||||
| 100 | + "DE CHARLES TRENET À STROMAE") | ||||
| 101 | + assert len(rows[0]["description"]) >= 200 | ||||
| 89 | 102 | ||||
| 90 | 103 | ||||
| 91 | def test_lepointdevente_parses_fixture(): | 104 | def test_lepointdevente_parses_fixture(): | ||
@@ -197,22 +210,25 @@ def test_ticketmaster_without_key_scrapes_discover(monkeypatch): | |||||
| 197 | ev = events[0] | 210 | ev = events[0] | ||
| 198 | assert ev.external_id == "310064A20EB06FDE" | 211 | assert ev.external_id == "310064A20EB06FDE" | ||
| 199 | assert ev.start_date == "2026-08-18" and ev.start_time == "19:00" # UTC→locale | 212 | assert ev.start_date == "2026-08-18" and ev.start_time == "19:00" # UTC→locale | ||
| 200 | − assert ev.venue == "Le Balcon X Terrasse" and ev.city == "Montreal" | 213 | + # Phase 2 : « Montreal » (graphie source) → nom canonique MAMH | ||
| 214 | + assert ev.venue == "Le Balcon X Terrasse" and ev.city == "Montréal" | ||||
| 201 | assert ev.lat == 45.505438 and ev.region == "Montréal" | 215 | assert ev.lat == 45.505438 and ev.region == "Montréal" | ||
| 202 | assert ev.categories # Concerts → taxonomie canonique | 216 | assert ev.categories # Concerts → taxonomie canonique | ||
| 203 | assert ev.is_free is None and ev.price_min is None # jamais inventé | 217 | assert ev.is_free is None and ev.price_min is None # jamais inventé | ||
| 204 | 218 | ||||
| 205 | 219 | ||||
| 206 | −def test_ticketmaster_scrape_skips_cancelled_and_tba_time(): | 220 | +def test_ticketmaster_scrape_keeps_cancelled_with_status_and_tba_time(): | ||
| 221 | + """Phase 2 : un événement annulé est CONSERVÉ avec status=cancelled | ||||
| 222 | + (bandeau frontend + eventStatus schema.org), plus jeté.""" | ||||
| 207 | from sortika.connectors.ticketmaster import TicketmasterConnector | 223 | from sortika.connectors.ticketmaster import TicketmasterConnector | ||
| 208 | c = TicketmasterConnector() | 224 | c = TicketmasterConnector() | ||
| 209 | − assert c._scrape_hit_to_event({"id": "x", "title": "T", | 225 | + ev = c._scrape_hit_to_event({"id": "x", "title": "T", "cancelled": True}) | ||
| 210 | − "cancelled": True}) is None | 226 | + assert ev is not None and ev.status == "cancelled" | ||
| 211 | ev = c._scrape_hit_to_event({ | 227 | ev = c._scrape_hit_to_event({ | ||
| 212 | "id": "y", "title": "Sans heure annoncée", | 228 | "id": "y", "title": "Sans heure annoncée", | ||
| 213 | "dates": {"dateDisplay": "showDateOnly", | 229 | "dates": {"dateDisplay": "showDateOnly", | ||
| 214 | "startDate": "2026-09-01T04:00:00Z"}, "venue": {}}) | 230 | "startDate": "2026-09-01T04:00:00Z"}, "venue": {}}) | ||
| 215 | − assert ev is not None and ev.start_time is None | 231 | + assert ev is not None and ev.start_time is None and ev.status == "" | ||
| 216 | 232 | ||||
| 217 | 233 | ||||
| 218 | def test_ticketmaster_parses_hit(): | 234 | def test_ticketmaster_parses_hit(): | ||
@@ -275,6 +291,100 @@ def test_bandsintown_filters_quebec(monkeypatch): | |||||
| 275 | assert r["lineup"] == ["Deep Purple"] | 291 | assert r["lineup"] == ["Deep Purple"] | ||
| 276 | 292 | ||||
| 277 | 293 | ||||
| 294 | +def test_atuvu_boilerplate_description_not_published(): | ||||
| 295 | + """La og:description gabarit (« trouvez des billets à tarif réduit… ») | ||||
| 296 | + n'est jamais publiée comme description d'événement.""" | ||||
| 297 | + from sortika.connectors.atuvu import AtuvuConnector | ||||
| 298 | + html = (FIXTURES / "atuvu_fiche.html").read_text(encoding="utf-8") | ||||
| 299 | + rows = AtuvuConnector()._parse_fiche("https://atuvu.ca/billets-spectacle/arkansas", html) | ||||
| 300 | + assert rows and rows[0]["description"] == "" | ||||
| 301 | + | ||||
| 302 | + | ||||
| 303 | +def test_laval_cancelled_prefix_becomes_status(monkeypatch): | ||||
| 304 | + """« ANNULÉ - Titre » → status=cancelled, titre nettoyé (Phase 2).""" | ||||
| 305 | + from sortika.connectors.laval import LavalConnector | ||||
| 306 | + c = LavalConnector() | ||||
| 307 | + rec = {"Title": "ANNULÉ - Rencontre avec Kevin Raphaël", | ||||
| 308 | + "PageUrl": "/activites/rencontre.aspx", | ||||
| 309 | + "EventDate": "2026-10-01T22:30:00", "EndDate": "2026-10-01T23:30:00"} | ||||
| 310 | + monkeypatch.setattr(c, "get_json", lambda *a, **kw: [rec]) | ||||
| 311 | + events = [e.finalize() for e in c.fetch()] | ||||
| 312 | + assert len(events) == 1 | ||||
| 313 | + assert events[0].status == "cancelled" | ||||
| 314 | + assert events[0].title == "Rencontre avec Kevin Raphaël" | ||||
| 315 | + | ||||
| 316 | + | ||||
| 317 | +def test_eventbrite_result_to_event_end_time_status_organizer(): | ||||
| 318 | + """Champs Phase 2 du payload __SERVER_DATA__ réel : end_time, statut | ||||
| 319 | + (is_cancelled conservé), full_description, organisateur du JSON-LD.""" | ||||
| 320 | + from sortika.connectors.eventbrite import EventbriteConnector | ||||
| 321 | + r = {"id": "123", "name": "Atelier de poterie", | ||||
| 322 | + "url": "https://www.eventbrite.ca/e/atelier-123", | ||||
| 323 | + "summary": "court", "full_description": "Une description longue " * 20, | ||||
| 324 | + "start_date": "2026-09-12", "start_time": "19:00", | ||||
| 325 | + "end_date": "2026-09-12", "end_time": "21:30", | ||||
| 326 | + "is_cancelled": True, "urgency_signals": {"messages": [], "categories": []}, | ||||
| 327 | + "primary_venue": {"name": "Studio X", | ||||
| 328 | + "address": {"region": "QC", "city": "Montréal", | ||||
| 329 | + "address_1": "1 rue Test", | ||||
| 330 | + "postal_code": "H2X 1Y6", | ||||
| 331 | + "latitude": 45.5, "longitude": -73.6}}} | ||||
| 332 | + ev = EventbriteConnector()._result_to_event( | ||||
| 333 | + r, {"price": None, "organizer": "Les Ateliers MTL", | ||||
| 334 | + "description": ""}).finalize() | ||||
| 335 | + assert ev.end_time == "21:30" | ||||
| 336 | + assert ev.status == "cancelled" # conservé, plus jeté | ||||
| 337 | + assert ev.organizer == "Les Ateliers MTL" | ||||
| 338 | + assert len(ev.description) >= 200 # full_description > summary | ||||
| 339 | + # summary court + description JSON-LD de la fiche → la plus riche gagne | ||||
| 340 | + r2 = dict(r, full_description="") | ||||
| 341 | + ev2 = EventbriteConnector()._result_to_event( | ||||
| 342 | + r2, {"price": None, "organizer": "", | ||||
| 343 | + "description": "Description détaillée de la fiche. " * 10}) | ||||
| 344 | + assert len(ev2.description) >= 200 | ||||
| 345 | + | ||||
| 346 | + | ||||
| 347 | +def test_eventbrite_parse_detail_offers_and_organizer(): | ||||
| 348 | + from sortika.connectors.eventbrite import _parse_detail | ||||
| 349 | + html = ('<script type="application/ld+json">{"@type":"Event",' | ||||
| 350 | + '"organizer":{"@type":"Organization","name":"Prod QC"},' | ||||
| 351 | + '"offers":[{"@type":"AggregateOffer","lowPrice":"25.00",' | ||||
| 352 | + '"highPrice":"45.00","priceCurrency":"CAD"}]}</script>') | ||||
| 353 | + d = _parse_detail(html) | ||||
| 354 | + assert d["organizer"] == "Prod QC" | ||||
| 355 | + assert d["price"] == {"low": 25.0, "high": 45.0, "cur": "CAD"} | ||||
| 356 | + assert d["description"] == "" # clé toujours présente | ||||
| 357 | + | ||||
| 358 | + | ||||
| 359 | +def test_bandsintown_offers_ticket_url_and_soldout(monkeypatch): | ||||
| 360 | + from sortika.connectors.bandsintown import BandsintownConnector | ||||
| 361 | + c = BandsintownConnector() | ||||
| 362 | + payload = [{"id": "9", "url": "https://www.bandsintown.com/e/9", | ||||
| 363 | + "datetime": "2026-09-02T20:00:00", "title": "", | ||||
| 364 | + "artist": {"name": "X"}, "lineup": ["X"], | ||||
| 365 | + "offers": [{"type": "Tickets", "status": "sold out", | ||||
| 366 | + "url": "https://billets.example/x"}], | ||||
| 367 | + "venue": {"name": "Salle Y", "city": "Québec", | ||||
| 368 | + "country": "Canada", "region": "QC"}}] | ||||
| 369 | + monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload) | ||||
| 370 | + rows = c._artist_events("X") | ||||
| 371 | + assert rows[0]["url"] == "https://billets.example/x" | ||||
| 372 | + assert rows[0]["offer_status"] == "sold out" | ||||
| 373 | + | ||||
| 374 | + | ||||
| 375 | +def test_longueuil_image_from_nuxt_state(): | ||||
| 376 | + from sortika.connectors.longueuil import _IMG_RES, _js_str | ||||
| 377 | + state = ('x:{bg:"https:\\u002F\\u002Fcms.longueuil.quebec\\u002Fsites' | ||||
| 378 | + '\\u002Fdefault\\u002Ffiles\\u002Fstyles\\u002Ffront_medium_16_9' | ||||
| 379 | + '\\u002Fpublic\\u002Fmedias\\u002Fimages\\u002F2022-03' | ||||
| 380 | + '\\u002Fcvl231121-04h_0.jpg?itok=GYMG8rcf"}') | ||||
| 381 | + m = _IMG_RES[0].search(state) | ||||
| 382 | + assert m | ||||
| 383 | + url = _js_str(m.group(1)) | ||||
| 384 | + assert url.startswith("https://cms.longueuil.quebec/sites/") | ||||
| 385 | + assert "front_medium_16_9" in url and url.endswith("itok=GYMG8rcf") | ||||
| 386 | + | ||||
| 387 | + | ||||
| 278 | def test_brossard_parses_fiche_banner_and_jsonld(): | 388 | def test_brossard_parses_fiche_banner_and_jsonld(): | ||
| 279 | from sortika.connectors.brossard import BrossardConnector | 389 | from sortika.connectors.brossard import BrossardConnector | ||
| 280 | c = BrossardConnector() | 390 | c = BrossardConnector() | ||
@@ -284,6 +394,7 @@ def test_brossard_parses_fiche_banner_and_jsonld(): | |||||
| 284 | '<meta property="og:image" content="https://brossard.ca/i.jpg">') | 394 | '<meta property="og:image" content="https://brossard.ca/i.jpg">') | ||
| 285 | info = c._parse_fiche(ville) | 395 | info = c._parse_fiche(ville) | ||
| 286 | assert info["start_date"] == "2026-12-06" and info["time"] == "15:00" | 396 | assert info["start_date"] == "2026-12-06" and info["time"] == "15:00" | ||
| 397 | + assert info["end_time"] == "16:00" # Phase 2 : « de 15 h 00 à 16 h 00 » | ||||
| 287 | assert info["image"] == "https://brossard.ca/i.jpg" | 398 | assert info["image"] == "https://brossard.ca/i.jpg" | ||
| 288 | # JSON-LD Event des fiches biblio.brossard.ca (structure réelle, 2026-08) | 399 | # JSON-LD Event des fiches biblio.brossard.ca (structure réelle, 2026-08) | ||
| 289 | biblio = ('<script type="application/ld+json">[{"@context":"http://schema.org",' | 400 | biblio = ('<script type="application/ld+json">[{"@context":"http://schema.org",' | ||
@@ -297,5 +408,6 @@ def test_brossard_parses_fiche_banner_and_jsonld(): | |||||
| 297 | info = c._parse_fiche(biblio) | 408 | info = c._parse_fiche(biblio) | ||
| 298 | assert info["start_date"] == "2026-11-27" and info["time"] == "13:30" | 409 | assert info["start_date"] == "2026-11-27" and info["time"] == "13:30" | ||
| 299 | assert info["end_date"] == "2026-11-27" | 410 | assert info["end_date"] == "2026-11-27" | ||
| 411 | + assert info["end_time"] == "15:00" # Phase 2 : endDate JSON-LD | ||||
| 300 | assert info["venue"] == "Salle d'animation" | 412 | assert info["venue"] == "Salle d'animation" | ||
| 301 | assert info["postal_code"] == "J4X 2A4" | 413 | assert info["postal_code"] == "J4X 2A4" | ||
| 302 | 414 | ||||