#!/usr/bin/env python3 """Sélecteur de « fait marquant » pour la publication sociale automatique KA. Interroge les API stats des sites du Groupe KA, extrait plusieurs insights candidats par site, les score par saillance, et renvoie un classement JSON. Usage : insights.py -> classement complet (JSON sur stdout) insights.py --exclude lou-ka,food-ka -> exclut ces sites (anti-répétition) insights.py --site immo-ka -> ne renvoie que les candidats de ce site insights.py --top 1 -> ne renvoie que le meilleur candidat """ import json, sys, urllib.request, argparse, ssl CTX = ssl.create_default_context() CTX.check_hostname = False CTX.verify_mode = ssl.CERT_NONE LIME = "#d9f26b" ACCENT = { "lou-ka": "#ff6a00", "immo-ka": "#e23744", "food-ka": "#1f9d55", "fabri-ka": "#c4532e", "auto-ka": "#ff5a2a", "vrai-prix": LIME, "sorti-ka": LIME, "job-ka": LIME, "groupe-ka": LIME, } LABELS = { "lou-ka": "Lou·Ka", "immo-ka": "Immo·Ka", "food-ka": "Food·Ka", "fabri-ka": "Fabri·Ka", "auto-ka": "Auto·Ka", "vrai-prix": "Vrai-Prix", "sorti-ka": "Sorti·Ka", "job-ka": "Job·Ka", } URLS = { "lou-ka": "https://www.lou-ka.com/api/stats/detailed", "immo-ka": "https://www.immo-ka.com/api/stats", "food-ka": "https://www.food-ka.com/api/stats", "fabri-ka": "https://www.fabri-ka.com/api/stats", "auto-ka": "https://www.auto-ka.com/api/stats", "vrai-prix": "https://www.vrai-prix.com/api/stats", "sorti-ka": "https://www.sorti-ka.com/api/stats", "job-ka": "https://www.job-ka.com/api/stats", } def fr(n): try: n = int(round(float(n))) except Exception: return str(n) return f"{n:,}".replace(",", " ") def money(n): return fr(n) + " $" def get(url): req = urllib.request.Request(url, headers={"User-Agent": "KA-Social/1.0"}) with urllib.request.urlopen(req, timeout=15, context=CTX) as r: return json.load(r) def mk(site, insight_id, headline, headline_label, tiles, fact, salience, bars=None, note=None, tagline=None): return { "site": site, "label": LABELS.get(site, site), "accent": ACCENT.get(site, LIME), "insight_id": insight_id, "headline": headline, "headline_label": headline_label, "tiles": tiles, "bars": bars, "note": note, "tagline": tagline, "fact": fact, "salience": round(salience, 2), } # ---- extracteurs par site : renvoient une liste de candidats ---- def ex_lou_ka(d): t = d.get("totals", d) total = t.get("total", 0) out = [] out.append(mk("lou-ka", "total", fr(total), "logements à louer actifs au Québec", [(fr(t.get("sources", 0)), "sources"), (fr(t.get("cities", 0)), "villes"), (money(t.get("median", 0)), "loyer médian")], f"Lou·Ka agrège maintenant {fr(total)} logements à louer actifs partout au Québec, " f"depuis {fr(t.get('sources',0))} sources, loyer médian {money(t.get('median',0))}.", 0.55 + milestone_bonus(total), tagline="Tous les logements à louer du Québec, au même endroit.")) # aubaines sous le marché (si dispo via dashboard non ici) — approx via by_type bt = d.get("by_type", []) bt = [x for x in bt if isinstance(x.get("avg_price"), (int, float)) and x.get("count", 0) > 500] if bt: bt = sorted(bt, key=lambda x: -x["count"])[:6] out.append(mk("lou-ka", "by_type", fr(sum(x["count"] for x in bt)), "logements ventilés par type", [(money(t.get("avg", 0)), "loyer moyen"), (money(t.get("median", 0)), "médian"), (fr(t.get("dispo_now", 0)), "dispo maintenant")], "Le marché locatif québécois par type de logement, du studio au 6½ — " "avec le loyer moyen de chacun.", 0.5, bars=[[x["key"], x["count"], fr(x["count"]), f"loyer moyen {money(x['avg_price'])}"] for x in bt], tagline="Le marché locatif, mesuré en temps réel.")) return out def ex_immo_ka(d): out = [] total = d.get("total", 0) vp = d.get("vraiprix", {}).get("ensemble", {}) out.append(mk("immo-ka", "total", fr(total), "propriétés à vendre au Québec", [(fr(d.get("sources", 0)), "sources"), (fr(d.get("cities", 0)), "villes"), (money(d.get("avg_price", 0)), "prix moyen affiché")], f"Immo·Ka réunit {fr(total)} propriétés à vendre au Québec, dans {fr(d.get('cities',0))} villes.", 0.5 + milestone_bonus(total), tagline="Toutes les propriétés à vendre, un seul site.")) if vp.get("pct_sur10"): out.append(mk("immo-ka", "vraiprix", f"{str(vp['pct_sur10']).replace('.',',')} %", "des propriétés affichées à +10 % au-dessus de leur valeur réelle", [(f"{str(vp.get('pct_sous5',0)).replace('.',',')} %", "sous-évaluées"), (f"{str(vp.get('pct_juste',0)).replace('.',',')} %", "prix justes"), (f"+{str(vp.get('median_delta_pct',0)).replace('.',',')} %", "écart médian")], f"En croisant les prix affichés avec la valeur Vrai-Prix, Immo·Ka révèle que " f"{str(vp['pct_sur10']).replace('.',',')} % des propriétés sont affichées à plus de 10 % " f"au-dessus de leur valeur réelle estimée.", 0.8, # insight distinctif et fort tagline="Le seul site qui compare le prix affiché à la vraie valeur.")) return out def ex_vrai_prix(d): out = [] units = d.get("units_total", 0) out.append(mk("vrai-prix", "units", fr(units), "unités d'évaluation couvertes au Québec", [(f"{fr(round(d.get('value_total_2026',0)/1e9))} G$", "valeur totale 2026"), (money(d.get("value_median_2026", 0)), "valeur médiane"), (f"+{str(d.get('growth_2021_2026_pct','')).replace('.',',')} %", "croissance 2021→2026")], f"Vrai-Prix évalue {fr(units)} unités — la quasi-totalité du parc immobilier québécois — " f"pour une valeur totale estimée de {fr(round(d.get('value_total_2026',0)/1e9))} milliards de dollars en 2026.", 0.7 + milestone_bonus(units), note="Modèle hédonique + comparables — erreur médiane 11 %.", tagline="La vraie valeur de chaque propriété du Québec.")) return out def ex_auto_ka(d): out = [] total = d.get("total", 0) by = sorted([x for x in d.get("by_region", []) if x.get("n")], key=lambda x: -x["n"])[:6] out.append(mk("auto-ka", "total", fr(total), "véhicules d'occasion en vente au Québec", [(fr(d.get("sources", 0)), "sources"), (money(d.get("avg_price", 0)), "prix moyen"), (fr(d.get("recalls_total", 0)), "rappels croisés")], f"Auto·Ka agrège {fr(total)} véhicules d'occasion depuis {fr(d.get('sources',0))} sources, " f"et croise automatiquement {fr(d.get('recalls_total',0))} rappels de sécurité avec les annonces.", 0.55 + milestone_bonus(total), bars=[[x["region"], x["n"], fr(x["n"])] for x in by] if by else None, tagline="Le marché de l'occasion, décodé.")) return out def ex_food_ka(d): out = [] total = d.get("total", 0) on_sale = d.get("on_sale", 0) out.append(mk("food-ka", "sales", fr(on_sale), "produits d'épicerie en solde en ce moment", [(fr(total), "produits suivis"), (fr(d.get("sources", 0)), "bannières"), (money(round(d.get("avg_price", 0), 2)).replace(" $", " $"), "prix moyen")], f"Food·Ka repère en ce moment {fr(on_sale)} produits d'épicerie en solde, " f"sur {fr(total)} produits suivis dans {fr(d.get('sources',0))} bannières.", 0.6, tagline="L'épicerie comparée, chaque jour.")) cat = sorted([x for x in d.get("by_category", []) if x.get("n")], key=lambda x: -x["n"])[:6] if cat: out.append(mk("food-ka", "categories", fr(total), "produits d'épicerie suivis", [(fr(on_sale), "en solde"), (fr(d.get("sources", 0)), "bannières"), (fr(d.get("categories", 0)), "catégories")], "Le panier québécois catégorie par catégorie, avec le prix moyen de chacune.", 0.45, bars=[[x["category"], x["n"], fr(x["n"]), f"prix moyen {money(round(x['avg_price'],2))}"] for x in cat], tagline="L'épicerie comparée, chaque jour.")) return out def ex_fabri_ka(d): out = [] t = d.get("totals", {}) total = t.get("products", 0) by = sorted([x for x in d.get("by_region", []) if x.get("products")], key=lambda x: -x["products"])[:6] out.append(mk("fabri-ka", "products", fr(total), "produits québécois répertoriés", [(fr(t.get("stores_live", 0)), "boutiques en ligne"), (fr(t.get("regions", 0)), "régions"), (fr(t.get("stores_registry", 0)), "au registre")], f"Fabri·Ka répertorie {fr(total)} produits québécois provenant de {fr(t.get('stores_live',0))} " f"boutiques en ligne, dans les {fr(t.get('regions',0))} régions du Québec.", 0.6 + milestone_bonus(total), bars=[[x["region"], x["products"], fr(x["products"])] for x in by] if by else None, tagline="Le savoir-faire québécois, agrégé.")) return out def ex_resto_ka(d): out = [] total = d.get("restaurants", 0) out.append(mk("total", fr(total), "restaurants répertoriés au Québec", [(fr(d.get("items", 0)), "plats aux menus"), (fr(d.get("menus", 0)), "menus complets"), (fr(d.get("regions", 0)), "régions")], f"Resto·Ka répertorie {fr(total)} restaurants et {fr(d.get('items',0))} plats détaillés " f"dans {fr(d.get('menus',0))} menus complets, partout au Québec.", 0.5 + milestone_bonus(total), tagline="Tous les restos du Québec, une seule adresse.")) by = sorted([x for x in d.get("by_region", []) if x.get("n")], key=lambda x: -x["n"])[:6] if by: out.append(mk("regions", fr(total), "restaurants, par région", [(fr(d.get("items", 0)), "plats"), (fr(d.get("inspections", 0)), "inspections MAPAQ"), (fr(d.get("with_alcohol_permit", 0)), "permis d'alcool")], "Le Québec gourmand région par région, des grands centres aux régions.", 0.4, bars=[[x["region"], x["n"], fr(x["n"])] for x in by], tagline="Tous les restos du Québec, une seule adresse.")) return out def ex_sorti_ka(d): out = [] total = d.get("total_active", d.get("upcoming", 0)) free = d.get("free_upcoming", 0) out.append(mk("sorti-ka", "free", fr(free), "événements GRATUITS à venir au Québec", [(fr(total), "événements actifs"), (fr(d.get("cities", 0)), "villes"), (fr(d.get("sources", 0)), "sources")], f"Sorti·Ka recense {fr(total)} événements à venir au Québec — dont {fr(free)} entièrement gratuits, " f"dans {fr(d.get('cities',0))} villes.", 0.6, tagline="Toutes les sorties du Québec, en un coup d'œil.")) return out def ex_crea_ka(d): out = [] total = d.get("creators", 0) bp = d.get("by_platform", {}) bars = sorted(bp.items(), key=lambda x: -x[1])[:6] out.append(mk("creators", fr(total), "créateurs de contenu québécois recensés", [(fr(d.get("accounts", 0)), "comptes reliés"), (str(len(bp)), "plateformes"), (str(len(d.get("by_niche", {}))), "niches")], f"Créa·Ka a cartographié {fr(total)} créateurs de contenu québécois, avec {fr(d.get('accounts',0))} " f"comptes reliés sur {len(bp)} plateformes.", 0.5 + milestone_bonus(total), bars=[[k.capitalize(), v, fr(v)] for k, v in bars] if bars else None, tagline="Les créateurs de contenu d'ici, cartographiés.")) return out def ex_job_ka(d): out = [] total = d.get("total", 0) tc = sorted([x for x in d.get("top_cities", []) if x.get("n")], key=lambda x: -x["n"])[:6] out.append(mk("job-ka", "total", fr(total), "offres d'emploi agrégées au Québec", [(fr(d.get("employers", 0)), "employeurs"), (fr(d.get("sources", 0)), "sources"), (money(round(d.get("avg_salary_year", 0))), "salaire moyen")], f"Job·Ka agrège {fr(total)} offres d'emploi de {fr(d.get('employers',0))} employeurs, " f"dont {fr(d.get('remote',0))} en télétravail.", 0.5 + milestone_bonus(total), bars=[[x["city"], x["n"], fr(x["n"])] for x in tc] if tc else None, tagline="Tous les emplois du Québec, au même endroit.")) return out def ex_trouve_ka(d): out = [] pages = d.get("pages_indexed", 0) out.append(mk("pages", fr(pages), "pages web québécoises indexées", [(fr(d.get("domains_count", 0)), "domaines"), (fr(d.get("indexed_last_hour", 0)), "pages/heure"), ("100 %", "recherche sémantique")], f"Trouve·Ka a indexé {fr(pages)} pages web québécoises sur {fr(d.get('domains_count',0))} domaines, " f"au rythme de {fr(d.get('indexed_last_hour',0))} pages à l'heure.", 0.55 + milestone_bonus(pages), tagline="Le moteur de recherche 100 % québécois.")) return out EXTRACT = { "lou-ka": ex_lou_ka, "immo-ka": ex_immo_ka, "vrai-prix": ex_vrai_prix, "auto-ka": ex_auto_ka, "food-ka": ex_food_ka, "fabri-ka": ex_fabri_ka, "sorti-ka": ex_sorti_ka, "job-ka": ex_job_ka, } def milestone_bonus(n): """Bonus de saillance si le nombre vient de franchir un cap rond.""" try: n = float(n) except Exception: return 0.0 if n <= 0: return 0.0 for cap in (1_000_000, 500_000, 100_000, 50_000, 10_000): if n >= cap and n < cap * 1.06: # fraîchement franchi return 0.25 return 0.0 def main(): ap = argparse.ArgumentParser() ap.add_argument("--exclude", default="") ap.add_argument("--site", default="") ap.add_argument("--top", type=int, default=0) args = ap.parse_args() exclude = set(s for s in args.exclude.split(",") if s) sites = [args.site] if args.site else list(EXTRACT.keys()) cands = [] errors = {} for site in sites: if site in exclude or site not in EXTRACT: continue try: d = get(URLS[site]) cands.extend(EXTRACT[site](d)) except Exception as e: errors[site] = str(e) cands.sort(key=lambda c: -c["salience"]) out = {"candidates": cands, "errors": errors} if args.top: out["candidates"] = cands[:args.top] print(json.dumps(out, ensure_ascii=False)) if __name__ == "__main__": main()