JavaScript 65.5%
Python 17.8%
CSS 13%
HTML 3.7%
1#!/usr/bin/env python32"""Sélecteur de « fait marquant » pour la publication sociale automatique KA.34Interroge les API stats des sites du Groupe KA, extrait plusieurs insights5candidats par site, les score par saillance, et renvoie un classement JSON.67Usage :8 insights.py -> classement complet (JSON sur stdout)9 insights.py --exclude lou-ka,food-ka -> exclut ces sites (anti-répétition)10 insights.py --site immo-ka -> ne renvoie que les candidats de ce site11 insights.py --top 1 -> ne renvoie que le meilleur candidat12"""13import json, sys, urllib.request, argparse, ssl1415CTX = ssl.create_default_context()16CTX.check_hostname = False17CTX.verify_mode = ssl.CERT_NONE1819LIME = "#d9f26b"20ACCENT = {21 "lou-ka": "#ff6a00", "immo-ka": "#e23744", "food-ka": "#1f9d55",22 "fabri-ka": "#c4532e", "auto-ka": "#ff5a2a",23 "vrai-prix": LIME, "sorti-ka": LIME, "job-ka": LIME, "groupe-ka": LIME,24}25LABELS = {26 "lou-ka": "Lou·Ka", "immo-ka": "Immo·Ka", "food-ka": "Food·Ka",27 "fabri-ka": "Fabri·Ka", "auto-ka": "Auto·Ka",28 "vrai-prix": "Vrai-Prix", "sorti-ka": "Sorti·Ka", "job-ka": "Job·Ka",29}30URLS = {31 "lou-ka": "https://www.lou-ka.com/api/stats/detailed",32 "immo-ka": "https://www.immo-ka.com/api/stats",33 "food-ka": "https://www.food-ka.com/api/stats",34 "fabri-ka": "https://www.fabri-ka.com/api/stats",35 "auto-ka": "https://www.auto-ka.com/api/stats",36 "vrai-prix": "https://www.vrai-prix.com/api/stats",37 "sorti-ka": "https://www.sorti-ka.com/api/stats",38 "job-ka": "https://www.job-ka.com/api/stats",39}404142def fr(n):43 try:44 n = int(round(float(n)))45 except Exception:46 return str(n)47 return f"{n:,}".replace(",", " ")484950def money(n):51 return fr(n) + " $"525354def get(url):55 req = urllib.request.Request(url, headers={"User-Agent": "KA-Social/1.0"})56 with urllib.request.urlopen(req, timeout=15, context=CTX) as r:57 return json.load(r)585960def mk(site, insight_id, headline, headline_label, tiles, fact, salience,61 bars=None, note=None, tagline=None):62 return {63 "site": site, "label": LABELS.get(site, site), "accent": ACCENT.get(site, LIME),64 "insight_id": insight_id, "headline": headline, "headline_label": headline_label,65 "tiles": tiles, "bars": bars, "note": note, "tagline": tagline,66 "fact": fact, "salience": round(salience, 2),67 }686970# ---- extracteurs par site : renvoient une liste de candidats ----7172def ex_lou_ka(d):73 t = d.get("totals", d)74 total = t.get("total", 0)75 out = []76 out.append(mk("lou-ka", "total", fr(total), "logements à louer actifs au Québec",77 [(fr(t.get("sources", 0)), "sources"), (fr(t.get("cities", 0)), "villes"),78 (money(t.get("median", 0)), "loyer médian")],79 f"Lou·Ka agrège maintenant {fr(total)} logements à louer actifs partout au Québec, "80 f"depuis {fr(t.get('sources',0))} sources, loyer médian {money(t.get('median',0))}.",81 0.55 + milestone_bonus(total),82 tagline="Tous les logements à louer du Québec, au même endroit."))83 # aubaines sous le marché (si dispo via dashboard non ici) — approx via by_type84 bt = d.get("by_type", [])85 bt = [x for x in bt if isinstance(x.get("avg_price"), (int, float)) and x.get("count", 0) > 500]86 if bt:87 bt = sorted(bt, key=lambda x: -x["count"])[:6]88 out.append(mk("lou-ka", "by_type", fr(sum(x["count"] for x in bt)),89 "logements ventilés par type",90 [(money(t.get("avg", 0)), "loyer moyen"), (money(t.get("median", 0)), "médian"),91 (fr(t.get("dispo_now", 0)), "dispo maintenant")],92 "Le marché locatif québécois par type de logement, du studio au 6½ — "93 "avec le loyer moyen de chacun.",94 0.5,95 bars=[[x["key"], x["count"], fr(x["count"]), f"loyer moyen {money(x['avg_price'])}"] for x in bt],96 tagline="Le marché locatif, mesuré en temps réel."))97 return out9899100def ex_immo_ka(d):101 out = []102 total = d.get("total", 0)103 vp = d.get("vraiprix", {}).get("ensemble", {})104 out.append(mk("immo-ka", "total", fr(total), "propriétés à vendre au Québec",105 [(fr(d.get("sources", 0)), "sources"), (fr(d.get("cities", 0)), "villes"),106 (money(d.get("avg_price", 0)), "prix moyen affiché")],107 f"Immo·Ka réunit {fr(total)} propriétés à vendre au Québec, dans {fr(d.get('cities',0))} villes.",108 0.5 + milestone_bonus(total),109 tagline="Toutes les propriétés à vendre, un seul site."))110 if vp.get("pct_sur10"):111 out.append(mk("immo-ka", "vraiprix", f"{str(vp['pct_sur10']).replace('.',',')} %",112 "des propriétés affichées à +10 % au-dessus de leur valeur réelle",113 [(f"{str(vp.get('pct_sous5',0)).replace('.',',')} %", "sous-évaluées"),114 (f"{str(vp.get('pct_juste',0)).replace('.',',')} %", "prix justes"),115 (f"+{str(vp.get('median_delta_pct',0)).replace('.',',')} %", "écart médian")],116 f"En croisant les prix affichés avec la valeur Vrai-Prix, Immo·Ka révèle que "117 f"{str(vp['pct_sur10']).replace('.',',')} % des propriétés sont affichées à plus de 10 % "118 f"au-dessus de leur valeur réelle estimée.",119 0.8, # insight distinctif et fort120 tagline="Le seul site qui compare le prix affiché à la vraie valeur."))121 return out122123124def ex_vrai_prix(d):125 out = []126 units = d.get("units_total", 0)127 out.append(mk("vrai-prix", "units", fr(units), "unités d'évaluation couvertes au Québec",128 [(f"{fr(round(d.get('value_total_2026',0)/1e9))} G$", "valeur totale 2026"),129 (money(d.get("value_median_2026", 0)), "valeur médiane"),130 (f"+{str(d.get('growth_2021_2026_pct','')).replace('.',',')} %", "croissance 2021→2026")],131 f"Vrai-Prix évalue {fr(units)} unités — la quasi-totalité du parc immobilier québécois — "132 f"pour une valeur totale estimée de {fr(round(d.get('value_total_2026',0)/1e9))} milliards de dollars en 2026.",133 0.7 + milestone_bonus(units),134 note="Modèle hédonique + comparables — erreur médiane 11 %.",135 tagline="La vraie valeur de chaque propriété du Québec."))136 return out137138139def ex_auto_ka(d):140 out = []141 total = d.get("total", 0)142 by = sorted([x for x in d.get("by_region", []) if x.get("n")], key=lambda x: -x["n"])[:6]143 out.append(mk("auto-ka", "total", fr(total), "véhicules d'occasion en vente au Québec",144 [(fr(d.get("sources", 0)), "sources"), (money(d.get("avg_price", 0)), "prix moyen"),145 (fr(d.get("recalls_total", 0)), "rappels croisés")],146 f"Auto·Ka agrège {fr(total)} véhicules d'occasion depuis {fr(d.get('sources',0))} sources, "147 f"et croise automatiquement {fr(d.get('recalls_total',0))} rappels de sécurité avec les annonces.",148 0.55 + milestone_bonus(total),149 bars=[[x["region"], x["n"], fr(x["n"])] for x in by] if by else None,150 tagline="Le marché de l'occasion, décodé."))151 return out152153154def ex_food_ka(d):155 out = []156 total = d.get("total", 0)157 on_sale = d.get("on_sale", 0)158 out.append(mk("food-ka", "sales", fr(on_sale), "produits d'épicerie en solde en ce moment",159 [(fr(total), "produits suivis"), (fr(d.get("sources", 0)), "bannières"),160 (money(round(d.get("avg_price", 0), 2)).replace(" $", " $"), "prix moyen")],161 f"Food·Ka repère en ce moment {fr(on_sale)} produits d'épicerie en solde, "162 f"sur {fr(total)} produits suivis dans {fr(d.get('sources',0))} bannières.",163 0.6,164 tagline="L'épicerie comparée, chaque jour."))165 cat = sorted([x for x in d.get("by_category", []) if x.get("n")], key=lambda x: -x["n"])[:6]166 if cat:167 out.append(mk("food-ka", "categories", fr(total), "produits d'épicerie suivis",168 [(fr(on_sale), "en solde"), (fr(d.get("sources", 0)), "bannières"),169 (fr(d.get("categories", 0)), "catégories")],170 "Le panier québécois catégorie par catégorie, avec le prix moyen de chacune.",171 0.45,172 bars=[[x["category"], x["n"], fr(x["n"]), f"prix moyen {money(round(x['avg_price'],2))}"] for x in cat],173 tagline="L'épicerie comparée, chaque jour."))174 return out175176177def ex_fabri_ka(d):178 out = []179 t = d.get("totals", {})180 total = t.get("products", 0)181 by = sorted([x for x in d.get("by_region", []) if x.get("products")], key=lambda x: -x["products"])[:6]182 out.append(mk("fabri-ka", "products", fr(total), "produits québécois répertoriés",183 [(fr(t.get("stores_live", 0)), "boutiques en ligne"), (fr(t.get("regions", 0)), "régions"),184 (fr(t.get("stores_registry", 0)), "au registre")],185 f"Fabri·Ka répertorie {fr(total)} produits québécois provenant de {fr(t.get('stores_live',0))} "186 f"boutiques en ligne, dans les {fr(t.get('regions',0))} régions du Québec.",187 0.6 + milestone_bonus(total),188 bars=[[x["region"], x["products"], fr(x["products"])] for x in by] if by else None,189 tagline="Le savoir-faire québécois, agrégé."))190 return out191192193def ex_resto_ka(d):194 out = []195 total = d.get("restaurants", 0)196 out.append(mk("total", fr(total), "restaurants répertoriés au Québec",197 [(fr(d.get("items", 0)), "plats aux menus"), (fr(d.get("menus", 0)), "menus complets"),198 (fr(d.get("regions", 0)), "régions")],199 f"Resto·Ka répertorie {fr(total)} restaurants et {fr(d.get('items',0))} plats détaillés "200 f"dans {fr(d.get('menus',0))} menus complets, partout au Québec.",201 0.5 + milestone_bonus(total),202 tagline="Tous les restos du Québec, une seule adresse."))203 by = sorted([x for x in d.get("by_region", []) if x.get("n")], key=lambda x: -x["n"])[:6]204 if by:205 out.append(mk("regions", fr(total), "restaurants, par région",206 [(fr(d.get("items", 0)), "plats"), (fr(d.get("inspections", 0)), "inspections MAPAQ"),207 (fr(d.get("with_alcohol_permit", 0)), "permis d'alcool")],208 "Le Québec gourmand région par région, des grands centres aux régions.",209 0.4,210 bars=[[x["region"], x["n"], fr(x["n"])] for x in by],211 tagline="Tous les restos du Québec, une seule adresse."))212 return out213214215def ex_sorti_ka(d):216 out = []217 total = d.get("total_active", d.get("upcoming", 0))218 free = d.get("free_upcoming", 0)219 out.append(mk("sorti-ka", "free", fr(free), "événements GRATUITS à venir au Québec",220 [(fr(total), "événements actifs"), (fr(d.get("cities", 0)), "villes"),221 (fr(d.get("sources", 0)), "sources")],222 f"Sorti·Ka recense {fr(total)} événements à venir au Québec — dont {fr(free)} entièrement gratuits, "223 f"dans {fr(d.get('cities',0))} villes.",224 0.6,225 tagline="Toutes les sorties du Québec, en un coup d'œil."))226 return out227228229def ex_crea_ka(d):230 out = []231 total = d.get("creators", 0)232 bp = d.get("by_platform", {})233 bars = sorted(bp.items(), key=lambda x: -x[1])[:6]234 out.append(mk("creators", fr(total), "créateurs de contenu québécois recensés",235 [(fr(d.get("accounts", 0)), "comptes reliés"),236 (str(len(bp)), "plateformes"), (str(len(d.get("by_niche", {}))), "niches")],237 f"Créa·Ka a cartographié {fr(total)} créateurs de contenu québécois, avec {fr(d.get('accounts',0))} "238 f"comptes reliés sur {len(bp)} plateformes.",239 0.5 + milestone_bonus(total),240 bars=[[k.capitalize(), v, fr(v)] for k, v in bars] if bars else None,241 tagline="Les créateurs de contenu d'ici, cartographiés."))242 return out243244245def ex_job_ka(d):246 out = []247 total = d.get("total", 0)248 tc = sorted([x for x in d.get("top_cities", []) if x.get("n")], key=lambda x: -x["n"])[:6]249 out.append(mk("job-ka", "total", fr(total), "offres d'emploi agrégées au Québec",250 [(fr(d.get("employers", 0)), "employeurs"), (fr(d.get("sources", 0)), "sources"),251 (money(round(d.get("avg_salary_year", 0))), "salaire moyen")],252 f"Job·Ka agrège {fr(total)} offres d'emploi de {fr(d.get('employers',0))} employeurs, "253 f"dont {fr(d.get('remote',0))} en télétravail.",254 0.5 + milestone_bonus(total),255 bars=[[x["city"], x["n"], fr(x["n"])] for x in tc] if tc else None,256 tagline="Tous les emplois du Québec, au même endroit."))257 return out258259260def ex_trouve_ka(d):261 out = []262 pages = d.get("pages_indexed", 0)263 out.append(mk("pages", fr(pages), "pages web québécoises indexées",264 [(fr(d.get("domains_count", 0)), "domaines"), (fr(d.get("indexed_last_hour", 0)), "pages/heure"),265 ("100 %", "recherche sémantique")],266 f"Trouve·Ka a indexé {fr(pages)} pages web québécoises sur {fr(d.get('domains_count',0))} domaines, "267 f"au rythme de {fr(d.get('indexed_last_hour',0))} pages à l'heure.",268 0.55 + milestone_bonus(pages),269 tagline="Le moteur de recherche 100 % québécois."))270 return out271272273EXTRACT = {274 "lou-ka": ex_lou_ka, "immo-ka": ex_immo_ka, "vrai-prix": ex_vrai_prix,275 "auto-ka": ex_auto_ka, "food-ka": ex_food_ka, "fabri-ka": ex_fabri_ka, "sorti-ka": ex_sorti_ka,276 "job-ka": ex_job_ka,277}278279280def milestone_bonus(n):281 """Bonus de saillance si le nombre vient de franchir un cap rond."""282 try:283 n = float(n)284 except Exception:285 return 0.0286 if n <= 0:287 return 0.0288 for cap in (1_000_000, 500_000, 100_000, 50_000, 10_000):289 if n >= cap and n < cap * 1.06: # fraîchement franchi290 return 0.25291 return 0.0292293294def main():295 ap = argparse.ArgumentParser()296 ap.add_argument("--exclude", default="")297 ap.add_argument("--site", default="")298 ap.add_argument("--top", type=int, default=0)299 args = ap.parse_args()300 exclude = set(s for s in args.exclude.split(",") if s)301 sites = [args.site] if args.site else list(EXTRACT.keys())302303 cands = []304 errors = {}305 for site in sites:306 if site in exclude or site not in EXTRACT:307 continue308 try:309 d = get(URLS[site])310 cands.extend(EXTRACT[site](d))311 except Exception as e:312 errors[site] = str(e)313314 cands.sort(key=lambda c: -c["salience"])315 out = {"candidates": cands, "errors": errors}316 if args.top:317 out["candidates"] = cands[:args.top]318 print(json.dumps(out, ensure_ascii=False))319320321if __name__ == "__main__":322 main()323