SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%
14.3 KB · 323 lines python
Raw Blame History
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