SPB Git

spb/admin-ka

Public
14.7 KB · 329 lines python
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", "resto-ka": "#f08c00", "auto-ka": "#ff5a2a",23    "vrai-prix": LIME, "trouve-ka": LIME, "sorti-ka": LIME,24    "crea-ka": LIME, "job-ka": LIME, "groupe-ka": LIME,25}26LABELS = {27    "lou-ka": "Lou·Ka", "immo-ka": "Immo·Ka", "food-ka": "Food·Ka",28    "fabri-ka": "Fabri·Ka", "resto-ka": "Resto·Ka", "auto-ka": "Auto·Ka",29    "vrai-prix": "Vrai-Prix", "trouve-ka": "Trouve·Ka", "sorti-ka": "Sorti·Ka",30    "crea-ka": "Créa·Ka", "job-ka": "Job·Ka", "trouve-ka": "Trouve·Ka",31}32URLS = {33    "lou-ka": "https://www.lou-ka.com/api/stats/detailed",34    "immo-ka": "https://www.immo-ka.com/api/stats",35    "food-ka": "https://www.food-ka.com/api/stats",36    "fabri-ka": "https://www.fabri-ka.com/api/stats",37    "resto-ka": "https://www.resto-ka.com/api/stats",38    "auto-ka": "https://www.auto-ka.com/api/stats",39    "vrai-prix": "https://www.vrai-prix.com/api/stats",40    "sorti-ka": "https://www.sorti-ka.com/api/stats",41    "crea-ka": "https://www.crea-ka.com/api/stats",42    "job-ka": "https://www.job-ka.com/api/stats",43    "trouve-ka": "https://www.trouve-ka.com/api/status",44}454647def fr(n):48    try:49        n = int(round(float(n)))50    except Exception:51        return str(n)52    return f"{n:,}".replace(",", " ")535455def money(n):56    return fr(n) + " $"575859def get(url):60    req = urllib.request.Request(url, headers={"User-Agent": "KA-Social/1.0"})61    with urllib.request.urlopen(req, timeout=15, context=CTX) as r:62        return json.load(r)636465def mk(site, insight_id, headline, headline_label, tiles, fact, salience,66       bars=None, note=None, tagline=None):67    return {68        "site": site, "label": LABELS.get(site, site), "accent": ACCENT.get(site, LIME),69        "insight_id": insight_id, "headline": headline, "headline_label": headline_label,70        "tiles": tiles, "bars": bars, "note": note, "tagline": tagline,71        "fact": fact, "salience": round(salience, 2),72    }737475# ---- extracteurs par site : renvoient une liste de candidats ----7677def ex_lou_ka(d):78    t = d.get("totals", d)79    total = t.get("total", 0)80    out = []81    out.append(mk("lou-ka", "total", fr(total), "logements à louer actifs au Québec",82        [(fr(t.get("sources", 0)), "sources"), (fr(t.get("cities", 0)), "villes"),83         (money(t.get("median", 0)), "loyer médian")],84        f"Lou·Ka agrège maintenant {fr(total)} logements à louer actifs partout au Québec, "85        f"depuis {fr(t.get('sources',0))} sources, loyer médian {money(t.get('median',0))}.",86        0.55 + milestone_bonus(total),87        tagline="Tous les logements à louer du Québec, au même endroit."))88    # aubaines sous le marché (si dispo via dashboard non ici) — approx via by_type89    bt = d.get("by_type", [])90    bt = [x for x in bt if isinstance(x.get("avg_price"), (int, float)) and x.get("count", 0) > 500]91    if bt:92        bt = sorted(bt, key=lambda x: -x["count"])[:6]93        out.append(mk("lou-ka", "by_type", fr(sum(x["count"] for x in bt)),94            "logements ventilés par type",95            [(money(t.get("avg", 0)), "loyer moyen"), (money(t.get("median", 0)), "médian"),96             (fr(t.get("dispo_now", 0)), "dispo maintenant")],97            "Le marché locatif québécois par type de logement, du studio au 6½ — "98            "avec le loyer moyen de chacun.",99            0.5,100            bars=[[x["key"], x["count"], fr(x["count"]), f"loyer moyen {money(x['avg_price'])}"] for x in bt],101            tagline="Le marché locatif, mesuré en temps réel."))102    return out103104105def ex_immo_ka(d):106    out = []107    total = d.get("total", 0)108    vp = d.get("vraiprix", {}).get("ensemble", {})109    out.append(mk("immo-ka", "total", fr(total), "propriétés à vendre au Québec",110        [(fr(d.get("sources", 0)), "sources"), (fr(d.get("cities", 0)), "villes"),111         (money(d.get("avg_price", 0)), "prix moyen affiché")],112        f"Immo·Ka réunit {fr(total)} propriétés à vendre au Québec, dans {fr(d.get('cities',0))} villes.",113        0.5 + milestone_bonus(total),114        tagline="Toutes les propriétés à vendre, un seul site."))115    if vp.get("pct_sur10"):116        out.append(mk("immo-ka", "vraiprix", f"{str(vp['pct_sur10']).replace('.',',')} %",117            "des propriétés affichées à +10 % au-dessus de leur valeur réelle",118            [(f"{str(vp.get('pct_sous5',0)).replace('.',',')} %", "sous-évaluées"),119             (f"{str(vp.get('pct_juste',0)).replace('.',',')} %", "prix justes"),120             (f"+{str(vp.get('median_delta_pct',0)).replace('.',',')} %", "écart médian")],121            f"En croisant les prix affichés avec la valeur Vrai-Prix, Immo·Ka révèle que "122            f"{str(vp['pct_sur10']).replace('.',',')} % des propriétés sont affichées à plus de 10 % "123            f"au-dessus de leur valeur réelle estimée.",124            0.8,  # insight distinctif et fort125            tagline="Le seul site qui compare le prix affiché à la vraie valeur."))126    return out127128129def ex_vrai_prix(d):130    out = []131    units = d.get("units_total", 0)132    out.append(mk("vrai-prix", "units", fr(units), "unités d'évaluation couvertes au Québec",133        [(f"{fr(round(d.get('value_total_2026',0)/1e9))} G$", "valeur totale 2026"),134         (money(d.get("value_median_2026", 0)), "valeur médiane"),135         (f"+{str(d.get('growth_2021_2026_pct','')).replace('.',',')} %", "croissance 2021→2026")],136        f"Vrai-Prix évalue {fr(units)} unités — la quasi-totalité du parc immobilier québécois — "137        f"pour une valeur totale estimée de {fr(round(d.get('value_total_2026',0)/1e9))} milliards de dollars en 2026.",138        0.7 + milestone_bonus(units),139        note="Modèle hédonique + comparables — erreur médiane 11 %.",140        tagline="La vraie valeur de chaque propriété du Québec."))141    return out142143144def ex_auto_ka(d):145    out = []146    total = d.get("total", 0)147    by = sorted([x for x in d.get("by_region", []) if x.get("n")], key=lambda x: -x["n"])[:6]148    out.append(mk("auto-ka", "total", fr(total), "véhicules d'occasion en vente au Québec",149        [(fr(d.get("sources", 0)), "sources"), (money(d.get("avg_price", 0)), "prix moyen"),150         (fr(d.get("recalls_total", 0)), "rappels croisés")],151        f"Auto·Ka agrège {fr(total)} véhicules d'occasion depuis {fr(d.get('sources',0))} sources, "152        f"et croise automatiquement {fr(d.get('recalls_total',0))} rappels de sécurité avec les annonces.",153        0.55 + milestone_bonus(total),154        bars=[[x["region"], x["n"], fr(x["n"])] for x in by] if by else None,155        tagline="Le marché de l'occasion, décodé."))156    return out157158159def ex_food_ka(d):160    out = []161    total = d.get("total", 0)162    on_sale = d.get("on_sale", 0)163    out.append(mk("food-ka", "sales", fr(on_sale), "produits d'épicerie en solde en ce moment",164        [(fr(total), "produits suivis"), (fr(d.get("sources", 0)), "bannières"),165         (money(round(d.get("avg_price", 0), 2)).replace(" $", " $"), "prix moyen")],166        f"Food·Ka repère en ce moment {fr(on_sale)} produits d'épicerie en solde, "167        f"sur {fr(total)} produits suivis dans {fr(d.get('sources',0))} bannières.",168        0.6,169        tagline="L'épicerie comparée, chaque jour."))170    cat = sorted([x for x in d.get("by_category", []) if x.get("n")], key=lambda x: -x["n"])[:6]171    if cat:172        out.append(mk("food-ka", "categories", fr(total), "produits d'épicerie suivis",173            [(fr(on_sale), "en solde"), (fr(d.get("sources", 0)), "bannières"),174             (fr(d.get("categories", 0)), "catégories")],175            "Le panier québécois catégorie par catégorie, avec le prix moyen de chacune.",176            0.45,177            bars=[[x["category"], x["n"], fr(x["n"]), f"prix moyen {money(round(x['avg_price'],2))}"] for x in cat],178            tagline="L'épicerie comparée, chaque jour."))179    return out180181182def ex_fabri_ka(d):183    out = []184    t = d.get("totals", {})185    total = t.get("products", 0)186    by = sorted([x for x in d.get("by_region", []) if x.get("products")], key=lambda x: -x["products"])[:6]187    out.append(mk("fabri-ka", "products", fr(total), "produits québécois répertoriés",188        [(fr(t.get("stores_live", 0)), "boutiques en ligne"), (fr(t.get("regions", 0)), "régions"),189         (fr(t.get("stores_registry", 0)), "au registre")],190        f"Fabri·Ka répertorie {fr(total)} produits québécois provenant de {fr(t.get('stores_live',0))} "191        f"boutiques en ligne, dans les {fr(t.get('regions',0))} régions du Québec.",192        0.6 + milestone_bonus(total),193        bars=[[x["region"], x["products"], fr(x["products"])] for x in by] if by else None,194        tagline="Le savoir-faire québécois, agrégé."))195    return out196197198def ex_resto_ka(d):199    out = []200    total = d.get("restaurants", 0)201    out.append(mk("resto-ka", "total", fr(total), "restaurants répertoriés au Québec",202        [(fr(d.get("items", 0)), "plats aux menus"), (fr(d.get("menus", 0)), "menus complets"),203         (fr(d.get("regions", 0)), "régions")],204        f"Resto·Ka répertorie {fr(total)} restaurants et {fr(d.get('items',0))} plats détaillés "205        f"dans {fr(d.get('menus',0))} menus complets, partout au Québec.",206        0.5 + milestone_bonus(total),207        tagline="Tous les restos du Québec, une seule adresse."))208    by = sorted([x for x in d.get("by_region", []) if x.get("n")], key=lambda x: -x["n"])[:6]209    if by:210        out.append(mk("resto-ka", "regions", fr(total), "restaurants, par région",211            [(fr(d.get("items", 0)), "plats"), (fr(d.get("inspections", 0)), "inspections MAPAQ"),212             (fr(d.get("with_alcohol_permit", 0)), "permis d'alcool")],213            "Le Québec gourmand région par région, des grands centres aux régions.",214            0.4,215            bars=[[x["region"], x["n"], fr(x["n"])] for x in by],216            tagline="Tous les restos du Québec, une seule adresse."))217    return out218219220def ex_sorti_ka(d):221    out = []222    total = d.get("total_active", d.get("upcoming", 0))223    free = d.get("free_upcoming", 0)224    out.append(mk("sorti-ka", "free", fr(free), "événements GRATUITS à venir au Québec",225        [(fr(total), "événements actifs"), (fr(d.get("cities", 0)), "villes"),226         (fr(d.get("sources", 0)), "sources")],227        f"Sorti·Ka recense {fr(total)} événements à venir au Québec — dont {fr(free)} entièrement gratuits, "228        f"dans {fr(d.get('cities',0))} villes.",229        0.6,230        tagline="Toutes les sorties du Québec, en un coup d'œil."))231    return out232233234def ex_crea_ka(d):235    out = []236    total = d.get("creators", 0)237    bp = d.get("by_platform", {})238    bars = sorted(bp.items(), key=lambda x: -x[1])[:6]239    out.append(mk("crea-ka", "creators", fr(total), "créateurs de contenu québécois recensés",240        [(fr(d.get("accounts", 0)), "comptes reliés"),241         (str(len(bp)), "plateformes"), (str(len(d.get("by_niche", {}))), "niches")],242        f"Créa·Ka a cartographié {fr(total)} créateurs de contenu québécois, avec {fr(d.get('accounts',0))} "243        f"comptes reliés sur {len(bp)} plateformes.",244        0.5 + milestone_bonus(total),245        bars=[[k.capitalize(), v, fr(v)] for k, v in bars] if bars else None,246        tagline="Les créateurs de contenu d'ici, cartographiés."))247    return out248249250def ex_job_ka(d):251    out = []252    total = d.get("total", 0)253    tc = sorted([x for x in d.get("top_cities", []) if x.get("n")], key=lambda x: -x["n"])[:6]254    out.append(mk("job-ka", "total", fr(total), "offres d'emploi agrégées au Québec",255        [(fr(d.get("employers", 0)), "employeurs"), (fr(d.get("sources", 0)), "sources"),256         (money(round(d.get("avg_salary_year", 0))), "salaire moyen")],257        f"Job·Ka agrège {fr(total)} offres d'emploi de {fr(d.get('employers',0))} employeurs, "258        f"dont {fr(d.get('remote',0))} en télétravail.",259        0.5 + milestone_bonus(total),260        bars=[[x["city"], x["n"], fr(x["n"])] for x in tc] if tc else None,261        tagline="Tous les emplois du Québec, au même endroit."))262    return out263264265def ex_trouve_ka(d):266    out = []267    pages = d.get("pages_indexed", 0)268    out.append(mk("trouve-ka", "pages", fr(pages), "pages web québécoises indexées",269        [(fr(d.get("domains_count", 0)), "domaines"), (fr(d.get("indexed_last_hour", 0)), "pages/heure"),270         ("100 %", "recherche sémantique")],271        f"Trouve·Ka a indexé {fr(pages)} pages web québécoises sur {fr(d.get('domains_count',0))} domaines, "272        f"au rythme de {fr(d.get('indexed_last_hour',0))} pages à l'heure.",273        0.55 + milestone_bonus(pages),274        tagline="Le moteur de recherche 100 % québécois."))275    return out276277278EXTRACT = {279    "lou-ka": ex_lou_ka, "immo-ka": ex_immo_ka, "vrai-prix": ex_vrai_prix,280    "auto-ka": ex_auto_ka, "food-ka": ex_food_ka, "fabri-ka": ex_fabri_ka,281    "resto-ka": ex_resto_ka, "sorti-ka": ex_sorti_ka, "crea-ka": ex_crea_ka,282    "job-ka": ex_job_ka, "trouve-ka": ex_trouve_ka,283}284285286def milestone_bonus(n):287    """Bonus de saillance si le nombre vient de franchir un cap rond."""288    try:289        n = float(n)290    except Exception:291        return 0.0292    if n <= 0:293        return 0.0294    for cap in (1_000_000, 500_000, 100_000, 50_000, 10_000):295        if n >= cap and n < cap * 1.06:  # fraîchement franchi296            return 0.25297    return 0.0298299300def main():301    ap = argparse.ArgumentParser()302    ap.add_argument("--exclude", default="")303    ap.add_argument("--site", default="")304    ap.add_argument("--top", type=int, default=0)305    args = ap.parse_args()306    exclude = set(s for s in args.exclude.split(",") if s)307    sites = [args.site] if args.site else list(EXTRACT.keys())308309    cands = []310    errors = {}311    for site in sites:312        if site in exclude or site not in EXTRACT:313            continue314        try:315            d = get(URLS[site])316            cands.extend(EXTRACT[site](d))317        except Exception as e:318            errors[site] = str(e)319320    cands.sort(key=lambda c: -c["salience"])321    out = {"candidates": cands, "errors": errors}322    if args.top:323        out["candidates"] = cands[:args.top]324    print(json.dumps(out, ensure_ascii=False))325326327if __name__ == "__main__":328    main()329
Files · ee1f78227355f7efe4a3ebffbf93acfb5e6404a1
Menu

Theme